bctx-weave 0.1.6

bctx-weave — FilterMesh lens pipeline, CLI interception, domain compression
Documentation
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());

// ── kubectl get ───────────────────────────────────────────────────────────────

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
    )
}

// ── kubectl describe ──────────────────────────────────────────────────────────

pub fn compress_describe(raw: &str) -> String {
    let cleaned = compactor::normalise(raw);
    // Strip annotations, labels, managed fields — noisy and rarely task-relevant
    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)
}

// ── kubectl apply / create / delete ──────────────────────────────────────────

pub fn compress_apply(raw: &str) -> String {
    // Keep only summary lines: "created", "configured", "unchanged", "deleted"
    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")
    }
}

// ── kubectl rollout ───────────────────────────────────────────────────────────

pub fn compress_rollout(raw: &str) -> String {
    let cleaned = compactor::normalise(raw);
    // Keep last non-empty line (final status)
    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
}

// ── kubectl logs ──────────────────────────────────────────────────────────────

pub fn compress_logs(raw: &str) -> String {
    let cleaned = compactor::normalise(raw);
    super::super::sys::log_dedup(&cleaned)
}

// ── kubectl events ────────────────────────────────────────────────────────────

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;
    }
    // Deduplicate repeated event messages
    let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
    let mut out: Vec<&str> = Vec::new();
    for line in &lines {
        // Use last 60 chars as dedup key (avoids count-column differences)
        let key = if line.len() > 60 {
            &line[line.len() - 60..]
        } else {
            line
        };
        if seen.insert(key) {
            out.push(line);
        }
    }
    out.join("\n")
}

// ── kubectl top ───────────────────────────────────────────────────────────────

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;
    }

    // Separate header from data rows
    let header = lines[0];
    let data: Vec<&str> = lines[1..]
        .iter()
        .copied()
        .filter(|l| !l.trim().is_empty())
        .collect();

    // For `kubectl top pods`: sort by CPU column (3rd column for pods, 2nd for nodes)
    // Detect pods vs nodes by header content
    // Try to sort by CPU (parse millicores like "120m" or plain "120")
    // CPU is column 1 for both `top pods` and `top nodes`
    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
    }
}

// ── kubectl exec ──────────────────────────────────────────────────────────────

pub fn compress_exec(raw: &str) -> String {
    // exec output is program output — deduplicate repeated lines
    let cleaned = compactor::normalise(raw);
    super::super::sys::log_dedup(&cleaned)
}

// ── kubectl config ────────────────────────────────────────────────────────────

pub fn compress_config(raw: &str) -> String {
    // config view: strip certificate-authority-data (long base64 blobs)
    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")
}

// ── top-level dispatcher ──────────────────────────────────────────────────────

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();
        // Row with highest CPU is in the middle
        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);
        // Should keep header
        assert!(out.contains("CPU(cores)"), "{out}");
        // Should mention truncation
        assert!(out.contains("more rows"), "{out}");
        // Highest CPU pod should appear first in data rows
        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);
    }
}