Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Sources/Mocker/Commands/ContainerCmd.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ struct ContainerCommand: AsyncParsableCommand {
Run.self,
Create.self,
Start.self,
PS.self,
ContainerLs.self,
Stop.self,
Restart.self,
Kill.self,
Expand All @@ -31,6 +31,6 @@ struct ContainerCommand: AsyncParsableCommand {
Commit.self,
ContainerPrune.self,
],
defaultSubcommand: PS.self
defaultSubcommand: ContainerLs.self
)
}
113 changes: 113 additions & 0 deletions Sources/Mocker/Commands/ContainerListOptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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 {
// --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)
// --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))
} 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)
}

/// 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
}
}
15 changes: 15 additions & 0 deletions Sources/Mocker/Commands/ContainerLs.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import ArgumentParser

struct ContainerLs: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "ls",
abstract: "List containers",
aliases: ["ps"]
)

@OptionGroup var options: ContainerListOptions

func run() async throws {
try await options.render()
}
}
4 changes: 2 additions & 2 deletions Sources/Mocker/Commands/ImageCmd.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ struct ImageCommand: AsyncParsableCommand {
commandName: "image",
abstract: "Manage images",
subcommands: [
Images.self,
ImageLs.self,
Build.self,
Pull.self,
Push.self,
Expand All @@ -19,6 +19,6 @@ struct ImageCommand: AsyncParsableCommand {
ImageInspect.self,
ImagePrune.self,
],
defaultSubcommand: Images.self
defaultSubcommand: ImageLs.self
)
}
99 changes: 99 additions & 0 deletions Sources/Mocker/Commands/ImageListOptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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 {
// --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 = filtered(try await manager.list())

if quiet {
for image in images {
print(noTrunc ? image.id : image.shortID)
}
return
}

if format != nil {
for img in images {
print(formatLine(img))
}
return
}

let headers = ["Repository", "Tag", "Image ID", "Created", "Size"]
let rows = images.map { img in
[
img.repository,
img.tag,
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: "<none>")
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
}
}
15 changes: 15 additions & 0 deletions Sources/Mocker/Commands/ImageLs.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import ArgumentParser

struct ImageLs: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "ls",
abstract: "List images",
aliases: ["images"]
)

@OptionGroup var options: ImageListOptions

func run() async throws {
try await options.render()
}
}
46 changes: 3 additions & 43 deletions Sources/Mocker/Commands/Images.swift
Original file line number Diff line number Diff line change
@@ -1,54 +1,14 @@
import ArgumentParser
import MockerKit

struct Images: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "images",
abstract: "List 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()
}
}
Loading
Loading