Skip to content
Open
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
1 change: 1 addition & 0 deletions Sources/Container-Compose/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public struct Main: AsyncParsableCommand {
ComposeUp.self,
ComposeDown.self,
ComposeBuild.self,
ComposePs.self,
Version.self
])

Expand Down
101 changes: 101 additions & 0 deletions Sources/Container-Compose/Commands/ComposePs.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved.
//
// 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.
//===----------------------------------------------------------------------===//

//
// ComposePs.swift
// Container-Compose
//
// Implements the `ps` subcommand, which lists the state of a compose
// project's containers.
//

import ArgumentParser
import ContainerCommands
import ContainerAPIClient
import Foundation
import Yams

public struct ComposePs: AsyncParsableCommand {
public init() {}

public static let configuration: CommandConfiguration = .init(
commandName: "ps",
abstract: "List containers for this compose project"
)

@Argument(help: "Specify the services to list")
var services: [String] = []

@Flag(
name: [.customShort("a"), .customLong("all")],
help: "Show all containers (default shows just running)")
var all: Bool = false

@OptionGroup
var project: ComposeProjectOptions

public func run() async throws {
let resolved = try project.resolve(filteringBy: services)
try Self.validateRequestedServices(services, in: resolved)

let client = ContainerClient()
let snapshots = try await client.list()

// A service's container is matched primarily by the compose labels
// `up` stamps (reliable regardless of naming mode), falling back to
// the candidate names for containers created before the labels
// existed. Dependency order from the resolved project is preserved;
// containers that don't exist yet simply don't appear.
let ordered = resolved.services.flatMap { target in
snapshots.filter { snapshot in
let labels = snapshot.configuration.labels
if labels["com.docker.compose.project"] == resolved.projectName {
return labels["com.docker.compose.service"] == target.serviceName
}
return target.candidateContainerNames.contains(snapshot.id)
}
}
let visible = all ? ordered : ordered.filter { $0.status == .running }

let header = ["NAME", "IMAGE", "STATUS", "PORTS"]
let rows = visible.map { snapshot in
[
snapshot.id,
snapshot.configuration.image.reference,
snapshot.status.rawValue,
// Render published ports like `docker compose ps`:
// `0.0.0.0:8080->80/tcp`. Avoids naming the `PublishPort` type
// (not re-exported into this module) by mapping inline.
snapshot.configuration.publishedPorts
.map { "\($0.hostAddress):\($0.hostPort)->\($0.containerPort)/\($0.proto.rawValue)" }
.joined(separator: ", "),
]
}
print(TableFormatter.render(header: header, rows: rows))
}
}

extension ComposePs {
/// Fail fast on a typo'd/unknown service name, matching
/// `docker compose ps <svc>` ("no such service: <svc>") — same contract
/// #139 established for `up`.
static func validateRequestedServices(_ requested: [String], in project: ComposeProject) throws {
let defined: [(serviceName: String, service: Service)] = project.compose.services.compactMap { name, service in
service.map { (name, $0) }
}
try Service.validateRequestedServices(requested, against: defined)
}
}
47 changes: 47 additions & 0 deletions Sources/Container-Compose/TableFormatter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved.
//
// 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.
//===----------------------------------------------------------------------===//

//
// TableFormatter.swift
// Container-Compose
//
// Shared presentation helper for subcommands that render aligned tables.
//

/// Minimal left-aligned, space-padded table renderer.
enum TableFormatter {
static func render(header: [String], rows: [[String]]) -> String {
let allRows = [header] + rows
let columnCount = header.count
var widths = [Int](repeating: 0, count: columnCount)
for row in allRows {
for (index, cell) in row.enumerated() where index < columnCount {
widths[index] = max(widths[index], cell.count)
}
}

func format(_ row: [String]) -> String {
row.enumerated()
.map { index, cell in
// Don't pad the trailing column — avoids dangling whitespace.
index == columnCount - 1 ? cell : cell.padding(toLength: widths[index] + 3, withPad: " ", startingAt: 0)
}
.joined()
}

return allRows.map(format).joined(separator: "\n")
}
}
46 changes: 46 additions & 0 deletions Tests/Container-Compose-StaticTests/ComposePsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved.
//
// 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 Testing
@testable import ContainerComposeCore

@Suite("Compose ps Tests")
struct ComposePsTests {

// MARK: - TableFormatter

@Test("Table columns align and the trailing column is unpadded")
func tableFormatting() {
let out = TableFormatter.render(
header: ["NAME", "STATUS"],
rows: [["web", "running"], ["database", "stopped"]])
let lines = out.split(separator: "\n").map(String.init)
#expect(lines == [
"NAME STATUS",
"web running",
"database stopped",
])
}

// MARK: - CLI parsing

@Test("ps accepts -a and service arguments alongside the project option group")
func psParsesArguments() throws {
let cmd = try ComposePs.parse(["-a", "web"])
#expect(cmd.all)
#expect(cmd.services == ["web"])
}
}
Loading