diff --git a/Package.resolved b/Package.resolved index 5729a11..0496bea 100644 --- a/Package.resolved +++ b/Package.resolved @@ -116,6 +116,15 @@ "revision" : "da637c398c5d08896521b737f2868ddc2e7996ae", "version" : "0.50.6" } + }, + { + "identity" : "texttable", + "kind" : "remoteSourceControl", + "location" : "https://github.com/cfilipov/TextTable", + "state" : { + "branch" : "master", + "revision" : "e03289289155b4e7aa565e32862f9cb42140596a" + } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index 7b729e5..de9e7f4 100644 --- a/Package.swift +++ b/Package.swift @@ -20,6 +20,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")), .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"), .package(url: "https://github.com/getsentry/sentry-cocoa", from: "7.31.3"), + .package(url: "https://github.com/cfilipov/TextTable", branch: "master"), ], targets: [ .executableTarget(name: "tart", dependencies: [ @@ -32,7 +33,7 @@ let package = Package( .product(name: "Antlr4Static", package: "Antlr4"), .product(name: "Atomics", package: "swift-atomics"), .product(name: "Sentry", package: "sentry-cocoa"), - + .product(name: "TextTable", package: "TextTable"), ], exclude: [ "OCI/Reference/Makefile", "OCI/Reference/Reference.g4", diff --git a/Sources/tart/Commands/Get.swift b/Sources/tart/Commands/Get.swift index a061e88..9867c67 100644 --- a/Sources/tart/Commands/Get.swift +++ b/Sources/tart/Commands/Get.swift @@ -1,52 +1,31 @@ import ArgumentParser import Foundation +fileprivate struct VMInfo: Encodable { + let CPU: Int + let Memory: UInt64 + let Disk: Int + let Display: String + let Running: Bool +} + struct Get: AsyncParsableCommand { static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration") @Argument(help: "VM name.") var name: String - @Flag(help: "Number of VM CPUs.") - var cpu: Bool = false - - @Flag(help: "VM memory size in megabytes.") - var memory: Bool = false - - @Flag(help: "Disk size in gigabytes.") - var diskSize: Bool = false - - @Flag(help: "VM display resolution in a format of x. For example, 1200x800.") - var display: Bool = false - - func validate() throws { - if [cpu, memory, diskSize, display].filter({$0}).count > 1 { - throw ValidationError("Options --cpu, --memory, --disk-size and --display are mutually exclusive") - } - } + @Option(help: "Output format: text or json") + var format: Format = .text func run() async throws { let vmDir = try VMStorageLocal().open(name) let vmConfig = try VMConfig(fromURL: vmDir.configURL) - let diskSizeInGb = try vmDir.sizeBytes() / 1000 / 1000 / 1000 - let memorySizeInMb = vmConfig.memorySize / 1024 / 1024 + let diskSizeInGb = try vmDir.sizeGB() + let memorySizeInMb = vmConfig.memorySize / 1024 / 1024 + let running = try PIDLock(lockURL: vmDir.configURL).pid() > 0 - if cpu { - print(vmConfig.cpuCount) - } else if memory { - print(memorySizeInMb) - } else if diskSize { - print(diskSizeInGb) - } else if display { - print("\(vmConfig.display.width)x\(vmConfig.display.height)") - } else { - print( - "CPU\tMemory\tDisk\tDisplay\n" + - "\(vmConfig.cpuCount)\t" + - "\(memorySizeInMb) MB\t" + - "\(diskSizeInGb) GB\t" + - "\(vmConfig.display)" - ) - } + let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb, Display: vmConfig.display.description, Running: running) + print(format.renderSingle(info)) } } diff --git a/Sources/tart/Commands/List.swift b/Sources/tart/Commands/List.swift index 0c7f8b1..f7c864c 100644 --- a/Sources/tart/Commands/List.swift +++ b/Sources/tart/Commands/List.swift @@ -2,15 +2,24 @@ import ArgumentParser import Dispatch import SwiftUI +fileprivate struct VMInfo: Encodable { + let Source: String + let Name: String + let Size: Int +} + struct List: AsyncParsableCommand { static var configuration = CommandConfiguration(abstract: "List created VMs") - @Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names.")) - var quiet: Bool = false - @Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci).")) var source: String? + @Option(help: "Output format: text or json") + var format: Format = .text + + @Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names.")) + var quiet: Bool = false + func validate() throws { guard let source = source else { return @@ -22,27 +31,28 @@ struct List: AsyncParsableCommand { } func run() async throws { - if !quiet { - print("Source\tName") - } - + var infos: [VMInfo] = [] if source == nil || source == "local" { - displayTable("local", try VMStorageLocal().list()) + infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in + try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB()) + }) } if source == nil || source == "oci" { - displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) }) + infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in + try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB()) + }) + } + if (quiet) { + for info in infos { + print(info.Name) + } + } else { + print(format.renderList(infos)) } } - private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) { - for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) { - if quiet { - print(name) - } else { - let source = source.padding(toLength: "Source".count, withPad: " ", startingAt: 0) - print("\(source)\t\(name)") - } - } + private func sortedInfos(_ infos: [VMInfo]) -> [VMInfo] { + infos.sorted(by: { left, right in left.Name < right.Name }) } } diff --git a/Sources/tart/Formatter/Format.swift b/Sources/tart/Formatter/Format.swift new file mode 100644 index 0000000..912d6be --- /dev/null +++ b/Sources/tart/Formatter/Format.swift @@ -0,0 +1,41 @@ +import ArgumentParser +import Foundation +import TextTable + +enum Format: String, ExpressibleByArgument, CaseIterable { + case text, json + + private(set) static var allValueStrings: [String] = Format.allCases.map { "\($0)"} + + func renderSingle(_ data: T) -> String where T: Encodable { + switch self { + case .text: + return renderList([data]) + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + return try! encoder.encode(data).asText() + } + } + + func renderList(_ data: Array) -> String where T: Encodable { + switch self { + case .text: + if (data.count == 0) { + return "" + } + let table = TextTable { (item: T) in + let mirroredObject = Mirror(reflecting: item) + return mirroredObject.children.enumerated().map { (_, element) in + let fieldName = element.label! + return Column(title: fieldName, value: element.value) + } + } + return table.string(for: data, style: Style.plain)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + return try! encoder.encode(data).asText() + } + } +} diff --git a/Sources/tart/VMDirectory.swift b/Sources/tart/VMDirectory.swift index 707125b..813915c 100644 --- a/Sources/tart/VMDirectory.swift +++ b/Sources/tart/VMDirectory.swift @@ -107,6 +107,10 @@ struct VMDirectory: Prunable { try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes() } + func sizeGB() throws -> Int { + try sizeBytes() / 1000 / 1000 / 1000 + } + func markExplicitlyPulled() { FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil) }