Consolidate CLI formatting infrastructure (#1385)

## Motivation and Context
This PR consolidates duplicated list-output formatting across the CLI
into shared rendering infrastructure.

Currently, each list command has its own copy of the same
json/quiet/table branching. This PR pulls that into shared rendering
infrastructure in `ContainerCommands`:
- `ListDisplayable` protocol for table + quiet output
- `renderJSON`, `renderTable`, `renderList` as pure functions that
return strings
- `emit()` as the single stdout boundary (no-ops on empty strings to
avoid blank-line regressions)
- `JSONOptions` so all JSON encoding goes through one path, including
volume inspect's pretty + ISO 8601 case

JSON encoding remains separate from display formatting: each command
still chooses its own JSON model, while `ListDisplayable` is used only
for table and quiet output. `ImageList` remains the intentional
exception for quiet mode so it can avoid unnecessary async work.

This change also replaces inline `JSONEncoder` usage with `renderJSON`,
removes the old `Codable+JSON.swift` helper, and moves `TableOutput` and
`ListFormat` into `ContainerCommands`. It also adds unit tests for the
shared rendering helpers and expands integration coverage for image,
network, and registry list formatting.

## Testing
- [x] Tested locally
- [x] Added/updated tests
- [ ] Added/updated docs
This commit is contained in:
Raj
2026-04-07 09:50:51 -07:00
committed by GitHub
parent f0e85c4ac7
commit 3366916e80
26 changed files with 799 additions and 428 deletions
+7
View File
@@ -122,6 +122,13 @@ let package = Package(
"ContainerBuild"
]
),
.testTarget(
name: "ContainerCommandsTests",
dependencies: [
"ContainerCommands",
"ContainerResource",
]
),
.executableTarget(
name: "container-apiserver",
dependencies: [
@@ -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
@@ -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 Output.emit(Output.renderJSON([PrintableContainer(container)]))
return
}
if quiet && container.status != .running {
return
}
Output.emit(Output.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
}
var rows = createHeader()
for container in containers {
rows.append(container.asRow)
}
let formatter = TableOutput(rows: rows)
print(formatter.format())
}
}
}
extension ContainerSnapshot {
fileprivate var asRow: [String] {
private struct PrintableBuilder: ListDisplayable {
let snapshot: ContainerSnapshot
init(_ snapshot: ContainerSnapshot) {
self.snapshot = snapshot
}
static var tableHeader: [String] {
["ID", "IMAGE", "STATE", "ADDR", "CPUS", "MEMORY"]
}
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
}
}
@@ -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 Output.emit(Output.renderJSON(containers))
}
}
}
@@ -46,59 +46,37 @@ 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"]]
}
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)
}
return
}
var rows = createHeader()
for container in containers {
rows.append(container.asRow)
}
let formatter = TableOutput(rows: rows)
print(formatter.format())
let items = containers.map { PrintableContainer($0) }
try Output.render(json: items, display: items, format: format, 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]
@@ -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 Output.emit(Output.renderJSON(jsonStats))
return
}
@@ -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 Output.emit(Output.renderJSON(printable))
}
if !allErrors.isEmpty {
+137 -126
View File
@@ -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
struct ListImageImplementation {
static func createHeader() -> [[String]] {
[["NAME", "TAG", "DIGEST"]]
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
}
if verbose {
let items = try await Self.buildVerboseItems(images: images)
Output.emit(Output.renderTable(items))
return
}
let items = try await Self.buildTableItems(images: images)
Output.emit(Output.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 Output.emit(Output.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 ?? "<none>",
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 size = descriptor.size + manifest.config.size + manifest.layers.reduce(0) { $0 + $1.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 ?? "<none>",
Utility.trimDigest(digest: imageDigest),
os,
arch,
variant,
formattedSize,
created,
Utility.trimDigest(digest: descriptor.digest),
]
rows.append(row)
items.append(
VerboseImageRow(
name: reference.name,
tag: reference.tag ?? "<none>",
indexDigest: Utility.trimDigest(digest: imageDigest),
os: platform.os,
arch: platform.architecture,
variant: platform.variant ?? "",
fullSize: formattedSize,
created: created,
manifestDigest: Utility.trimDigest(digest: descriptor.digest)
))
}
}
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 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)
}
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 ?? "<none>",
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"])
@OptionGroup
var options: ListImageOptions
@OptionGroup
public var logOptions: Flags.Logging
public mutating func run() async throws {
try ListImageImplementation.validate(options: options)
try await ListImageImplementation.listImages(options: options)
}
}
}
private struct ImageRow: ListDisplayable {
let name: String
let tag: String
let trimmedDigest: String
static var tableHeader: [String] {
["NAME", "TAG", "DIGEST"]
}
var tableRow: [String] {
[name, tag, trimmedDigest]
}
// 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
}
}
@@ -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.
public 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 }
}
@@ -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
}
@@ -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 Output.emit(Output.renderJSON(items))
}
}
}
@@ -41,54 +41,30 @@ 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"]]
}
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)
}
return
}
var rows = createHeader()
for network in networks {
rows.append(network.asRow)
}
let formatter = TableOutput(rows: rows)
print(formatter.format())
let items = networks.map { PrintableNetwork($0) }
try Output.render(json: items, display: items, format: format, quiet: quiet)
}
}
}
extension NetworkState {
var asRow: [String] {
switch self {
case .created(_):
return [self.id, self.state, "none"]
case .running(_, let status):
extension PrintableNetwork: ListDisplayable {
public static var tableHeader: [String] {
["NETWORK", "STATE", "SUBNET"]
}
public var tableRow: [String] {
if let status {
return [self.id, self.state, status.ipv4Subnet.description]
}
return [self.id, self.state, "none"]
}
public var quietValue: String {
self.id
}
}
public struct PrintableNetwork: Codable {
public struct PrintableNetwork: Codable, Sendable {
let id: String
let state: String
let config: NetworkConfiguration
@@ -0,0 +1,88 @@
//===----------------------------------------------------------------------===//
// 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`.
public struct JSONOptions: Sendable {
public var outputFormatting: JSONEncoder.OutputFormatting = []
public var dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate
public static let compact = JSONOptions()
public static let prettySorted = JSONOptions(outputFormatting: [.prettyPrinted, .sortedKeys])
public init(
outputFormatting: JSONEncoder.OutputFormatting = [],
dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate
) {
self.outputFormatting = outputFormatting
self.dateEncodingStrategy = dateEncodingStrategy
}
}
/// Shared rendering helpers for CLI output.
///
/// All list commands route their output through these methods. JSON rendering
/// is separate from table/quiet rendering because the JSON model often differs
/// from the display model (e.g., `Volume` for JSON vs `PrintableVolume` for table).
public enum Output {
/// Renders an `Encodable` value as a JSON string.
public static func renderJSON<T: Encodable>(_ 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.
public static func renderList<T: ListDisplayable>(_ 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.
public static func renderTable<T: ListDisplayable>(_ items: [T]) -> String {
var rows: [[String]] = [T.tableHeader]
for item in items {
rows.append(item.tableRow)
}
return TableOutput(rows: rows).format()
}
/// Renders list output in the requested format.
///
/// The JSON and display models may be the same type (e.g., `PrintableContainer`)
/// or different types (e.g., `Volume` for JSON and `PrintableVolume` for table).
public static func render<J: Encodable, D: ListDisplayable>(
json: J, display: [D], format: ListFormat, quiet: Bool
) throws {
switch format {
case .json: try emit(renderJSON(json))
case .table: emit(renderList(display, quiet: quiet))
}
}
/// 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).
public static func emit(_ output: String) {
if !output.isEmpty {
print(output)
}
}
}
@@ -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,36 @@ 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))
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())
try Output.render(
json: registries,
display: registries.map { PrintableRegistry($0) },
format: format, 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
}
}
@@ -38,38 +38,34 @@ 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)
try Output.render(
json: domains,
display: domains.map { PrintableDomain($0) },
format: format, quiet: quiet
)
}
}
}
private func createHeader() -> [[String]] {
[["DOMAIN"]]
}
private struct PrintableDomain: ListDisplayable {
let domain: String
func printDomains(domains: [String], format: ListFormat) throws {
if format == .json {
let data = try JSONEncoder().encode(domains)
print(String(decoding: data, as: UTF8.self))
init(_ domain: String) {
self.domain = domain
}
return
}
static var tableHeader: [String] {
["DOMAIN"]
}
if self.quiet {
domains.forEach { domain in
print(domain)
}
return
}
var tableRow: [String] {
[domain]
}
var rows = createHeader()
for domain in domains {
rows.append([domain])
}
let formatter = TableOutput(rows: rows)
print(formatter.format())
}
var quietValue: String {
domain
}
}
@@ -40,41 +40,39 @@ 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))
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())
try Output.render(
json: vals,
display: vals.map { PrintableProperty($0) },
format: format, 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 +84,7 @@ extension String {
}
if maxCount < ellipsis.count {
return ellipsis
return String(ellipsis.prefix(maxCount))
}
let prefixCount = maxCount - ellipsis.count
@@ -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 Output.emit(Output.renderJSON(stats, options: .prettySorted))
return
}
@@ -64,8 +64,7 @@ extension Application {
apiServerBuild: "",
apiServerAppName: ""
)
let data = try JSONEncoder().encode(status)
print(String(decoding: data, as: UTF8.self))
try Output.emit(Output.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 Output.emit(Output.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 Output.emit(Output.renderJSON(status))
} else {
print("apiserver is not running")
}
@@ -62,7 +62,7 @@ extension Application {
case .table:
printVersionTable(versions: versions)
case .json:
try printVersionJSON(versions: versions)
try Output.emit(Output.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 {
@@ -16,7 +16,7 @@
import Foundation
public struct TableOutput {
public struct TableOutput: Sendable {
private let rows: [[String]]
private let spacing: Int
@@ -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 Output.emit(Output.renderJSON(volumes, options: options))
}
}
}
@@ -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 Output.emit(Output.renderJSON(volumes))
return
}
if quiet {
volumes.forEach {
print($0.name)
}
return
}
// Sort volumes by creation time (newest first)
let sortedVolumes = volumes.sorted { v1, v2 in
v1.createdAt > v2.createdAt
}
var rows = createHeader()
for volume in sortedVolumes {
rows.append(volume.asRow)
}
let formatter = TableOutput(rows: rows)
print(formatter.format())
// 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 }
Output.emit(Output.renderList(items.map { PrintableVolume($0) }, quiet: quiet))
}
}
}
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,
]
private struct PrintableVolume: ListDisplayable {
let name: String
let volumeType: String
let driver: String
let optionsString: String
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: ",")
}
static var tableHeader: [String] {
["NAME", "TYPE", "DRIVER", "OPTIONS"]
}
var tableRow: [String] {
[name, volumeType, driver, optionsString]
}
var quietValue: String {
name
}
}
@@ -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)"
@@ -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")
}
}
@@ -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")
}
}
@@ -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 = Output.renderTable(items)
#expect(output.contains("ID"))
#expect(output.contains("NAME"))
#expect(output.contains("abc"))
#expect(output.contains("second"))
}
@Test
func emptyListRendersHeaderOnly() {
let output = 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 = Output.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 = 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 = Output.renderList(items, quiet: true)
#expect(output == "abc\ndef")
}
@Test
func quietModeEmptyList() {
let output = 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 Output.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 Output.renderJSON(items)
#expect(!json.contains("\n"))
}
@Test
func prettySortedIsMultiLine() throws {
let items = [TestItem(id: "a", name: "b")]
let json = try Output.renderJSON(items, options: .prettySorted)
#expect(json.contains("\n"))
}
@Test
func prettySortedHasSortedKeys() throws {
let json = try Output.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 Output.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 Output.renderJSON(items)
let perElement = try items.map { try Output.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) == "")
}
}