From 97d21a1e4ae222733d9996ec8650d30baaf0a1a8 Mon Sep 17 00:00:00 2001 From: Jahir Vidrio Date: Thu, 18 Jun 2026 16:27:52 -0600 Subject: [PATCH 1/3] refactor: rename Images/PS command files to ImageLs/ContainerLs --- Sources/Mocker/Commands/{PS.swift => ContainerLs.swift} | 0 Sources/Mocker/Commands/{Images.swift => ImageLs.swift} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename Sources/Mocker/Commands/{PS.swift => ContainerLs.swift} (100%) rename Sources/Mocker/Commands/{Images.swift => ImageLs.swift} (100%) diff --git a/Sources/Mocker/Commands/PS.swift b/Sources/Mocker/Commands/ContainerLs.swift similarity index 100% rename from Sources/Mocker/Commands/PS.swift rename to Sources/Mocker/Commands/ContainerLs.swift diff --git a/Sources/Mocker/Commands/Images.swift b/Sources/Mocker/Commands/ImageLs.swift similarity index 100% rename from Sources/Mocker/Commands/Images.swift rename to Sources/Mocker/Commands/ImageLs.swift From 54b32980a7db02454fb42fe61672430e6567173a Mon Sep 17 00:00:00 2001 From: Jahir Vidrio Date: Thu, 18 Jun 2026 16:31:16 -0600 Subject: [PATCH 2/3] feat(image,container): add canonical ls group subcommand --- Sources/Mocker/Commands/ContainerCmd.swift | 4 +- .../Commands/ContainerListOptions.swift | 104 +++++++++++++++++ Sources/Mocker/Commands/ContainerLs.swift | 108 +----------------- Sources/Mocker/Commands/ImageCmd.swift | 4 +- .../Mocker/Commands/ImageListOptions.swift | 50 ++++++++ Sources/Mocker/Commands/ImageLs.swift | 51 +-------- Sources/Mocker/Commands/Images.swift | 14 +++ Sources/Mocker/Commands/PS.swift | 14 +++ Tests/MockerTests/CLITests.swift | 25 ++++ 9 files changed, 223 insertions(+), 151 deletions(-) create mode 100644 Sources/Mocker/Commands/ContainerListOptions.swift create mode 100644 Sources/Mocker/Commands/ImageListOptions.swift create mode 100644 Sources/Mocker/Commands/Images.swift create mode 100644 Sources/Mocker/Commands/PS.swift diff --git a/Sources/Mocker/Commands/ContainerCmd.swift b/Sources/Mocker/Commands/ContainerCmd.swift index 77adfe6..25f4d37 100644 --- a/Sources/Mocker/Commands/ContainerCmd.swift +++ b/Sources/Mocker/Commands/ContainerCmd.swift @@ -8,7 +8,7 @@ struct ContainerCommand: AsyncParsableCommand { Run.self, Create.self, Start.self, - PS.self, + ContainerLs.self, Stop.self, Restart.self, Kill.self, @@ -31,6 +31,6 @@ struct ContainerCommand: AsyncParsableCommand { Commit.self, ContainerPrune.self, ], - defaultSubcommand: PS.self + defaultSubcommand: ContainerLs.self ) } diff --git a/Sources/Mocker/Commands/ContainerListOptions.swift b/Sources/Mocker/Commands/ContainerListOptions.swift new file mode 100644 index 0000000..a98abfd --- /dev/null +++ b/Sources/Mocker/Commands/ContainerListOptions.swift @@ -0,0 +1,104 @@ +import ArgumentParser +import MockerKit + +struct ContainerListOptions: ParsableArguments { + @Flag(name: .shortAndLong, help: "Show all containers (default shows just running)") + var all = false + + @Flag(name: .shortAndLong, help: "Only display container IDs") + var quiet = false + + @Option(name: .shortAndLong, parsing: .singleValue, help: "Filter output based on conditions provided") + var filter: [String] = [] + + @Option(name: .long, help: "Format output using a custom template") + var format: String? + + @Flag(name: .customLong("no-trunc"), help: "Don't truncate output") + var noTrunc = false + + @Option(name: [.customShort("n"), .long], help: "Show n last created containers (includes all states)") + var last: Int? + + @Flag(name: .shortAndLong, help: "Show the latest created container (includes all states)") + var latest = false + + @Flag(name: .shortAndLong, help: "Display total file sizes") + var size = false + + func render() async throws { + let config = MockerConfig() + let engine = try ContainerEngine(config: config) + var containers = try await engine.list(all: all) + + for f in filter { + let parts = f.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { continue } + let key = String(parts[0]) + let value = String(parts[1]) + switch key { + case "name": + containers = containers.filter { $0.name.contains(value) } + case "status": + containers = containers.filter { $0.state.rawValue == value } + case "id": + containers = containers.filter { $0.id.hasPrefix(value) } + case "label": + let labelParts = value.split(separator: "=", maxSplits: 1) + if labelParts.count == 2 { + containers = containers.filter { $0.labels[String(labelParts[0])] == String(labelParts[1]) } + } else { + containers = containers.filter { $0.labels[value] != nil } + } + case "ancestor": + containers = containers.filter { $0.image == value || $0.image.hasPrefix(value) } + default: + break + } + } + + if latest { + containers = Array(containers.prefix(1)) + } else if let last { + containers = Array(containers.prefix(last)) + } + + if quiet { + for container in containers { + print(noTrunc ? container.id : container.shortID) + } + return + } + + if let format { + for c in containers { + var output = format + output = output.replacingOccurrences(of: "{{.ID}}", with: noTrunc ? c.id : c.shortID) + output = output.replacingOccurrences(of: "{{.Image}}", with: c.image) + output = output.replacingOccurrences(of: "{{.Command}}", with: c.command) + output = output.replacingOccurrences(of: "{{.CreatedAt}}", with: c.createdAgo) + output = output.replacingOccurrences(of: "{{.Status}}", with: c.status) + output = output.replacingOccurrences(of: "{{.Ports}}", with: c.ports.map(\.description).joined(separator: ", ")) + output = output.replacingOccurrences(of: "{{.Names}}", with: c.name) + output = output.replacingOccurrences(of: "{{.State}}", with: c.state.displayString) + output = output.replacingOccurrences(of: "{{.Labels}}", with: c.labels.map { "\($0.key)=\($0.value)" }.joined(separator: ",")) + print(output) + } + return + } + + let headers = ["Container ID", "Image", "Command", "Created", "Status", "Ports", "Names"] + let rows = containers.map { c in + [ + noTrunc ? c.id : c.shortID, + c.image, + c.command.isEmpty ? "" : "\"\(c.command)\"", + c.createdAgo, + c.status, + c.ports.map(\.description).joined(separator: ", "), + c.name, + ] + } + TableFormatter.print(headers: headers, rows: rows) + } +} diff --git a/Sources/Mocker/Commands/ContainerLs.swift b/Sources/Mocker/Commands/ContainerLs.swift index af40f46..6c3cdd0 100644 --- a/Sources/Mocker/Commands/ContainerLs.swift +++ b/Sources/Mocker/Commands/ContainerLs.swift @@ -1,111 +1,15 @@ import ArgumentParser -import MockerKit -struct PS: AsyncParsableCommand { +struct ContainerLs: AsyncParsableCommand { static let configuration = CommandConfiguration( - commandName: "ps", - abstract: "List containers" + commandName: "ls", + abstract: "List containers", + aliases: ["ps"] ) - @Flag(name: .shortAndLong, help: "Show all containers (default shows just running)") - var all = false - - @Flag(name: .shortAndLong, help: "Only display container IDs") - var quiet = false - - @Option(name: .shortAndLong, parsing: .singleValue, help: "Filter output based on conditions provided") - var filter: [String] = [] - - @Option(name: .long, help: "Format output using a custom template") - var format: String? - - @Flag(name: .customLong("no-trunc"), help: "Don't truncate output") - var noTrunc = false - - @Option(name: [.customShort("n"), .long], help: "Show n last created containers (includes all states)") - var last: Int? - - @Flag(name: .shortAndLong, help: "Show the latest created container (includes all states)") - var latest = false - - @Flag(name: .shortAndLong, help: "Display total file sizes") - var size = false + @OptionGroup var options: ContainerListOptions func run() async throws { - let config = MockerConfig() - let engine = try ContainerEngine(config: config) - var containers = try await engine.list(all: all) - - // Apply filters - for f in filter { - let parts = f.split(separator: "=", maxSplits: 1) - guard parts.count == 2 else { continue } - let key = String(parts[0]) - let value = String(parts[1]) - switch key { - case "name": - containers = containers.filter { $0.name.contains(value) } - case "status": - containers = containers.filter { $0.state.rawValue == value } - case "id": - containers = containers.filter { $0.id.hasPrefix(value) } - case "label": - let labelParts = value.split(separator: "=", maxSplits: 1) - if labelParts.count == 2 { - containers = containers.filter { $0.labels[String(labelParts[0])] == String(labelParts[1]) } - } else { - containers = containers.filter { $0.labels[value] != nil } - } - case "ancestor": - containers = containers.filter { $0.image == value || $0.image.hasPrefix(value) } - default: - break - } - } - - // Apply --latest / --last - if latest { - containers = Array(containers.prefix(1)) - } else if let last { - containers = Array(containers.prefix(last)) - } - - if quiet { - for container in containers { - print(noTrunc ? container.id : container.shortID) - } - return - } - - if let format { - for c in containers { - var output = format - output = output.replacingOccurrences(of: "{{.ID}}", with: noTrunc ? c.id : c.shortID) - output = output.replacingOccurrences(of: "{{.Image}}", with: c.image) - output = output.replacingOccurrences(of: "{{.Command}}", with: c.command) - output = output.replacingOccurrences(of: "{{.CreatedAt}}", with: c.createdAgo) - output = output.replacingOccurrences(of: "{{.Status}}", with: c.status) - output = output.replacingOccurrences(of: "{{.Ports}}", with: c.ports.map(\.description).joined(separator: ", ")) - output = output.replacingOccurrences(of: "{{.Names}}", with: c.name) - output = output.replacingOccurrences(of: "{{.State}}", with: c.state.displayString) - output = output.replacingOccurrences(of: "{{.Labels}}", with: c.labels.map { "\($0.key)=\($0.value)" }.joined(separator: ",")) - print(output) - } - return - } - - let headers = ["Container ID", "Image", "Command", "Created", "Status", "Ports", "Names"] - let rows = containers.map { c in - [ - noTrunc ? c.id : c.shortID, - c.image, - c.command.isEmpty ? "" : "\"\(c.command)\"", - c.createdAgo, - c.status, - c.ports.map(\.description).joined(separator: ", "), - c.name, - ] - } - TableFormatter.print(headers: headers, rows: rows) + try await options.render() } } diff --git a/Sources/Mocker/Commands/ImageCmd.swift b/Sources/Mocker/Commands/ImageCmd.swift index 6aecea4..e1a5cbf 100644 --- a/Sources/Mocker/Commands/ImageCmd.swift +++ b/Sources/Mocker/Commands/ImageCmd.swift @@ -5,7 +5,7 @@ struct ImageCommand: AsyncParsableCommand { commandName: "image", abstract: "Manage images", subcommands: [ - Images.self, + ImageLs.self, Build.self, Pull.self, Push.self, @@ -19,6 +19,6 @@ struct ImageCommand: AsyncParsableCommand { ImageInspect.self, ImagePrune.self, ], - defaultSubcommand: Images.self + defaultSubcommand: ImageLs.self ) } diff --git a/Sources/Mocker/Commands/ImageListOptions.swift b/Sources/Mocker/Commands/ImageListOptions.swift new file mode 100644 index 0000000..f969a5c --- /dev/null +++ b/Sources/Mocker/Commands/ImageListOptions.swift @@ -0,0 +1,50 @@ +import ArgumentParser +import MockerKit + +struct ImageListOptions: ParsableArguments { + @Flag(name: .shortAndLong, help: "Only show image IDs") + var quiet = false + + @Flag(name: .shortAndLong, help: "Show all images (default hides intermediate images)") + var all = false + + @Option(name: .shortAndLong, parsing: .singleValue, help: "Filter output based on conditions provided") + var filter: [String] = [] + + @Option(name: .long, help: "Format output using a custom template") + var format: String? + + @Flag(name: .long, help: "Show digests") + var digests = false + + @Flag(name: .customLong("no-trunc"), help: "Don't truncate output") + var noTrunc = false + + @Flag(name: .long, help: "List images in tree format (experimental)") + var tree = false + + func render() async throws { + let config = MockerConfig() + let manager = try ImageManager(config: config) + let images = try await manager.list() + + if quiet { + for image in images { + print(image.shortID) + } + return + } + + let headers = ["Repository", "Tag", "Image ID", "Created", "Size"] + let rows = images.map { img in + [ + img.repository, + img.tag, + img.shortID, + img.createdAgo, + img.sizeString, + ] + } + TableFormatter.print(headers: headers, rows: rows) + } +} diff --git a/Sources/Mocker/Commands/ImageLs.swift b/Sources/Mocker/Commands/ImageLs.swift index bcbb50b..eee88c6 100644 --- a/Sources/Mocker/Commands/ImageLs.swift +++ b/Sources/Mocker/Commands/ImageLs.swift @@ -1,54 +1,15 @@ import ArgumentParser -import MockerKit -struct Images: AsyncParsableCommand { +struct ImageLs: AsyncParsableCommand { static let configuration = CommandConfiguration( - abstract: "List images" + commandName: "ls", + abstract: "List images", + aliases: ["images"] ) - @Flag(name: .shortAndLong, help: "Only show image IDs") - var quiet = false - - @Flag(name: .shortAndLong, help: "Show all images (default hides intermediate images)") - var all = false - - @Option(name: .shortAndLong, parsing: .singleValue, help: "Filter output based on conditions provided") - var filter: [String] = [] - - @Option(name: .long, help: "Format output using a custom template") - var format: String? - - @Flag(name: .long, help: "Show digests") - var digests = false - - @Flag(name: .customLong("no-trunc"), help: "Don't truncate output") - var noTrunc = false - - @Flag(name: .long, help: "List images in tree format (experimental)") - var tree = false + @OptionGroup var options: ImageListOptions func run() async throws { - let config = MockerConfig() - let manager = try ImageManager(config: config) - let images = try await manager.list() - - if quiet { - for image in images { - print(image.shortID) - } - return - } - - let headers = ["Repository", "Tag", "Image ID", "Created", "Size"] - let rows = images.map { img in - [ - img.repository, - img.tag, - img.shortID, - img.createdAgo, - img.sizeString, - ] - } - TableFormatter.print(headers: headers, rows: rows) + try await options.render() } } diff --git a/Sources/Mocker/Commands/Images.swift b/Sources/Mocker/Commands/Images.swift new file mode 100644 index 0000000..969a7fc --- /dev/null +++ b/Sources/Mocker/Commands/Images.swift @@ -0,0 +1,14 @@ +import ArgumentParser + +struct Images: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "images", + abstract: "List images" + ) + + @OptionGroup var options: ImageListOptions + + func run() async throws { + try await options.render() + } +} diff --git a/Sources/Mocker/Commands/PS.swift b/Sources/Mocker/Commands/PS.swift new file mode 100644 index 0000000..8607ca9 --- /dev/null +++ b/Sources/Mocker/Commands/PS.swift @@ -0,0 +1,14 @@ +import ArgumentParser + +struct PS: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "ps", + abstract: "List containers" + ) + + @OptionGroup var options: ContainerListOptions + + func run() async throws { + try await options.render() + } +} diff --git a/Tests/MockerTests/CLITests.swift b/Tests/MockerTests/CLITests.swift index eff8b77..1c3c1f6 100644 --- a/Tests/MockerTests/CLITests.swift +++ b/Tests/MockerTests/CLITests.swift @@ -147,4 +147,29 @@ struct CLITests { #expect(command.arch == nil) #expect(command.variant == nil) } + + @Test("image ls command accepts all list flags") + func imageLsAllFlags() throws { + let command = try ImageLs.parse(["--quiet", "--all", "--filter", "dangling=true", "--format", "{{.ID}}", "--digests", "--no-trunc", "--tree"]) + #expect(command.options.quiet == true) + #expect(command.options.all == true) + #expect(command.options.filter == ["dangling=true"]) + #expect(command.options.format == "{{.ID}}") + #expect(command.options.digests == true) + #expect(command.options.noTrunc == true) + #expect(command.options.tree == true) + } + + @Test("container ls command accepts all list flags") + func containerLsAllFlags() throws { + let command = try ContainerLs.parse(["--all", "--quiet", "--filter", "status=running", "--format", "{{.ID}}", "--no-trunc", "-n", "3", "--latest", "--size"]) + #expect(command.options.all == true) + #expect(command.options.quiet == true) + #expect(command.options.filter == ["status=running"]) + #expect(command.options.format == "{{.ID}}") + #expect(command.options.noTrunc == true) + #expect(command.options.last == 3) + #expect(command.options.latest == true) + #expect(command.options.size == true) + } } From 7a99f6d8d78243d2e0db2feeed40a46299ed04d6 Mon Sep 17 00:00:00 2001 From: us Date: Sat, 20 Jun 2026 00:42:36 +0300 Subject: [PATCH 3/3] fix: make image/container ls flags Docker-accurate Implement the previously-dead image list flags so `image ls` reaches parity with `container ls`: - --filter (reference, label), --format, --no-trunc now apply. - --all/--digests/--tree stay accepted no-ops (no backing data). Fix container --latest/--last to include all states regardless of --all, as Docker does and as the flag help already promises. Extract the filter/format logic into pure, unit-tested helpers. --- .../Commands/ContainerListOptions.swift | 63 +++++++++++-------- .../Mocker/Commands/ImageListOptions.swift | 55 +++++++++++++++- Tests/MockerTests/CLITests.swift | 42 ++++++++++++- 3 files changed, 128 insertions(+), 32 deletions(-) diff --git a/Sources/Mocker/Commands/ContainerListOptions.swift b/Sources/Mocker/Commands/ContainerListOptions.swift index a98abfd..9105a00 100644 --- a/Sources/Mocker/Commands/ContainerListOptions.swift +++ b/Sources/Mocker/Commands/ContainerListOptions.swift @@ -27,35 +27,13 @@ struct ContainerListOptions: ParsableArguments { var size = false func render() async throws { + // --size is accepted for Docker compatibility but is a no-op; ContainerInfo + // carries no size data. Add a SIZE column when the engine reports disk usage. let config = MockerConfig() let engine = try ContainerEngine(config: config) - var containers = try await engine.list(all: all) - - for f in filter { - let parts = f.split(separator: "=", maxSplits: 1) - guard parts.count == 2 else { continue } - let key = String(parts[0]) - let value = String(parts[1]) - switch key { - case "name": - containers = containers.filter { $0.name.contains(value) } - case "status": - containers = containers.filter { $0.state.rawValue == value } - case "id": - containers = containers.filter { $0.id.hasPrefix(value) } - case "label": - let labelParts = value.split(separator: "=", maxSplits: 1) - if labelParts.count == 2 { - containers = containers.filter { $0.labels[String(labelParts[0])] == String(labelParts[1]) } - } else { - containers = containers.filter { $0.labels[value] != nil } - } - case "ancestor": - containers = containers.filter { $0.image == value || $0.image.hasPrefix(value) } - default: - break - } - } + // --latest / --last include all states regardless of --all (matches Docker). + let includeAll = all || latest || last != nil + var containers = filtered(try await engine.list(all: includeAll)) if latest { containers = Array(containers.prefix(1)) @@ -101,4 +79,35 @@ struct ContainerListOptions: ParsableArguments { } TableFormatter.print(headers: headers, rows: rows) } + + /// Apply Docker-style `--filter` predicates. Pure; safe to unit-test. + func filtered(_ containers: [ContainerInfo]) -> [ContainerInfo] { + var containers = containers + for f in filter { + let parts = f.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { continue } + let key = String(parts[0]) + let value = String(parts[1]) + switch key { + case "name": + containers = containers.filter { $0.name.contains(value) } + case "status": + containers = containers.filter { $0.state.rawValue == value } + case "id": + containers = containers.filter { $0.id.hasPrefix(value) } + case "label": + let labelParts = value.split(separator: "=", maxSplits: 1) + if labelParts.count == 2 { + containers = containers.filter { $0.labels[String(labelParts[0])] == String(labelParts[1]) } + } else { + containers = containers.filter { $0.labels[value] != nil } + } + case "ancestor": + containers = containers.filter { $0.image == value || $0.image.hasPrefix(value) } + default: + break + } + } + return containers + } } diff --git a/Sources/Mocker/Commands/ImageListOptions.swift b/Sources/Mocker/Commands/ImageListOptions.swift index f969a5c..7f7beda 100644 --- a/Sources/Mocker/Commands/ImageListOptions.swift +++ b/Sources/Mocker/Commands/ImageListOptions.swift @@ -24,13 +24,23 @@ struct ImageListOptions: ParsableArguments { var tree = false func render() async throws { + // --all/--digests/--tree are accepted for Docker compatibility but are no-ops; + // ImageInfo has no intermediate-image, digest, or parent-tree data. Wire them + // up when ImageManager surfaces that data. let config = MockerConfig() let manager = try ImageManager(config: config) - let images = try await manager.list() + let images = filtered(try await manager.list()) if quiet { for image in images { - print(image.shortID) + print(noTrunc ? image.id : image.shortID) + } + return + } + + if format != nil { + for img in images { + print(formatLine(img)) } return } @@ -40,11 +50,50 @@ struct ImageListOptions: ParsableArguments { [ img.repository, img.tag, - img.shortID, + noTrunc ? img.id : img.shortID, img.createdAgo, img.sizeString, ] } TableFormatter.print(headers: headers, rows: rows) } + + /// Render a single image through the `--format` template. Pure; safe to unit-test. + func formatLine(_ img: ImageInfo) -> String { + var output = format ?? "" + output = output.replacingOccurrences(of: "{{.ID}}", with: noTrunc ? img.id : img.shortID) + output = output.replacingOccurrences(of: "{{.Repository}}", with: img.repository) + output = output.replacingOccurrences(of: "{{.Tag}}", with: img.tag) + output = output.replacingOccurrences(of: "{{.Digest}}", with: "") + output = output.replacingOccurrences(of: "{{.CreatedAt}}", with: img.createdAgo) + output = output.replacingOccurrences(of: "{{.Size}}", with: img.sizeString) + output = output.replacingOccurrences(of: "{{.Labels}}", with: img.labels.map { "\($0.key)=\($0.value)" }.joined(separator: ",")) + return output + } + + /// Apply Docker-style `--filter` predicates. Pure; safe to unit-test. + func filtered(_ images: [ImageInfo]) -> [ImageInfo] { + var images = images + for f in filter { + let parts = f.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { continue } + let key = String(parts[0]) + let value = String(parts[1]) + switch key { + case "reference": + // Docker matches a bare repo (any tag) or an exact repo:tag. Glob (`*`) is not supported. + images = images.filter { $0.repository == value || $0.reference == value } + case "label": + let labelParts = value.split(separator: "=", maxSplits: 1) + if labelParts.count == 2 { + images = images.filter { $0.labels[String(labelParts[0])] == String(labelParts[1]) } + } else { + images = images.filter { $0.labels[value] != nil } + } + default: + break + } + } + return images + } } diff --git a/Tests/MockerTests/CLITests.swift b/Tests/MockerTests/CLITests.swift index 1c3c1f6..565b818 100644 --- a/Tests/MockerTests/CLITests.swift +++ b/Tests/MockerTests/CLITests.swift @@ -1,5 +1,7 @@ +import Foundation import Testing import ArgumentParser +import MockerKit @testable import Mocker @Suite("CLI Tests") @@ -150,16 +152,39 @@ struct CLITests { @Test("image ls command accepts all list flags") func imageLsAllFlags() throws { - let command = try ImageLs.parse(["--quiet", "--all", "--filter", "dangling=true", "--format", "{{.ID}}", "--digests", "--no-trunc", "--tree"]) + let command = try ImageLs.parse(["--quiet", "--all", "--filter", "reference=nginx", "--format", "{{.ID}}", "--digests", "--no-trunc", "--tree"]) #expect(command.options.quiet == true) #expect(command.options.all == true) - #expect(command.options.filter == ["dangling=true"]) + #expect(command.options.filter == ["reference=nginx"]) #expect(command.options.format == "{{.ID}}") #expect(command.options.digests == true) #expect(command.options.noTrunc == true) #expect(command.options.tree == true) } + @Test("image ls --filter narrows by reference and label") + func imageLsFilter() throws { + let images = [ + ImageInfo(id: "a", repository: "nginx", tag: "latest", labels: ["env": "prod"]), + ImageInfo(id: "b", repository: "redis", tag: "7", labels: ["env": "dev"]), + ] + let byRef = try ImageLs.parse(["--filter", "reference=nginx"]).options + #expect(byRef.filtered(images).map(\.id) == ["a"]) + + let byLabel = try ImageLs.parse(["--filter", "label=env=dev"]).options + #expect(byLabel.filtered(images).map(\.id) == ["b"]) + } + + @Test("image ls --format substitutes repository, tag and labels") + func imageLsFormat() throws { + let img = ImageInfo(id: "abc123def456789", repository: "nginx", tag: "latest", labels: ["env": "prod"]) + let opts = try ImageLs.parse(["--format", "{{.Repository}}:{{.Tag}} {{.Labels}}"]).options + #expect(opts.formatLine(img) == "nginx:latest env=prod") + + let noTrunc = try ImageLs.parse(["--format", "{{.ID}}", "--no-trunc"]).options + #expect(noTrunc.formatLine(img) == "abc123def456789") + } + @Test("container ls command accepts all list flags") func containerLsAllFlags() throws { let command = try ContainerLs.parse(["--all", "--quiet", "--filter", "status=running", "--format", "{{.ID}}", "--no-trunc", "-n", "3", "--latest", "--size"]) @@ -172,4 +197,17 @@ struct CLITests { #expect(command.options.latest == true) #expect(command.options.size == true) } + + @Test("container ls --filter narrows by status and name") + func containerLsFilter() throws { + let containers = [ + ContainerInfo(id: "a", name: "web", image: "nginx", state: .running, status: "Up", created: .distantPast), + ContainerInfo(id: "b", name: "db", image: "redis", state: .exited, status: "Exited", created: .distantPast), + ] + let byStatus = try ContainerLs.parse(["--filter", "status=running"]).options + #expect(byStatus.filtered(containers).map(\.id) == ["a"]) + + let byName = try ContainerLs.parse(["--filter", "name=db"]).options + #expect(byName.filtered(containers).map(\.id) == ["b"]) + } }