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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ rtk docker ps # Compact container list
rtk docker images # Compact image list
rtk docker logs <container> # Deduplicated logs
rtk docker compose ps # Compose services
rtk docker compose up -d # Compact status summary (foreground passes through)
rtk kubectl pods # Compact pod list
rtk kubectl logs <pod> # Deduplicated logs
rtk kubectl services # Compact service list
Expand Down
192 changes: 192 additions & 0 deletions src/cmds/cloud/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,91 @@ pub fn format_compose_build(raw: &str) -> String {
result.trim_end().to_string()
}

/// Format `docker compose up -d` output into a compact summary.
///
/// Compose reprints each network/container as it moves through status
/// transitions (e.g. Created → Starting → Started, or Running → Waiting →
/// Healthy for entities with healthchecks) — keep only the last reported
/// status per entity instead of every transition line.
pub fn format_compose_up(raw: &str) -> String {
if raw.trim().is_empty() {
return "[compose] up: no output".to_string();
}

let mut order: Vec<String> = Vec::new();
let mut entities: std::collections::HashMap<String, (String, String)> =
std::collections::HashMap::new();
let mut orphan_warning = false;
let mut other_warnings = 0usize;

for line in raw.lines() {
let trimmed = line.trim().trim_start_matches(['✔', '✘']).trim();
if trimmed.is_empty() {
continue;
}

// Docker's own log lines (orphan container notices, etc.) rather than
// a compose status line — surface as a condensed warning, not verbatim.
if trimmed.starts_with("time=") {
if trimmed.contains("orphan containers") {
orphan_warning = true;
} else if trimmed.contains("level=warning") || trimmed.contains("level=error") {
other_warnings += 1;
}
continue;
}

let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() < 3 || !matches!(parts[0], "Network" | "Volume" | "Container" | "Image") {
continue;
}
let kind = parts[0].to_lowercase();
let name = parts[1].to_string();
let status = parts[2].to_string();

if !entities.contains_key(&name) {
order.push(name.clone());
}
entities.insert(name, (kind, status));
}

if order.is_empty() {
return "[compose] up: no output".to_string();
}

// Group by final status (most entities converge on the same one or two
// statuses) instead of repeating a line per entity.
let mut status_order: Vec<String> = Vec::new();
let mut groups: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
for name in &order {
let (kind, status) = entities.get(name).expect("name was just pushed to order");
let label = if kind == "container" {
name.clone()
} else {
format!("{} ({})", name, kind)
};
groups.entry(status.clone()).or_insert_with(|| {
status_order.push(status.clone());
Vec::new()
});
groups.get_mut(status).unwrap().push(label);
}

let mut result = format!("[compose] up: {} services\n", order.len());
for status in &status_order {
let names = groups.get(status).expect("status was just recorded");
result.push_str(&format!(" {}: {}\n", status, names.join(", ")));
}
if orphan_warning {
result.push_str("[warn] orphan containers — run with --remove-orphans\n");
}
if other_warnings > 0 {
result.push_str(&format!("[warn] {} other warning(s)\n", other_warnings));
}

result.trim_end().to_string()
}

fn compact_ports(ports: &str) -> String {
if ports.is_empty() {
return "-".to_string();
Expand Down Expand Up @@ -760,6 +845,37 @@ pub fn run_compose_build(service: Option<&str>, verbose: u8) -> Result<i32> {
)
}

/// Run `docker compose up`. Only detached runs (`-d`/`--detach`) are filtered
/// into a compact summary — a foreground `up` streams logs indefinitely, so it
/// passes through untouched rather than buffering forever waiting for exit.
pub fn run_compose_up(args: &[String], verbose: u8) -> Result<i32> {
let detached = args.iter().any(|a| a == "-d" || a == "--detach");
if !detached {
let mut combined = vec![OsString::from("compose"), OsString::from("up")];
combined.extend(args.iter().map(OsString::from));
return crate::core::runner::run_passthrough("docker", &combined, verbose);
}

let mut cmd = resolved_command("docker");
cmd.arg("compose").arg("up");
for a in args {
cmd.arg(a);
}

runner::run_filtered(
cmd,
"docker",
"compose up",
|raw| {
if verbose > 0 {
eprintln!("raw docker compose up:\n{}", raw);
}
format_compose_up(raw)
},
RunOptions::default().early_exit_on_failure(),
)
}

pub fn run_compose_passthrough(args: &[OsString], verbose: u8) -> Result<i32> {
let mut combined = vec![OsString::from("compose")];
combined.extend_from_slice(args);
Expand Down Expand Up @@ -937,6 +1053,82 @@ api-1 | Connected to database";
);
}

// ── format_compose_up ───────────────────────────────────

#[test]
fn test_format_compose_up_real_fixture_savings() {
let raw = include_str!("../../../tests/fixtures/docker_compose_up_raw.txt");
let out = format_compose_up(raw);

assert!(out.contains("4 services"), "should count distinct entities");
assert!(out.contains("datalake-postgres-1"), "should list container name");
// postgres transitions Running -> Waiting -> Healthy; only the last should show.
assert!(out.contains("Healthy"), "should keep the final status per entity");
assert_eq!(
out.matches("datalake-postgres-1").count(),
1,
"should not repeat an entity that transitioned through several statuses"
);
assert!(out.contains("[warn] orphan containers"), "should surface the orphan warning");
assert!(
!out.contains("level=warning"),
"should not leak raw docker log formatting"
);

let input_tokens = raw.split_whitespace().count();
let output_tokens = out.split_whitespace().count();
let savings = 100.0 - (output_tokens as f64 / input_tokens as f64 * 100.0);
assert!(savings >= 60.0, "Expected >=60% savings, got {:.1}%", savings);
}

#[test]
fn test_format_compose_up_fresh_creation() {
// Compose prints a distinct set of verbs when creating from scratch
// rather than reconciling already-running containers. The network is
// only ever "Created" (never "Started"), so it must be kept — only the
// containers' intermediate "Starting" transition should be dropped.
let raw = "\
Network myapp_default Created
Container myapp-db-1 Created
Container myapp-web-1 Created
Container myapp-db-1 Starting
Container myapp-web-1 Starting
Container myapp-db-1 Started
Container myapp-web-1 Started";
let out = format_compose_up(raw);
assert!(out.contains("3 services"), "should count network + 2 containers");
assert!(out.contains("myapp-db-1"), "should show container name");
assert!(out.contains("myapp_default"), "should show network name");
assert!(out.contains("network"), "should label the non-container entity");
assert!(out.contains("Started"), "should keep the containers' final status");
assert!(
!out.contains("Starting"),
"intermediate container transitions should be dropped"
);
}

#[test]
fn test_format_compose_up_empty() {
let out = format_compose_up("");
assert!(out.contains("no output"));
}

#[test]
fn test_format_compose_up_whitespace_only() {
let out = format_compose_up(" \n \n");
assert!(out.contains("no output"));
}

#[test]
fn test_format_compose_up_no_orphan_warning_when_absent() {
let raw = " Container myapp-web-1 Running";
let out = format_compose_up(raw);
assert!(
!out.contains("[warn]"),
"should not mention orphans when there is no such warning"
);
}

// ── compact_ports (existing, previously untested) ──────

#[test]
Expand Down
15 changes: 13 additions & 2 deletions src/discover/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2344,11 +2344,22 @@ mod tests {
);
}

// `up` gained a filter (compact summary when detached, passthrough
// otherwise — see container::run_compose_up) so it is rewritten either way;
// the flag-aware choice between filtering and passthrough happens downstream.
#[test]
fn test_rewrite_docker_compose_up_skipped() {
fn test_rewrite_docker_compose_up_detached() {
assert_eq!(
rewrite_command_no_prefixes("docker compose up -d", &[]),
None
Some("rtk docker compose up -d".into())
);
}

#[test]
fn test_rewrite_docker_compose_up_foreground() {
assert_eq!(
rewrite_command_no_prefixes("docker compose up", &[]),
Some("rtk docker compose up".into())
);
}

Expand Down
2 changes: 1 addition & 1 deletion src/discover/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ pub const RULES: &[RtkRule] = &[
subcmd_status: &[],
},
RtkRule {
pattern: r"^docker\s+(ps|images|logs|run|exec|build|compose\s+(ps|logs|build))",
pattern: r"^docker\s+(ps|images|logs|run|exec|build|compose\s+(ps|logs|build|up))",
rtk_cmd: "rtk docker",
rewrite_prefixes: &["docker"],
category: "Infra",
Expand Down
6 changes: 6 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,11 @@ enum ComposeCommands {
/// Optional service name
service: Option<String>,
},
/// Start compose services (compact summary in detached mode)
Up {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Passthrough: runs any unsupported compose subcommand directly
#[command(external_subcommand)]
Other(Vec<OsString>),
Expand Down Expand Up @@ -1893,6 +1898,7 @@ fn run_cli() -> Result<i32> {
ComposeCommands::Build { service } => {
container::run_compose_build(service.as_deref(), cli.verbose)?
}
ComposeCommands::Up { args } => container::run_compose_up(&args, cli.verbose)?,
ComposeCommands::Other(args) => {
container::run_compose_passthrough(&args, cli.verbose)?
}
Expand Down
7 changes: 7 additions & 0 deletions tests/fixtures/docker_compose_up_raw.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
time="2026-07-14T15:52:47+02:00" level=warning msg="Found orphan containers ([datalake-garage-init-1 datalake-garage-1]) for this project. If you removed or renamed this service in your compose file, you can run this command with the --remove-orphans flag to clean it up."
Container datalake-postgres-1 Running
Container datalake-rustfs-1 Running
Container datalake-backend-1 Running
Container datalake-frontend-1 Running
Container datalake-postgres-1 Waiting
Container datalake-postgres-1 Healthy