mirror of
https://github.com/apple/container.git
synced 2026-09-22 23:55:34 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
+5
-6
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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) == "")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user