From 6922c1b3f42763e072e150e51caf66e2b956a536 Mon Sep 17 00:00:00 2001 From: Raj Date: Thu, 2 Apr 2026 16:54:58 -0700 Subject: [PATCH 1/2] Add shared list formatting infrastructure for CLI commands --- Package.swift | 7 + Sources/ContainerCommands/Application.swift | 5 - .../Builder/BuilderStatus.swift | 73 +++-- .../Container/ContainerInspect.swift | 4 +- .../Container/ContainerList.swift | 54 ++-- .../Container/ContainerStats.swift | 3 +- .../Image/ImageInspect.swift | 5 +- .../ContainerCommands/Image/ImageList.swift | 253 ++++++++-------- .../ContainerCommands/ListDisplayable.swift | 29 ++ .../{Codable+JSON.swift => ListFormat.swift} | 11 +- .../Network/NetworkInspect.swift | 4 +- .../Network/NetworkList.swift | 50 +--- .../ContainerCommands/OutputRendering.swift | 60 ++++ .../Registry/RegistryList.swift | 55 ++-- .../System/DNS/DNSList.swift | 45 ++- .../System/Property/PropertyList.swift | 53 ++-- .../ContainerCommands/System/SystemDF.swift | 12 +- .../System/SystemStatus.swift | 9 +- .../System/SystemVersion.swift | 7 +- .../TableOutput.swift | 6 +- .../Volume/VolumeInspect.swift | 11 +- .../ContainerCommands/Volume/VolumeList.swift | 69 ++--- .../ListFormattingTests.swift | 280 ++++++++++++++++++ 23 files changed, 704 insertions(+), 401 deletions(-) create mode 100644 Sources/ContainerCommands/ListDisplayable.swift rename Sources/ContainerCommands/{Codable+JSON.swift => ListFormat.swift} (72%) create mode 100644 Sources/ContainerCommands/OutputRendering.swift rename Sources/{Services/ContainerAPIService/Client => ContainerCommands}/TableOutput.swift (96%) create mode 100644 Tests/ContainerCommandsTests/ListFormattingTests.swift diff --git a/Package.swift b/Package.swift index a9d7e90d2..4800d22c5 100644 --- a/Package.swift +++ b/Package.swift @@ -122,6 +122,13 @@ let package = Package( "ContainerBuild" ] ), + .testTarget( + name: "ContainerCommandsTests", + dependencies: [ + "ContainerCommands", + "ContainerResource", + ] + ), .executableTarget( name: "container-apiserver", dependencies: [ diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index c0733a0be..567bb80fb 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -245,11 +245,6 @@ extension Application { print(altered) } - public enum ListFormat: String, CaseIterable, ExpressibleByArgument { - case json - case table - } - func isTranslated() throws -> Bool { do { return try Sysctl.byName("sysctl.proc_translated") == 1 diff --git a/Sources/ContainerCommands/Builder/BuilderStatus.swift b/Sources/ContainerCommands/Builder/BuilderStatus.swift index 5699d7518..eaf4a12a8 100644 --- a/Sources/ContainerCommands/Builder/BuilderStatus.swift +++ b/Sources/ContainerCommands/Builder/BuilderStatus.swift @@ -45,10 +45,20 @@ extension Application { do { let client = ContainerClient() let container = try await client.get(id: "buildkit") - try printContainers(containers: [container], format: format) + + if format == .json { + try emit(renderJSON([PrintableContainer(container)])) + return + } + + if quiet && container.status != .running { + return + } + + emit(renderList([PrintableBuilder(container)], quiet: quiet)) } catch { - if error is ContainerizationError { - if (error as? ContainerizationError)?.code == .notFound && !quiet { + if let czError = error as? ContainerizationError, czError.code == .notFound { + if !quiet { print("builder is not running") return } @@ -56,49 +66,32 @@ extension Application { throw error } } + } +} - private func createHeader() -> [[String]] { - [["ID", "IMAGE", "STATE", "ADDR", "CPUS", "MEMORY"]] - } - - private func printContainers(containers: [ContainerSnapshot], format: ListFormat) throws { - if format == .json { - let printables = containers.map { - PrintableContainer($0) - } - let data = try JSONEncoder().encode(printables) - print(String(decoding: data, as: UTF8.self)) - - return - } - - if self.quiet { - containers - .filter { $0.status == .running } - .forEach { print($0.id) } - return - } +private struct PrintableBuilder: ListDisplayable { + let snapshot: ContainerSnapshot - var rows = createHeader() - for container in containers { - rows.append(container.asRow) - } + init(_ snapshot: ContainerSnapshot) { + self.snapshot = snapshot + } - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } + static var tableHeader: [String] { + ["ID", "IMAGE", "STATE", "ADDR", "CPUS", "MEMORY"] } -} -extension ContainerSnapshot { - fileprivate var asRow: [String] { + var tableRow: [String] { [ - self.id, - self.configuration.image.reference, - self.status.rawValue, - self.networks.compactMap { $0.ipv4Address.description }.joined(separator: ","), - "\(self.configuration.resources.cpus)", - "\(self.configuration.resources.memoryInBytes / (1024 * 1024)) MB", + snapshot.id, + snapshot.configuration.image.reference, + snapshot.status.rawValue, + snapshot.networks.map { $0.ipv4Address.description }.joined(separator: ","), + "\(snapshot.configuration.resources.cpus)", + "\(snapshot.configuration.resources.memoryInBytes / (1024 * 1024)) MB", ] } + + var quietValue: String { + snapshot.id + } } diff --git a/Sources/ContainerCommands/Container/ContainerInspect.swift b/Sources/ContainerCommands/Container/ContainerInspect.swift index 322798c4f..9bd0f0cdd 100644 --- a/Sources/ContainerCommands/Container/ContainerInspect.swift +++ b/Sources/ContainerCommands/Container/ContainerInspect.swift @@ -36,12 +36,12 @@ extension Application { public func run() async throws { let client = ContainerClient() - let objects: [any Codable] = try await client.list().filter { + let containers = try await client.list().filter { containerIds.contains($0.id) }.map { PrintableContainer($0) } - print(try objects.jsonArray()) + try emit(renderJSON(containers)) } } } diff --git a/Sources/ContainerCommands/Container/ContainerList.swift b/Sources/ContainerCommands/Container/ContainerList.swift index 53b110bfa..5e8cdcd05 100644 --- a/Sources/ContainerCommands/Container/ContainerList.swift +++ b/Sources/ContainerCommands/Container/ContainerList.swift @@ -46,59 +46,43 @@ extension Application { let client = ContainerClient() let filters = self.all ? ContainerListFilters.all : ContainerListFilters(status: .running) let containers = try await client.list(filters: filters) - try printContainers(containers: containers, format: format) - } - - private func createHeader() -> [[String]] { - [["ID", "IMAGE", "OS", "ARCH", "STATE", "ADDR", "CPUS", "MEMORY", "STARTED"]] - } + let items = containers.map { PrintableContainer($0) } - private func printContainers(containers: [ContainerSnapshot], format: ListFormat) throws { if format == .json { - let printables = containers.map { - PrintableContainer($0) - } - let data = try JSONEncoder().encode(printables) - print(String(decoding: data, as: UTF8.self)) - - return - } - - if self.quiet { - containers.forEach { - print($0.id) - } + try emit(renderJSON(items)) return } - var rows = createHeader() - for container in containers { - rows.append(container.asRow) - } - - let formatter = TableOutput(rows: rows) - print(formatter.format()) + emit(renderList(items, quiet: quiet)) } } } -extension ContainerSnapshot { - fileprivate var asRow: [String] { +extension PrintableContainer: ListDisplayable { + static var tableHeader: [String] { + ["ID", "IMAGE", "OS", "ARCH", "STATE", "ADDR", "CPUS", "MEMORY", "STARTED"] + } + + var tableRow: [String] { [ - self.id, + self.configuration.id, self.configuration.image.reference, - self.platform.os, - self.platform.architecture, + self.configuration.platform.os, + self.configuration.platform.architecture, self.status.rawValue, - self.networks.compactMap { $0.ipv4Address.description }.joined(separator: ","), + self.networks.map { $0.ipv4Address.description }.joined(separator: ","), "\(self.configuration.resources.cpus)", "\(self.configuration.resources.memoryInBytes / (1024 * 1024)) MB", - self.startedDate.map { ISO8601DateFormatter().string(from: $0) } ?? "", + self.startedDate?.ISO8601Format() ?? "", ] } + + var quietValue: String { + self.configuration.id + } } -struct PrintableContainer: Codable { +struct PrintableContainer: Codable, Sendable { let status: RuntimeStatus let configuration: ContainerConfiguration let networks: [Attachment] diff --git a/Sources/ContainerCommands/Container/ContainerStats.swift b/Sources/ContainerCommands/Container/ContainerStats.swift index f2090dd18..a63da159c 100644 --- a/Sources/ContainerCommands/Container/ContainerStats.swift +++ b/Sources/ContainerCommands/Container/ContainerStats.swift @@ -104,8 +104,7 @@ extension Application { if format == .json { let jsonStats = statsData.map { $0.stats2 } - let data = try JSONEncoder().encode(jsonStats) - print(String(decoding: data, as: UTF8.self)) + try emit(renderJSON(jsonStats)) return } diff --git a/Sources/ContainerCommands/Image/ImageInspect.swift b/Sources/ContainerCommands/Image/ImageInspect.swift index dba80a321..c8766cd47 100644 --- a/Sources/ContainerCommands/Image/ImageInspect.swift +++ b/Sources/ContainerCommands/Image/ImageInspect.swift @@ -17,6 +17,7 @@ import ArgumentParser import ContainerAPIClient import ContainerLog +import ContainerResource import ContainerizationError import Foundation import Logging @@ -42,7 +43,7 @@ extension Application { } public func run() async throws { - var printable = [any Codable]() + var printable: [ImageDetail] = [] var succeededImages: [String] = [] var allErrors: [(String, Error)] = [] @@ -59,7 +60,7 @@ extension Application { } if !printable.isEmpty { - print(try printable.jsonArray()) + try emit(renderJSON(printable)) } if !allErrors.isEmpty { diff --git a/Sources/ContainerCommands/Image/ImageList.swift b/Sources/ContainerCommands/Image/ImageList.swift index 7e03b583e..2a46f5381 100644 --- a/Sources/ContainerCommands/Image/ImageList.swift +++ b/Sources/ContainerCommands/Image/ImageList.swift @@ -23,7 +23,13 @@ import Foundation import SwiftProtobuf extension Application { - public struct ListImageOptions: ParsableArguments { + public struct ImageList: AsyncLoggableCommand { + public init() {} + public static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List images", + aliases: ["ls"]) + @Option(name: .long, help: "Format of the output") var format: ListFormat = .table @@ -33,26 +39,87 @@ extension Application { @Flag(name: .shortAndLong, help: "Verbose output") var verbose = false - public init() {} - } + @OptionGroup + public var logOptions: Flags.Logging + + public mutating func run() async throws { + try Self.validate(format: format, quiet: quiet, verbose: verbose) + + var images = try await ClientImage.list().filter { img in + !Utility.isInfraImage(name: img.reference) + } + images.sort { $0.reference < $1.reference } + + if format == .json { + try await Self.emitJSON(images: images) + return + } + + if quiet { + for image in images { + let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + print(processedReferenceString) + } + return + } - struct ListImageImplementation { - static func createHeader() -> [[String]] { - [["NAME", "TAG", "DIGEST"]] + if verbose { + let items = try await Self.buildVerboseItems(images: images) + emit(renderTable(items)) + return + } + + let items = try await Self.buildTableItems(images: images) + emit(renderTable(items)) } - static func createVerboseHeader() -> [[String]] { - [["NAME", "TAG", "INDEX DIGEST", "OS", "ARCH", "VARIANT", "FULL SIZE", "CREATED", "MANIFEST DIGEST"]] + private static func validate(format: ListFormat, quiet: Bool, verbose: Bool) throws { + if quiet && verbose { + throw ContainerizationError(.invalidArgument, message: "cannot use flag --quiet and --verbose together") + } + let modifier = quiet || verbose + if modifier && format == .json { + throw ContainerizationError(.invalidArgument, message: "cannot use flag --quiet or --verbose along with --format json") + } } - static func printImagesVerbose(images: [ClientImage]) async throws { + private static func emitJSON(images: [ClientImage]) async throws { + let formatter = ByteCountFormatter() + var printableImages: [PrintableImage] = [] + for image in images { + let size = try await ClientImage.getFullImageSize(image: image) + let formattedSize = formatter.string(fromByteCount: size) + printableImages.append( + PrintableImage(reference: image.reference, fullSize: formattedSize, descriptor: image.descriptor) + ) + } + try emit(renderJSON(printableImages)) + } - var rows = createVerboseHeader() + private static func buildTableItems(images: [ClientImage]) async throws -> [ImageRow] { + var items: [ImageRow] = [] + for image in images { + let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) + let digest = try await image.resolved().digest + items.append( + ImageRow( + name: reference.name, + tag: reference.tag ?? "", + trimmedDigest: Utility.trimDigest(digest: digest) + )) + } + return items + } + + private static func buildVerboseItems(images: [ClientImage]) async throws -> [VerboseImageRow] { + let formatter = ByteCountFormatter() + var items: [VerboseImageRow] = [] for image in images { - let formatter = ByteCountFormatter() let imageDigest = try await image.resolved().digest + let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) for descriptor in try await image.index().manifests { - // Don't list attestation manifests if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], referenceType == "attestation-manifest" { @@ -63,10 +130,6 @@ extension Application { continue } - let os = platform.os - let arch = platform.architecture - let variant = platform.variant ?? "" - var config: ContainerizationOCI.Image var manifest: ContainerizationOCI.Manifest do { @@ -77,126 +140,74 @@ extension Application { } let created = config.created ?? "" - let size = descriptor.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size }) - let formattedSize = formatter.string(fromByteCount: size) - - let processedReferenceString = try ClientImage.denormalizeReference(image.reference) - let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) - let row = [ - reference.name, - reference.tag ?? "", - Utility.trimDigest(digest: imageDigest), - os, - arch, - variant, - formattedSize, - created, - Utility.trimDigest(digest: descriptor.digest), - ] - rows.append(row) - } - } - - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } - - static func printImages(images: [ClientImage], format: ListFormat, options: ListImageOptions) async throws { - var images = images - images.sort { - $0.reference < $1.reference - } - - if format == .json { - var printableImages: [PrintableImage] = [] - for image in images { - let formatter = ByteCountFormatter() - let size = try await ClientImage.getFullImageSize(image: image) + let size = descriptor.size + manifest.config.size + manifest.layers.reduce(0) { $0 + $1.size } let formattedSize = formatter.string(fromByteCount: size) - printableImages.append( - PrintableImage(reference: image.reference, fullSize: formattedSize, descriptor: image.descriptor) - ) - } - let data = try JSONEncoder().encode(printableImages) - print(String(decoding: data, as: UTF8.self)) - return - } - - if options.quiet { - try images.forEach { image in - let processedReferenceString = try ClientImage.denormalizeReference(image.reference) - print(processedReferenceString) + items.append( + VerboseImageRow( + name: reference.name, + tag: reference.tag ?? "", + indexDigest: Utility.trimDigest(digest: imageDigest), + os: platform.os, + arch: platform.architecture, + variant: platform.variant ?? "", + fullSize: formattedSize, + created: created, + manifestDigest: Utility.trimDigest(digest: descriptor.digest) + )) } - return - } - - if options.verbose { - try await Self.printImagesVerbose(images: images) - return - } - - var rows = createHeader() - for image in images { - let processedReferenceString = try ClientImage.denormalizeReference(image.reference) - let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) - let digest = try await image.resolved().digest - rows.append([ - reference.name, - reference.tag ?? "", - Utility.trimDigest(digest: digest), - ]) - } - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } - - static func validate(options: ListImageOptions) throws { - if options.quiet && options.verbose { - throw ContainerizationError(.invalidArgument, message: "cannot use flag --quiet and --verbose together") - } - let modifier = options.quiet || options.verbose - if modifier && options.format == .json { - throw ContainerizationError(.invalidArgument, message: "cannot use flag --quiet or --verbose along with --format json") } - } - - static func listImages(options: ListImageOptions) async throws { - let images = try await ClientImage.list().filter { img in - !Utility.isInfraImage(name: img.reference) - } - try await printImages(images: images, format: options.format, options: options) + return items } struct PrintableImage: Codable { let reference: String let fullSize: String let descriptor: Descriptor - - init(reference: String, fullSize: String, descriptor: Descriptor) { - self.reference = reference - self.fullSize = fullSize - self.descriptor = descriptor - } } } +} - public struct ImageList: AsyncLoggableCommand { - public init() {} - public static let configuration = CommandConfiguration( - commandName: "list", - abstract: "List images", - aliases: ["ls"]) +private struct ImageRow: ListDisplayable { + let name: String + let tag: String + let trimmedDigest: String - @OptionGroup - var options: ListImageOptions + static var tableHeader: [String] { + ["NAME", "TAG", "DIGEST"] + } - @OptionGroup - public var logOptions: Flags.Logging + var tableRow: [String] { + [name, tag, trimmedDigest] + } - public mutating func run() async throws { - try ListImageImplementation.validate(options: options) - try await ListImageImplementation.listImages(options: options) - } + // Required by ListDisplayable but unused — ImageList handles quiet mode + // separately to avoid expensive digest resolution. + var quietValue: String { + name + } +} + +private struct VerboseImageRow: ListDisplayable { + let name: String + let tag: String + let indexDigest: String + let os: String + let arch: String + let variant: String + let fullSize: String + let created: String + let manifestDigest: String + + static var tableHeader: [String] { + ["NAME", "TAG", "INDEX DIGEST", "OS", "ARCH", "VARIANT", "FULL SIZE", "CREATED", "MANIFEST DIGEST"] + } + + var tableRow: [String] { + [name, tag, indexDigest, os, arch, variant, fullSize, created, manifestDigest] + } + + var quietValue: String { + name } } diff --git a/Sources/ContainerCommands/ListDisplayable.swift b/Sources/ContainerCommands/ListDisplayable.swift new file mode 100644 index 000000000..3e6529fed --- /dev/null +++ b/Sources/ContainerCommands/ListDisplayable.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +/// A type that can be rendered as a table row or quiet-mode output. +/// +/// Conformers provide the column headers, row values, and a primary identifier +/// for quiet mode. JSON encoding is handled separately by each command using +/// its own data model. +protocol ListDisplayable { + /// Column headers for table output (e.g., `["ID", "IMAGE", "STATE"]`). + static var tableHeader: [String] { get } + /// The values for each column, matching the order of ``tableHeader``. + var tableRow: [String] { get } + /// The primary identifier shown in `--quiet` mode (typically ID or name). + var quietValue: String { get } +} diff --git a/Sources/ContainerCommands/Codable+JSON.swift b/Sources/ContainerCommands/ListFormat.swift similarity index 72% rename from Sources/ContainerCommands/Codable+JSON.swift rename to Sources/ContainerCommands/ListFormat.swift index 432a1e637..6083acbcd 100644 --- a/Sources/ContainerCommands/Codable+JSON.swift +++ b/Sources/ContainerCommands/ListFormat.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025-2026 Apple Inc. and the container project authors. +// Copyright © 2026 Apple Inc. and the container project authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -14,10 +14,9 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import Foundation +import ArgumentParser -extension [any Codable] { - func jsonArray() throws -> String { - "[\(try self.map { String(decoding: try JSONEncoder().encode($0), as: UTF8.self) }.joined(separator: ","))]" - } +public enum ListFormat: String, CaseIterable, ExpressibleByArgument, Sendable { + case json + case table } diff --git a/Sources/ContainerCommands/Network/NetworkInspect.swift b/Sources/ContainerCommands/Network/NetworkInspect.swift index 1f3da08d1..8e11b08c4 100644 --- a/Sources/ContainerCommands/Network/NetworkInspect.swift +++ b/Sources/ContainerCommands/Network/NetworkInspect.swift @@ -34,12 +34,12 @@ extension Application { public init() {} public func run() async throws { - let objects: [any Codable] = try await ClientNetwork.list().filter { + let items = try await ClientNetwork.list().filter { networks.contains($0.id) }.map { PrintableNetwork($0) } - print(try objects.jsonArray()) + try emit(renderJSON(items)) } } } diff --git a/Sources/ContainerCommands/Network/NetworkList.swift b/Sources/ContainerCommands/Network/NetworkList.swift index 6f30b1f3e..879d2902a 100644 --- a/Sources/ContainerCommands/Network/NetworkList.swift +++ b/Sources/ContainerCommands/Network/NetworkList.swift @@ -41,54 +41,36 @@ extension Application { public func run() async throws { let networks = try await ClientNetwork.list() - try printNetworks(networks: networks, format: format) - } - - private func createHeader() -> [[String]] { - [["NETWORK", "STATE", "SUBNET"]] - } + let items = networks.map { PrintableNetwork($0) } - func printNetworks(networks: [NetworkState], format: ListFormat) throws { if format == .json { - let printables = networks.map { - PrintableNetwork($0) - } - let data = try JSONEncoder().encode(printables) - print(String(decoding: data, as: UTF8.self)) - - return - } - - if self.quiet { - networks.forEach { - print($0.id) - } + try emit(renderJSON(items)) return } - var rows = createHeader() - for network in networks { - rows.append(network.asRow) - } - - let formatter = TableOutput(rows: rows) - print(formatter.format()) + emit(renderList(items, quiet: quiet)) } } } -extension NetworkState { - var asRow: [String] { - switch self { - case .created(_): - return [self.id, self.state, "none"] - case .running(_, let status): +extension PrintableNetwork: ListDisplayable { + static var tableHeader: [String] { + ["NETWORK", "STATE", "SUBNET"] + } + + var tableRow: [String] { + if let status { return [self.id, self.state, status.ipv4Subnet.description] } + return [self.id, self.state, "none"] + } + + var quietValue: String { + self.id } } -public struct PrintableNetwork: Codable { +public struct PrintableNetwork: Codable, Sendable { let id: String let state: String let config: NetworkConfiguration diff --git a/Sources/ContainerCommands/OutputRendering.swift b/Sources/ContainerCommands/OutputRendering.swift new file mode 100644 index 000000000..8f525c285 --- /dev/null +++ b/Sources/ContainerCommands/OutputRendering.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +/// Options for JSON rendering, wrapping the knobs on `JSONEncoder`. +struct JSONOptions { + var outputFormatting: JSONEncoder.OutputFormatting = [] + var dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate + + static let compact = JSONOptions() + static let prettySorted = JSONOptions(outputFormatting: [.prettyPrinted, .sortedKeys]) +} + +/// Renders an `Encodable` value as a JSON string. +func renderJSON(_ value: T, options: JSONOptions = .compact) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = options.outputFormatting + encoder.dateEncodingStrategy = options.dateEncodingStrategy + let data = try encoder.encode(value) + return String(decoding: data, as: UTF8.self) +} + +/// Renders a list of displayable items as a table (with header) or quiet-mode identifiers. +func renderList(_ items: [T], quiet: Bool) -> String { + if quiet { + return items.map(\.quietValue).joined(separator: "\n") + } + return renderTable(items) +} + +/// Renders a list of displayable items as a column-aligned table with a header row. +func renderTable(_ items: [T]) -> String { + var rows: [[String]] = [T.tableHeader] + for item in items { + rows.append(item.tableRow) + } + return TableOutput(rows: rows).format() +} + +/// Writes rendered output to stdout. No-ops on empty strings to avoid blank lines +/// (e.g., `container list -q` with zero results should produce no output, not a newline). +func emit(_ output: String) { + if !output.isEmpty { + print(output) + } +} diff --git a/Sources/ContainerCommands/Registry/RegistryList.swift b/Sources/ContainerCommands/Registry/RegistryList.swift index 2201f2429..2db6b6d70 100644 --- a/Sources/ContainerCommands/Registry/RegistryList.swift +++ b/Sources/ContainerCommands/Registry/RegistryList.swift @@ -29,7 +29,7 @@ extension Application { @Option(name: .long, help: "Format of the output") var format: ListFormat = .table - @Flag(name: .shortAndLong, help: "Only output the registry name") + @Flag(name: .shortAndLong, help: "Only output the registry hostname") var quiet = false public init() {} @@ -43,44 +43,37 @@ extension Application { let registryInfos = try keychain.list() let registries = registryInfos.map { RegistryResource(from: $0) } - try printRegistries(registries: registries, format: format) - } - - private func createHeader() -> [[String]] { - [["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"]] - } - - private func printRegistries(registries: [RegistryResource], format: ListFormat) throws { if format == .json { - let data = try JSONEncoder().encode(registries) - print(String(decoding: data, as: UTF8.self)) + try emit(renderJSON(registries)) return } - if self.quiet { - registries.forEach { - print($0.name) - } - return - } - - var rows = createHeader() - for registry in registries { - rows.append(registry.asRow) - } - - let formatter = TableOutput(rows: rows) - print(formatter.format()) + emit(renderList(registries.map { PrintableRegistry($0) }, quiet: quiet)) } } } -extension RegistryResource { - fileprivate var asRow: [String] { + +private struct PrintableRegistry: ListDisplayable { + let registry: RegistryResource + + init(_ registry: RegistryResource) { + self.registry = registry + } + + static var tableHeader: [String] { + ["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"] + } + + var tableRow: [String] { [ - self.name, - self.username, - self.modificationDate.ISO8601Format(), - self.creationDate.ISO8601Format(), + registry.name, + registry.username, + registry.modificationDate.ISO8601Format(), + registry.creationDate.ISO8601Format(), ] } + + var quietValue: String { + registry.name + } } diff --git a/Sources/ContainerCommands/System/DNS/DNSList.swift b/Sources/ContainerCommands/System/DNS/DNSList.swift index ed4b5824e..af2757194 100644 --- a/Sources/ContainerCommands/System/DNS/DNSList.swift +++ b/Sources/ContainerCommands/System/DNS/DNSList.swift @@ -38,38 +38,35 @@ extension Application { public init() {} public func run() async throws { - let resolver: HostDNSResolver = HostDNSResolver() + let resolver = HostDNSResolver() let domains = resolver.listDomains() - try printDomains(domains: domains, format: format) - } - - private func createHeader() -> [[String]] { - [["DOMAIN"]] - } - func printDomains(domains: [String], format: ListFormat) throws { if format == .json { - let data = try JSONEncoder().encode(domains) - print(String(decoding: data, as: UTF8.self)) - + try emit(renderJSON(domains)) return } - if self.quiet { - domains.forEach { domain in - print(domain) - } - return - } + emit(renderList(domains.map { PrintableDomain($0) }, quiet: quiet)) + } + } +} - var rows = createHeader() - for domain in domains { - rows.append([domain]) - } +private struct PrintableDomain: ListDisplayable { + let domain: String - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } + init(_ domain: String) { + self.domain = domain + } + + static var tableHeader: [String] { + ["DOMAIN"] + } + + var tableRow: [String] { + [domain] + } + var quietValue: String { + domain } } diff --git a/Sources/ContainerCommands/System/Property/PropertyList.swift b/Sources/ContainerCommands/System/Property/PropertyList.swift index e7787015a..203af280d 100644 --- a/Sources/ContainerCommands/System/Property/PropertyList.swift +++ b/Sources/ContainerCommands/System/Property/PropertyList.swift @@ -40,41 +40,40 @@ extension Application { public func run() async throws { let vals = DefaultsStore.allValues() - try printValues(vals, format: format) - } - - private func createHeader() -> [[String]] { - [["ID", "TYPE", "VALUE", "DESCRIPTION"]] - } - private func printValues(_ vals: [DefaultsStoreValue], format: ListFormat) throws { if format == .json { - let data = try JSONEncoder().encode(vals) - print(String(decoding: data, as: UTF8.self)) + try emit(renderJSON(vals)) return } - if self.quiet { - vals.forEach { - print($0.id) - } - return - } - - var rows = createHeader() - for property in vals { - rows.append(property.asRow) - } - - let formatter = TableOutput(rows: rows) - print(formatter.format()) + emit(renderList(vals.map { PrintableProperty($0) }, quiet: quiet)) } } } -extension DefaultsStoreValue { - var asRow: [String] { - [id, String(describing: type), value?.description.elided(to: 40) ?? "*undefined*", description] +private struct PrintableProperty: ListDisplayable { + let id: String + let typeName: String + let valueDescription: String + let description: String + + init(_ value: DefaultsStoreValue) { + self.id = value.id + self.typeName = String(describing: value.type) + self.valueDescription = value.value?.description.elided(to: 40) ?? "*undefined*" + self.description = value.description + } + + static var tableHeader: [String] { + ["ID", "TYPE", "VALUE", "DESCRIPTION"] + } + + var tableRow: [String] { + [id, typeName, valueDescription, description] + } + + var quietValue: String { + id } } @@ -86,7 +85,7 @@ extension String { } if maxCount < ellipsis.count { - return ellipsis + return String(ellipsis.prefix(maxCount)) } let prefixCount = maxCount - ellipsis.count diff --git a/Sources/ContainerCommands/System/SystemDF.swift b/Sources/ContainerCommands/System/SystemDF.swift index 1371906d6..9794f2e15 100644 --- a/Sources/ContainerCommands/System/SystemDF.swift +++ b/Sources/ContainerCommands/System/SystemDF.swift @@ -16,7 +16,6 @@ import ArgumentParser import ContainerAPIClient -import ContainerizationError import Foundation extension Application { @@ -38,16 +37,7 @@ extension Application { let stats = try await ClientDiskUsage.get() if format == .json { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(stats) - guard let jsonString = String(data: data, encoding: .utf8) else { - throw ContainerizationError( - .internalError, - message: "failed to encode JSON output" - ) - } - print(jsonString) + try emit(renderJSON(stats, options: .prettySorted)) return } diff --git a/Sources/ContainerCommands/System/SystemStatus.swift b/Sources/ContainerCommands/System/SystemStatus.swift index 44a84a228..0386dc4c2 100644 --- a/Sources/ContainerCommands/System/SystemStatus.swift +++ b/Sources/ContainerCommands/System/SystemStatus.swift @@ -64,8 +64,7 @@ extension Application { apiServerBuild: "", apiServerAppName: "" ) - let data = try JSONEncoder().encode(status) - print(String(decoding: data, as: UTF8.self)) + try emit(renderJSON(status)) } else { print("apiserver is not running and not registered with launchd") } @@ -87,8 +86,7 @@ extension Application { apiServerBuild: systemHealth.apiServerBuild, apiServerAppName: systemHealth.apiServerAppName ) - let data = try JSONEncoder().encode(status) - print(String(decoding: data, as: UTF8.self)) + try emit(renderJSON(status)) } else { let rows: [[String]] = [ ["FIELD", "VALUE"], @@ -116,8 +114,7 @@ extension Application { apiServerBuild: "", apiServerAppName: "" ) - let data = try JSONEncoder().encode(status) - print(String(decoding: data, as: UTF8.self)) + try emit(renderJSON(status)) } else { print("apiserver is not running") } diff --git a/Sources/ContainerCommands/System/SystemVersion.swift b/Sources/ContainerCommands/System/SystemVersion.swift index 4e4e5d68f..cb8557c1d 100644 --- a/Sources/ContainerCommands/System/SystemVersion.swift +++ b/Sources/ContainerCommands/System/SystemVersion.swift @@ -62,7 +62,7 @@ extension Application { case .table: printVersionTable(versions: versions) case .json: - try printVersionJSON(versions: versions) + try emit(renderJSON(versions)) } } @@ -73,11 +73,6 @@ extension Application { let table = TableOutput(rows: rows) print(table.format()) } - - private func printVersionJSON(versions: [VersionInfo]) throws { - let data = try JSONEncoder().encode(versions) - print(String(data: data, encoding: .utf8) ?? "[]") - } } public struct VersionInfo: Codable { diff --git a/Sources/Services/ContainerAPIService/Client/TableOutput.swift b/Sources/ContainerCommands/TableOutput.swift similarity index 96% rename from Sources/Services/ContainerAPIService/Client/TableOutput.swift rename to Sources/ContainerCommands/TableOutput.swift index 7dc5b20cd..469c1fd6a 100644 --- a/Sources/Services/ContainerAPIService/Client/TableOutput.swift +++ b/Sources/ContainerCommands/TableOutput.swift @@ -16,11 +16,11 @@ import Foundation -public struct TableOutput { +struct TableOutput { private let rows: [[String]] private let spacing: Int - public init( + init( rows: [[String]], spacing: Int = 2 ) { @@ -28,7 +28,7 @@ public struct TableOutput { self.spacing = spacing } - public func format() -> String { + func format() -> String { var output = "" let maxLengths = self.maxLength() diff --git a/Sources/ContainerCommands/Volume/VolumeInspect.swift b/Sources/ContainerCommands/Volume/VolumeInspect.swift index b07ec7c37..9a862add6 100644 --- a/Sources/ContainerCommands/Volume/VolumeInspect.swift +++ b/Sources/ContainerCommands/Volume/VolumeInspect.swift @@ -42,12 +42,11 @@ extension Application.VolumeCommand { volumes.append(volume) } - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - - let data = try encoder.encode(volumes) - print(String(decoding: data, as: UTF8.self)) + let options = JSONOptions( + outputFormatting: [.prettyPrinted, .sortedKeys], + dateEncodingStrategy: .iso8601 + ) + try emit(renderJSON(volumes, options: options)) } } } diff --git a/Sources/ContainerCommands/Volume/VolumeList.swift b/Sources/ContainerCommands/Volume/VolumeList.swift index a391362c9..aa92a42c6 100644 --- a/Sources/ContainerCommands/Volume/VolumeList.swift +++ b/Sources/ContainerCommands/Volume/VolumeList.swift @@ -29,7 +29,7 @@ extension Application.VolumeCommand { ) @Option(name: .long, help: "Format of the output") - var format: Application.ListFormat = .table + var format: ListFormat = .table @Flag(name: .shortAndLong, help: "Only output the volume name") var quiet: Bool = false @@ -41,52 +41,45 @@ extension Application.VolumeCommand { public func run() async throws { let volumes = try await ClientVolume.list() - try printVolumes(volumes: volumes, format: format) - } - - private func createHeader() -> [[String]] { - [["NAME", "TYPE", "DRIVER", "OPTIONS"]] - } - func printVolumes(volumes: [Volume], format: Application.ListFormat) throws { if format == .json { - let data = try JSONEncoder().encode(volumes) - print(String(decoding: data, as: UTF8.self)) + try emit(renderJSON(volumes)) return } - if quiet { - volumes.forEach { - print($0.name) - } - return - } + // Sort by creation time (newest first) for table display only, + // matching the original behavior where JSON and quiet emit unsorted. + let items = quiet ? volumes : volumes.sorted { $0.createdAt > $1.createdAt } + emit(renderList(items.map { PrintableVolume($0) }, quiet: quiet)) + } + } +} - // Sort volumes by creation time (newest first) - let sortedVolumes = volumes.sorted { v1, v2 in - v1.createdAt > v2.createdAt - } +private struct PrintableVolume: ListDisplayable { + let name: String + let volumeType: String + let driver: String + let optionsString: String - var rows = createHeader() - for volume in sortedVolumes { - rows.append(volume.asRow) - } + init(_ volume: Volume) { + self.name = volume.name + self.volumeType = volume.isAnonymous ? "anonymous" : "named" + self.driver = volume.driver + self.optionsString = + volume.options.isEmpty + ? "" + : volume.options.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }.joined(separator: ",") + } - let formatter = TableOutput(rows: rows) - print(formatter.format()) - } + static var tableHeader: [String] { + ["NAME", "TYPE", "DRIVER", "OPTIONS"] + } + + var tableRow: [String] { + [name, volumeType, driver, optionsString] } -} -extension Volume { - var asRow: [String] { - let volumeType = self.isAnonymous ? "anonymous" : "named" - let optionsString = options.isEmpty ? "" : options.map { "\($0.key)=\($0.value)" }.joined(separator: ",") - return [ - self.name, - volumeType, - self.driver, - optionsString, - ] + var quietValue: String { + name } } diff --git a/Tests/ContainerCommandsTests/ListFormattingTests.swift b/Tests/ContainerCommandsTests/ListFormattingTests.swift new file mode 100644 index 000000000..3e055aa96 --- /dev/null +++ b/Tests/ContainerCommandsTests/ListFormattingTests.swift @@ -0,0 +1,280 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerResource +import Foundation +import Testing + +@testable import ContainerCommands + +// MARK: - Test ListDisplayable conformer + +private struct TestItem: ListDisplayable, Codable { + let id: String + let name: String + + static var tableHeader: [String] { ["ID", "NAME"] } + var tableRow: [String] { [id, name] } + var quietValue: String { id } +} + +// MARK: - TableOutput tests + +struct TableOutputTests { + @Test + func emptyRowsProducesEmptyString() { + #expect(TableOutput(rows: []).format() == "") + } + + @Test + func headerOnlyRow() { + #expect(TableOutput(rows: [["ID", "NAME"]]).format() == "ID NAME") + } + + @Test + func columnsPaddedToMaxWidth() { + let output = TableOutput(rows: [ + ["ID", "NAME"], + ["1", "short"], + ["2", "a longer name"], + ]).format() + let lines = output.split(separator: "\n") + #expect(lines.count == 3) + #expect(lines[0].hasPrefix("ID ")) + #expect(lines[1].hasPrefix("1 ")) + #expect(lines[2].hasPrefix("2 ")) + } + + @Test + func customSpacing() { + let output = TableOutput(rows: [["A", "B"], ["1", "2"]], spacing: 4).format() + #expect(output.contains("A B")) + } + + @Test + func lastColumnNotPadded() { + let lines = TableOutput(rows: [["A", "B"], ["1", "2"]]).format().split(separator: "\n") + for line in lines { + #expect(!line.hasSuffix(" ")) + } + } + + @Test + func singleColumnNoPadding() { + let output = TableOutput(rows: [["DOMAIN"], ["example.com"], ["test.local"]]).format() + #expect(output == "DOMAIN\nexample.com\ntest.local") + } + + @Test + func outputLineCountMatchesInputRowCount() { + let rows = [["H1", "H2"], ["a", "b"], ["c", "d"], ["e", "f"]] + let lines = TableOutput(rows: rows).format().split(separator: "\n") + #expect(lines.count == rows.count) + } +} + +// MARK: - renderTable tests + +struct RenderTableTests { + @Test + func rendersHeaderAndRows() { + let items = [TestItem(id: "abc", name: "first"), TestItem(id: "def", name: "second")] + let output = renderTable(items) + #expect(output.contains("ID")) + #expect(output.contains("NAME")) + #expect(output.contains("abc")) + #expect(output.contains("second")) + } + + @Test + func emptyListRendersHeaderOnly() { + let output = renderTable([TestItem]()) + #expect(output.contains("ID")) + #expect(output.contains("NAME")) + #expect(!output.contains("\n")) + } + + @Test + func columnCountMatchesHeader() { + let items = [TestItem(id: "1", name: "test")] + let lines = renderTable(items).split(separator: "\n") + let headerColumnCount = lines[0].split(separator: " ", omittingEmptySubsequences: true).count + let rowColumnCount = lines[1].split(separator: " ", omittingEmptySubsequences: true).count + #expect(headerColumnCount == rowColumnCount) + } +} + +// MARK: - renderList tests + +struct RenderListTests { + @Test + func tableMode() { + let items = [TestItem(id: "abc", name: "first")] + let output = renderList(items, quiet: false) + #expect(output.contains("ID")) + #expect(output.contains("abc")) + #expect(output.contains("first")) + } + + @Test + func quietMode() { + let items = [TestItem(id: "abc", name: "first"), TestItem(id: "def", name: "second")] + let output = renderList(items, quiet: true) + #expect(output == "abc\ndef") + } + + @Test + func quietModeEmptyList() { + let output = renderList([TestItem](), quiet: true) + #expect(output == "") + } +} + +// MARK: - renderJSON tests + +struct RenderJSONTests { + @Test + func compactProducesValidJSON() throws { + let items = [TestItem(id: "a", name: "b")] + let json = try renderJSON(items) + let decoded = try JSONDecoder().decode([TestItem].self, from: json.data(using: .utf8)!) + #expect(decoded.count == 1) + #expect(decoded[0].id == "a") + #expect(decoded[0].name == "b") + } + + @Test + func compactIsSingleLine() throws { + let items = [TestItem(id: "a", name: "b"), TestItem(id: "c", name: "d")] + let json = try renderJSON(items) + #expect(!json.contains("\n")) + } + + @Test + func prettySortedIsMultiLine() throws { + let items = [TestItem(id: "a", name: "b")] + let json = try renderJSON(items, options: .prettySorted) + #expect(json.contains("\n")) + } + + @Test + func prettySortedHasSortedKeys() throws { + let json = try renderJSON(["z": 1, "a": 2], options: .prettySorted) + let aIndex = json.range(of: "\"a\"")!.lowerBound + let zIndex = json.range(of: "\"z\"")!.lowerBound + #expect(aIndex < zIndex) + } + + @Test + func customDateStrategy() throws { + struct Dated: Codable { let date: Date } + let item = Dated(date: Date(timeIntervalSince1970: 0)) + let options = JSONOptions( + outputFormatting: [.prettyPrinted, .sortedKeys], + dateEncodingStrategy: .iso8601 + ) + let json = try renderJSON(item, options: options) + #expect(json.contains("1970-01-01")) + } + + @Test + func arrayEncodingMatchesOldJoinApproach() throws { + // Verify renderJSON(array) is structurally identical to the old + // jsonArray() approach (encode each element, join with commas). + let items = [TestItem(id: "x", name: "y"), TestItem(id: "a", name: "b")] + let wholeArray = try renderJSON(items) + let perElement = try items.map { try renderJSON($0) } + let joined = "[\(perElement.joined(separator: ","))]" + + let decoded1 = try JSONDecoder().decode([TestItem].self, from: wholeArray.data(using: .utf8)!) + let decoded2 = try JSONDecoder().decode([TestItem].self, from: joined.data(using: .utf8)!) + #expect(decoded1.count == decoded2.count) + #expect(decoded1[0].id == decoded2[0].id) + #expect(decoded1[1].id == decoded2[1].id) + } +} + +// MARK: - JSONOptions tests + +struct JSONOptionsTests { + @Test + func compactPresetHasNoFormatting() { + let opts = JSONOptions.compact + #expect(opts.outputFormatting == []) + } + + @Test + func prettySortedPresetHasBothFlags() { + let opts = JSONOptions.prettySorted + #expect(opts.outputFormatting.contains(.prettyPrinted)) + #expect(opts.outputFormatting.contains(.sortedKeys)) + } +} + +// MARK: - PrintableContainer conformance tests + +struct PrintableContainerDisplayTests { + @Test + func tableHeaderHasNineColumns() { + #expect(PrintableContainer.tableHeader.count == 9) + #expect(PrintableContainer.tableHeader[0] == "ID") + #expect(PrintableContainer.tableHeader[4] == "STATE") + #expect(PrintableContainer.tableHeader[8] == "STARTED") + } +} + +// MARK: - PrintableNetwork conformance tests + +struct PrintableNetworkDisplayTests { + @Test + func tableHeaderHasThreeColumns() { + #expect(PrintableNetwork.tableHeader.count == 3) + #expect(PrintableNetwork.tableHeader == ["NETWORK", "STATE", "SUBNET"]) + } +} + +// MARK: - ListFormat tests + +struct ListFormatTests { + @Test + func hasJsonAndTableCases() { + #expect(ListFormat.allCases.count == 2) + #expect(ListFormat.json.rawValue == "json") + #expect(ListFormat.table.rawValue == "table") + } +} + +// MARK: - String.elided tests + +struct StringElidedTests { + @Test + func shortStringUnchanged() { + #expect("hello".elided(to: 10) == "hello") + #expect("hello".elided(to: 5) == "hello") + } + + @Test + func longStringTruncatedWithEllipsis() { + #expect("hello world".elided(to: 8) == "hello...") + } + + @Test + func maxCountShorterThanEllipsis() { + #expect("hello".elided(to: 2) == "..") + #expect("hello".elided(to: 1) == ".") + #expect("hello".elided(to: 0) == "") + } +} From 3a775146f2b92477b70dd8b0d0a1681785ad5393 Mon Sep 17 00:00:00 2001 From: Raj Date: Fri, 3 Apr 2026 14:07:30 -0700 Subject: [PATCH 2/2] Expand integration tests for some list commands --- .../Images/TestCLIImagesCommand.swift | 11 ++++++ .../Subcommands/Networks/TestCLINetwork.swift | 36 +++++++++++++++++++ .../Registry/TestCLIRegistry.swift | 10 +++++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift index a2b71c20e..dfad72013 100644 --- a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift +++ b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift @@ -529,6 +529,17 @@ class TestCLIImagesCommand: CLITest { #expect(!size.isEmpty, "expected image to have non-empty 'fullSize' field: \(image)") } + @Test func testImageListTableFormat() throws { + try doPull(imageName: alpine) + + let (_, output, error, status) = try run(arguments: ["image", "ls"]) + #expect(status == 0, "image ls should succeed, stderr: \(error)") + + let headers = ["NAME", "TAG", "DIGEST"] + #expect(headers.allSatisfy { output.contains($0) }, "table should contain all headers") + #expect(output.contains("alpine"), "table should contain pulled image name") + } + private func addInvalidMemberToTar(tarPath: String, maliciousFilename: String) throws { // Create a malicious entry with path traversal let evilEntryName = "../../../../../../../../../../../tmp/\(maliciousFilename)" diff --git a/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift b/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift index bb329ba89..b4d5eae45 100644 --- a/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift +++ b/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift @@ -261,4 +261,40 @@ class TestCLINetwork: CLITest { #expect(failed == 6, "external connection should fail") } } + + @Test func testNetworkListTableFormat() throws { + let name = getLowercasedTestName() + _ = try? run(arguments: ["network", "delete", name]) + let createResult = try run(arguments: ["network", "create", name]) + if createResult.status != 0 { + throw CLIError.executionFailed("network create failed: \(createResult.error)") + } + defer { _ = try? run(arguments: ["network", "delete", name]) } + + let (_, output, error, status) = try run(arguments: ["network", "list"]) + #expect(status == 0, "network list should succeed, stderr: \(error)") + + let headers = ["NETWORK", "STATE", "SUBNET"] + #expect(headers.allSatisfy { output.contains($0) }, "table should contain all headers") + #expect(output.contains(name), "table should contain the created network") + } + + @Test func testNetworkListJSONFormat() throws { + let name = getLowercasedTestName() + _ = try? run(arguments: ["network", "delete", name]) + let createResult = try run(arguments: ["network", "create", name]) + if createResult.status != 0 { + throw CLIError.executionFailed("network create failed: \(createResult.error)") + } + defer { _ = try? run(arguments: ["network", "delete", name]) } + + let (data, _, error, status) = try run(arguments: ["network", "list", "--format", "json"]) + #expect(status == 0, "network list --format json should succeed, stderr: \(error)") + + guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else { + Issue.record("JSON output should be an array of objects") + return + } + #expect(json.contains { ($0["id"] as? String) == name }, "JSON should contain the created network") + } } diff --git a/Tests/CLITests/Subcommands/Registry/TestCLIRegistry.swift b/Tests/CLITests/Subcommands/Registry/TestCLIRegistry.swift index 4b0b81783..31f104665 100644 --- a/Tests/CLITests/Subcommands/Registry/TestCLIRegistry.swift +++ b/Tests/CLITests/Subcommands/Registry/TestCLIRegistry.swift @@ -22,7 +22,6 @@ class TestCLIRegistry: CLITest { let (_, output, error, status) = try run(arguments: ["registry", "list"]) #expect(status == 0, "registry list should succeed, stderr: \(error)") - // Check for table header let requiredHeaders = ["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"] #expect( requiredHeaders.allSatisfy { output.contains($0) }, @@ -30,10 +29,19 @@ class TestCLIRegistry: CLITest { ) } + @Test func testListJSONFormat() throws { + let (data, _, error, status) = try run(arguments: ["registry", "list", "--format", "json"]) + #expect(status == 0, "registry list --format json should succeed, stderr: \(error)") + + let json = try JSONSerialization.jsonObject(with: data, options: []) + #expect(json is [Any], "JSON output should be an array") + } + @Test func testListQuietMode() throws { let (_, output, error, status) = try run(arguments: ["registry", "list", "-q"]) #expect(status == 0, "registry list -q should succeed, stderr: \(error)") #expect(!output.contains("HOSTNAME"), "quiet mode should not contain headers") + #expect(!output.contains("USERNAME"), "quiet mode should not contain headers") } }