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(());
}
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() {
let src = "// TODO: remove\n unsafe { bad() }\n";
let findings = analyze_content(src, "all");
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"
);
}
}