use forge::signal::compactor;
use once_cell::sync::Lazy;
use regex::Regex;
static CONTAINER_EVENT_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?m)^ Container [^\s]+ +(Created|Recreated|Started|Stopped|Removed)\n?").unwrap()
});
static PULL_LAYER_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?m)^[a-f0-9]+: (Pull complete|Already exists|Pulling fs layer|Waiting|Downloading|Extracting)\n?").unwrap()
});
static PULLING_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^Pulling [^\n]+\n?").unwrap());
pub fn compress_compose(subcmd: &str, raw: &str) -> String {
let sub = subcmd.trim();
if sub.starts_with("up") {
return compress_up(raw);
}
if sub.starts_with("down") {
return compress_down(raw);
}
if sub.starts_with("logs") {
return compress_logs(raw);
}
if sub.starts_with("pull") {
return compress_pull(raw);
}
if sub.starts_with("build") {
let cleaned = compactor::normalise(raw);
return compactor::collapse_blanks(&cleaned);
}
compactor::normalise(raw)
}
fn compress_up(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let started: Vec<&str> = cleaned
.lines()
.filter(|l| {
let t = l.trim();
t.contains("Started") || t.contains("Healthy") || t.contains("Running")
})
.collect();
let errors: Vec<&str> = cleaned
.lines()
.filter(|l| {
let t = l.to_lowercase();
t.contains("error") || t.contains("exit") || t.contains("unhealthy")
})
.collect();
let s = CONTAINER_EVENT_RE.replace_all(&cleaned, "");
let mut kept: Vec<String> = Vec::new();
for line in s.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
let tl = t.to_lowercase();
if tl.contains("network") && (tl.contains("created") || tl.contains("creating")) {
kept.push(line.to_string());
continue;
}
if tl.contains("error") || tl.contains("exit") || tl.contains("unhealthy") {
kept.push(line.to_string());
continue;
}
if tl.contains("healthy") {
kept.push(line.to_string());
}
}
if errors.is_empty() && !started.is_empty() {
kept.push(format!("{} containers up", started.len()));
}
if kept.is_empty() {
return compactor::collapse_blanks(&cleaned);
}
kept.join("\n")
}
fn compress_down(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let stopped = cleaned
.lines()
.filter(|l| l.trim().contains("Stopped") || l.trim().contains("Removed"))
.count();
let networks: Vec<&str> = cleaned
.lines()
.filter(|l| l.trim().contains("Network") && l.trim().contains("Removed"))
.collect();
let errors: Vec<&str> = cleaned
.lines()
.filter(|l| l.to_lowercase().contains("error"))
.collect();
let mut result = Vec::new();
if stopped > 0 {
result.push(format!("Stopped/removed {stopped} containers"));
}
result.extend(networks.iter().map(|l| l.to_string()));
result.extend(errors.iter().map(|l| l.to_string()));
if result.is_empty() {
return compactor::collapse_blanks(&cleaned);
}
result.join("\n")
}
fn compress_logs(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
const MAX_LINES: usize = 50;
if lines.len() <= MAX_LINES {
return lines.join("\n");
}
let start = lines.len() - MAX_LINES;
format!(
"… [{} earlier lines omitted] …\n{}",
start,
lines[start..].join("\n")
)
}
fn compress_pull(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let s = PULL_LAYER_RE.replace_all(&cleaned, "");
let s = PULLING_RE.replace_all(&s, "");
let kept: Vec<&str> = s
.lines()
.filter(|l| {
let t = l.trim();
!t.is_empty()
&& (t.contains("Pulled")
|| t.contains("Digest")
|| t.contains("Status")
|| t.contains("error"))
})
.collect();
if kept.is_empty() {
return compactor::collapse_blanks(&s);
}
kept.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn up_strips_container_events_when_healthy() {
let raw = " Container app-db-1 Created\n Container app-db-1 Started\n Container app-web-1 Created\n Container app-web-1 Started\n";
let out = compress_compose("up", raw);
assert!(!out.contains("Container app-db-1 Created"), "{out}");
}
#[test]
fn up_keeps_error_lines() {
let raw = " Container app-db-1 Started\nError: port is already allocated\n";
let out = compress_compose("up", raw);
assert!(out.contains("port is already allocated"), "{out}");
}
#[test]
fn down_summarises_stopped_count() {
let raw = " Container app-db-1 Stopped\n Container app-db-1 Removed\n Container app-web-1 Stopped\n Container app-web-1 Removed\n Network app_default Removed\n";
let out = compress_compose("down", raw);
assert!(
out.contains("Stopped") || out.contains("removed") || out.contains("2"),
"{out}"
);
}
#[test]
fn logs_truncates_long_output() {
let raw = (0..80)
.map(|i| format!("web | log line {i}\n"))
.collect::<String>();
let out = compress_compose("logs", &raw);
assert!(out.contains("omitted") || out.contains("…"), "{out}");
}
#[test]
fn pull_strips_layer_progress() {
let raw = "Pulling web ...\nabc123: Pulling fs layer\nabc123: Pull complete\ndef456: Already exists\nStatus: Downloaded newer image for nginx:latest\n";
let out = compress_compose("pull", &raw);
assert!(!out.contains("Pulling fs layer"), "{out}");
assert!(!out.contains("Already exists"), "{out}");
assert!(
out.contains("Status") || out.contains("Downloaded"),
"{out}"
);
}
}