use forge::signal::compactor;
use once_cell::sync::Lazy;
use regex::Regex;
static ANNOTATION_BLOCK_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?ms)^Annotations:\s+\S[^\n]*(\n\s+\S[^\n]*)*\n?").unwrap());
static LABEL_BLOCK_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?ms)^Labels:\s+\S[^\n]*(\n\s+\S[^\n]*)*\n?").unwrap());
static MANAGED_FIELDS_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?ms)^Managed Fields:\n(\s+[^\n]+\n)+").unwrap());
pub fn compress_get(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let lines: Vec<&str> = cleaned.lines().collect();
if lines.len() <= 30 {
return cleaned;
}
format!(
"{}\n... [{} more rows] ...",
lines[..30].join("\n"),
lines.len() - 30
)
}
pub fn compress_describe(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let s = ANNOTATION_BLOCK_RE.replace_all(&cleaned, "");
let s = LABEL_BLOCK_RE.replace_all(&s, "");
let s = MANAGED_FIELDS_RE.replace_all(&s, "");
compactor::collapse_blanks(&s)
}
pub fn compress_apply(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let out: Vec<&str> = cleaned
.lines()
.filter(|l| {
l.contains(" created")
|| l.contains(" configured")
|| l.contains(" unchanged")
|| l.contains(" deleted")
|| l.starts_with("Error")
|| l.starts_with("error")
})
.collect();
if out.is_empty() {
cleaned
} else {
out.join("\n")
}
}
pub fn compress_rollout(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let lines: Vec<&str> = cleaned.lines().filter(|l| !l.trim().is_empty()).collect();
if let Some(&last) = lines.last() {
if lines.len() > 3 {
return format!("... [{} status lines] ...\n{}", lines.len() - 1, last);
}
}
cleaned
}
pub fn compress_logs(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
super::super::sys::log_dedup(&cleaned)
}
pub fn compress_events(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let lines: Vec<&str> = cleaned.lines().collect();
if lines.len() <= 20 {
return cleaned;
}
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut out: Vec<&str> = Vec::new();
for line in &lines {
let key = if line.len() > 60 {
&line[line.len() - 60..]
} else {
line
};
if seen.insert(key) {
out.push(line);
}
}
out.join("\n")
}
pub fn compress_top(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let lines: Vec<&str> = cleaned.lines().collect();
if lines.len() <= 12 {
return cleaned;
}
let header = lines[0];
let data: Vec<&str> = lines[1..]
.iter()
.copied()
.filter(|l| !l.trim().is_empty())
.collect();
let mut indexed: Vec<(usize, &str)> = data.iter().copied().enumerate().collect();
indexed.sort_by(|(_, a), (_, b)| {
let cpu_a = parse_cpu(extract_col(a, 1));
let cpu_b = parse_cpu(extract_col(b, 1));
cpu_b
.partial_cmp(&cpu_a)
.unwrap_or(std::cmp::Ordering::Equal)
});
const MAX_ROWS: usize = 10;
let total = indexed.len();
let shown: Vec<&str> = indexed.iter().take(MAX_ROWS).map(|(_, l)| *l).collect();
let mut result = vec![header];
result.extend(shown);
let mut out = result.join("\n");
if total > MAX_ROWS {
out.push_str(&format!(
"\n… {} more rows (sorted by CPU desc)",
total - MAX_ROWS
));
}
out
}
fn extract_col(line: &str, col: usize) -> &str {
line.split_whitespace().nth(col).unwrap_or("0")
}
fn parse_cpu(s: &str) -> f64 {
if let Some(stripped) = s.strip_suffix('m') {
stripped.parse::<f64>().unwrap_or(0.0)
} else {
s.parse::<f64>().unwrap_or(0.0) * 1000.0
}
}
pub fn compress_exec(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
super::super::sys::log_dedup(&cleaned)
}
pub fn compress_config(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let out: Vec<&str> = cleaned
.lines()
.filter(|l| {
!l.contains("certificate-authority-data")
&& !l.contains("client-certificate-data")
&& !l.contains("client-key-data")
})
.collect();
out.join("\n")
}
pub fn compress_kubectl(subcmd: &str, raw: &str) -> String {
let sub = subcmd.trim();
if sub.starts_with("get") {
return compress_get(raw);
}
if sub.starts_with("describe") {
return compress_describe(raw);
}
if sub.starts_with("apply") || sub.starts_with("create") || sub.starts_with("delete") {
return compress_apply(raw);
}
if sub.starts_with("rollout") {
return compress_rollout(raw);
}
if sub.starts_with("logs") {
return compress_logs(raw);
}
if sub.starts_with("events") {
return compress_events(raw);
}
if sub.starts_with("top") {
return compress_top(raw);
}
if sub.starts_with("exec") {
return compress_exec(raw);
}
if sub.starts_with("config") {
return compress_config(raw);
}
compactor::normalise(raw)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn describe_strips_annotations() {
let raw = "Name: my-pod\nAnnotations: kubectl.kubernetes.io/last-applied: {}\n another: val\nStatus: Running\n";
let out = compress_describe(raw);
assert!(!out.contains("Annotations:"), "{out}");
assert!(out.contains("Status: Running"));
}
#[test]
fn apply_keeps_resource_summaries() {
let raw = "deployment.apps/foo configured\nservice/bar unchanged\nconfigmap/baz created\n";
let out = compress_apply(raw);
assert!(out.contains("configured"));
assert!(out.contains("unchanged"));
assert!(out.contains("created"));
}
#[test]
fn get_truncates_long_tables() {
let header = "NAME STATUS AGE\n";
let rows: String = (0..35)
.map(|i| format!("pod-{i} Running 1h\n"))
.collect();
let out = compress_get(&format!("{header}{rows}"));
assert!(out.contains("more rows"), "{out}");
}
#[test]
fn events_deduplicates() {
let raw = "Warning BackOff pod/foo Back-off restarting\nWarning BackOff pod/foo Back-off restarting\nNormal Pulled pod/foo Pulled image\n";
let out = compress_events(raw);
assert!(out.contains("Pulled image"));
}
#[test]
fn top_pods_truncates_and_sorts_by_cpu() {
let header = "NAME CPU(cores) MEMORY(bytes)\n";
let mut rows = String::new();
for i in 0..15usize {
let cpu = if i == 7 { 900 } else { i * 10 };
rows.push_str(&format!("pod-{i:<3} {cpu}m 128Mi\n"));
}
let raw = format!("{header}{rows}");
let out = compress_top(&raw);
assert!(out.contains("CPU(cores)"), "{out}");
assert!(out.contains("more rows"), "{out}");
assert!(out.contains("pod-7"), "{out}");
}
#[test]
fn top_nodes_small_table_passthrough() {
let raw = "NAME CPU(cores) CPU% MEMORY(bytes) MEMORY%\nnode-1 250m 6% 1200Mi 31%\nnode-2 180m 4% 980Mi 25%\n";
let out = compress_top(raw);
assert!(out.contains("node-1"), "{out}");
assert!(out.contains("node-2"), "{out}");
assert!(!out.contains("more rows"), "{out}");
}
#[test]
fn parse_cpu_millicores() {
assert!((super::parse_cpu("250m") - 250.0).abs() < 0.01);
assert!((super::parse_cpu("2") - 2000.0).abs() < 0.01);
assert!((super::parse_cpu("0") - 0.0).abs() < 0.01);
}
}