bctx 0.1.15

bctx CLI — intercept CLI commands and compress output for LLM coding agents
use anyhow::Result;
use std::io::IsTerminal;
use std::path::Path;

fn read_path(p: &str) -> Result<String> {
    let path = Path::new(p);
    if path.is_dir() {
        let mut buf = String::new();
        read_dir_recursive(path, &mut buf)?;
        if buf.is_empty() {
            anyhow::bail!("no source files found in '{p}'");
        }
        return Ok(buf);
    }
    std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("cannot read '{p}': {e}"))
}

fn read_dir_recursive(dir: &Path, buf: &mut String) -> Result<()> {
    let extensions = [
        "rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "kt", "rb",
    ];
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();
        if path.is_dir() {
            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
            if matches!(
                name,
                "node_modules" | ".git" | "target" | "dist" | "build" | ".cache"
            ) {
                continue;
            }
            read_dir_recursive(&path, buf)?;
        } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
            if extensions.contains(&ext) {
                if let Ok(text) = std::fs::read_to_string(&path) {
                    buf.push_str(&text);
                    buf.push('\n');
                }
            }
        }
    }
    Ok(())
}

pub fn handle(path: Option<String>, staged: bool, focus: String) -> Result<()> {
    let (source_label, content) = if let Some(ref p) = path {
        let text = read_path(p)?;
        (p.to_string(), text)
    } else {
        let diff_arg = if staged { "--staged" } else { "HEAD" };
        let out = std::process::Command::new("git")
            .args(["diff", diff_arg])
            .output()
            .map_err(|_| anyhow::anyhow!("git not found — pass --file <path> instead"))?;
        let text = String::from_utf8_lossy(&out.stdout).to_string();
        if text.trim().is_empty() {
            println!();
            println!("  No changes to smell. Working tree is clean.");
            println!();
            return Ok(());
        }
        let label = if staged {
            "git diff --staged".to_string()
        } else {
            "git diff HEAD".to_string()
        };
        (label, text)
    };

    let findings = atlas::skills::arbiter::analyze_content(&content, &focus);

    let sep = "  ─────────────────────────────────────────────────────────";
    let is_tty = std::io::stdout().is_terminal();

    println!();
    println!("  bctx smells  ({source_label} · focus: {focus})");
    println!("{sep}");

    if findings.is_empty() {
        println!("  No findings. Looking good!");
        println!("{sep}");
        println!();
        return Ok(());
    }

    // Group by severity for ordering: high → medium → low
    let order = |sev: &str| match sev {
        "high" => 0,
        "medium" => 1,
        _ => 2,
    };
    let mut sorted = findings.clone();
    sorted.sort_by_key(|f| order(f["severity"].as_str().unwrap_or("low")));

    for f in &sorted {
        let sev = f["severity"].as_str().unwrap_or("low");
        let cat = f["category"].as_str().unwrap_or("style");
        let line = f["line"].as_i64().unwrap_or(0);
        let msg = f["message"].as_str().unwrap_or("");

        let sev_colored = if is_tty {
            match sev {
                "high" => format!("\x1b[31m{sev}\x1b[0m"),
                "medium" => format!("\x1b[33m{sev}\x1b[0m"),
                _ => format!("\x1b[2m{sev}\x1b[0m"),
            }
        } else {
            sev.to_string()
        };

        println!("  line {line:<5}  [{sev_colored} · {cat}]  {msg}");
    }

    println!("{sep}");

    let high = findings.iter().filter(|f| f["severity"] == "high").count();
    let med = findings
        .iter()
        .filter(|f| f["severity"] == "medium")
        .count();
    let low = findings.iter().filter(|f| f["severity"] == "low").count();
    let total = findings.len();

    let summary = if is_tty {
        format!(
            "  {} findings  (\x1b[31m{high} high\x1b[0m · \x1b[33m{med} medium\x1b[0m · \x1b[2m{low} low\x1b[0m)",
            total
        )
    } else {
        format!("  {total} findings  ({high} high · {med} medium · {low} low)")
    };
    println!("{summary}");
    println!();

    if focus == "all" {
        println!("  Tip: `bctx smells --focus security` to narrow results");
        println!();
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use atlas::skills::arbiter::analyze_content;

    #[test]
    fn clean_diff_has_no_findings() {
        let diff = "diff --git a/foo.rs b/foo.rs\n+fn add(a: u32, b: u32) -> u32 { a + b }\n";
        assert!(analyze_content(diff, "all").is_empty());
    }

    #[test]
    fn unwrap_in_diff_flagged() {
        let diff = "+    let val = map.get(key).unwrap();\n";
        let findings = analyze_content(diff, "all");
        assert!(!findings.is_empty());
    }

    #[test]
    fn unsafe_block_is_high_severity() {
        let src = "    unsafe { ptr::read(p) }\n";
        let findings = analyze_content(src, "all");
        assert!(
            findings.iter().any(|f| f["severity"] == "high"),
            "expected a high-severity finding for unsafe block"
        );
    }

    #[test]
    fn todo_marker_is_low_style() {
        let src = "// TODO: fix this before release\n";
        let findings = analyze_content(src, "all");
        let f = findings
            .iter()
            .find(|f| f["severity"] == "low")
            .expect("expected a low-severity finding for TODO");
        assert_eq!(f["category"], "style");
    }

    #[test]
    fn findings_sorted_high_before_low() {
        // unsafe → high, TODO → low; after severity sort high must come first
        let src = "// TODO: remove\n    unsafe { bad() }\n";
        let findings = analyze_content(src, "all");

        // Replicate the sort applied by the handle() function
        let order = |sev: &str| match sev {
            "high" => 0,
            "medium" => 1,
            _ => 2,
        };
        let mut sorted = findings.clone();
        sorted.sort_by_key(|f| order(f["severity"].as_str().unwrap_or("low")));

        let first_sev = sorted[0]["severity"].as_str().unwrap_or("");
        let last_sev = sorted[sorted.len() - 1]["severity"].as_str().unwrap_or("");
        assert_eq!(first_sev, "high", "first finding after sort should be high");
        assert_eq!(last_sev, "low", "last finding after sort should be low");
    }

    #[test]
    fn missing_file_returns_error() {
        let result = handle(
            Some("/does/not/exist/file.rs".to_string()),
            false,
            "all".to_string(),
        );
        assert!(result.is_err(), "expected error for non-existent file");
    }

    #[test]
    fn performance_focus_excludes_security_findings() {
        let src = "    unsafe { bad() }\nfor x in v { x.clone(); }\n";
        let findings = analyze_content(src, "performance");
        assert!(findings.iter().all(|f| f["category"] != "security"));
    }

    #[test]
    fn debug_println_in_added_line_flagged() {
        let diff = "+    println!(\"debug val = {:?}\", val);\n";
        let findings = analyze_content(diff, "all");
        assert!(
            findings
                .iter()
                .any(|f| f["message"].as_str().unwrap_or("").contains("debug output")),
            "expected debug output finding for println! in added diff line"
        );
    }
}