lean-ctx 3.6.0

Context Runtime for AI Agents with CCP. 63 MCP tools, 10 read modes, 95+ compression patterns, cross-session memory (CCP), persistent AI knowledge with temporal facts + contradiction detection, multi-agent context sharing + diaries, LITM-aware positioning, AAAK compact format, adaptive compression with Thompson Sampling bandits. Supports 24 AI tools. Reduces LLM token consumption by up to 99%.
Documentation
//! `ctx_smells` — Code smell detection tool.
//!
//! Scans the Property Graph for structural issues: dead code, god files,
//! long functions, fan-out skew, duplicate definitions, and more.

use crate::core::property_graph::CodeGraph;
use crate::core::smells::{self, Severity, SmellConfig, SmellFinding};
use crate::core::tokens::count_tokens;
use serde_json::{json, Value};

pub fn handle(
    action: &str,
    rule: Option<&str>,
    path: Option<&str>,
    root: &str,
    format: Option<&str>,
) -> String {
    let fmt = match parse_format(format) {
        Ok(f) => f,
        Err(e) => return e,
    };

    match action {
        "scan" => handle_scan(rule, path, root, fmt),
        "summary" => handle_summary(root, fmt),
        "rules" => handle_rules(fmt),
        "file" => handle_file(path, root, fmt),
        _ => "Unknown action. Use: scan, summary, rules, file".to_string(),
    }
}

#[derive(Clone, Copy)]
enum OutputFormat {
    Text,
    Json,
}

fn parse_format(format: Option<&str>) -> Result<OutputFormat, String> {
    let f = format.unwrap_or("text").trim().to_lowercase();
    match f.as_str() {
        "text" => Ok(OutputFormat::Text),
        "json" => Ok(OutputFormat::Json),
        _ => Err("Error: format must be text|json".to_string()),
    }
}

fn open_graph(root: &str) -> Result<CodeGraph, String> {
    CodeGraph::open(root).map_err(|e| format!("Failed to open graph: {e}"))
}

fn ensure_graph_built(root: &str) {
    let Ok(graph) = CodeGraph::open(root) else {
        return;
    };
    if graph.node_count().unwrap_or(0) == 0 {
        drop(graph);
        let result = crate::tools::ctx_impact::handle("build", None, root, None, None);
        tracing::info!(
            "Auto-built graph for smells: {}",
            &result[..result.len().min(100)]
        );
    }
}

fn handle_scan(rule: Option<&str>, path: Option<&str>, root: &str, fmt: OutputFormat) -> String {
    ensure_graph_built(root);
    let graph = match open_graph(root) {
        Ok(g) => g,
        Err(e) => return e,
    };

    let cfg = SmellConfig::default();
    let mut findings: Vec<SmellFinding> = if let Some(r) = rule {
        smells::scan_rule(graph.connection(), r, &cfg)
    } else {
        smells::scan_all(graph.connection(), &cfg)
    };

    if let Some(p) = path {
        findings.retain(|f| f.file_path.contains(p));
    }

    format_findings(&findings, rule, fmt)
}

fn handle_summary(root: &str, fmt: OutputFormat) -> String {
    ensure_graph_built(root);
    let graph = match open_graph(root) {
        Ok(g) => g,
        Err(e) => return e,
    };

    let cfg = SmellConfig::default();
    let all = smells::scan_all(graph.connection(), &cfg);
    let summary = smells::summarize(&all);
    let total: usize = summary.iter().map(|s| s.findings).sum();

    match fmt {
        OutputFormat::Json => {
            let items: Vec<Value> = summary
                .iter()
                .map(|s| {
                    json!({
                        "rule": s.rule,
                        "description": s.description,
                        "findings": s.findings
                    })
                })
                .collect();
            let v = json!({
                "tool": "ctx_smells",
                "action": "summary",
                "total_findings": total,
                "rules": items
            });
            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
        }
        OutputFormat::Text => {
            let mut result = format!("Code Smell Summary ({total} findings)\n\n");
            for s in &summary {
                let bar = severity_bar(s.findings);
                result.push_str(&format!(
                    "  {:<25} {:>3} {bar}  {}\n",
                    s.rule, s.findings, s.description
                ));
            }
            let tokens = count_tokens(&result);
            format!("{result}\n[ctx_smells summary: {tokens} tok]")
        }
    }
}

fn handle_rules(fmt: OutputFormat) -> String {
    match fmt {
        OutputFormat::Json => {
            let items: Vec<Value> = smells::RULES
                .iter()
                .map(|&(rule, desc)| json!({"rule": rule, "description": desc}))
                .collect();
            let v = json!({
                "tool": "ctx_smells",
                "action": "rules",
                "rules": items
            });
            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
        }
        OutputFormat::Text => {
            let mut result = "Available smell rules:\n\n".to_string();
            for &(rule, desc) in smells::RULES {
                result.push_str(&format!("  {rule:<25} {desc}\n"));
            }
            result
        }
    }
}

fn handle_file(path: Option<&str>, root: &str, fmt: OutputFormat) -> String {
    let Some(target) = path else {
        return "path is required for 'file' action".to_string();
    };

    ensure_graph_built(root);
    let graph = match open_graph(root) {
        Ok(g) => g,
        Err(e) => return e,
    };

    let cfg = SmellConfig::default();
    let mut findings = smells::scan_all(graph.connection(), &cfg);
    findings.retain(|f| f.file_path.contains(target));

    format_findings(&findings, None, fmt)
}

fn format_findings(findings: &[SmellFinding], rule: Option<&str>, fmt: OutputFormat) -> String {
    let label = rule.unwrap_or("all");

    match fmt {
        OutputFormat::Json => {
            let items: Vec<Value> = findings
                .iter()
                .map(|f| {
                    let mut v = json!({
                        "rule": f.rule,
                        "severity": f.severity,
                        "file": f.file_path,
                        "message": f.message,
                    });
                    if let Some(ref sym) = f.symbol {
                        v["symbol"] = json!(sym);
                    }
                    if let Some(line) = f.line {
                        v["line"] = json!(line);
                    }
                    if let Some(metric) = f.metric {
                        v["metric"] = json!(metric);
                    }
                    v
                })
                .collect();
            let v = json!({
                "tool": "ctx_smells",
                "action": "scan",
                "rule_filter": label,
                "total": findings.len(),
                "findings": items
            });
            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
        }
        OutputFormat::Text => {
            if findings.is_empty() {
                return format!("No smells found for rule '{label}'.");
            }

            let mut result = format!(
                "Code Smells ({} findings, rule: {label})\n\n",
                findings.len()
            );
            for f in findings.iter().take(50) {
                let sev = match f.severity {
                    Severity::Error => "ERR",
                    Severity::Warning => "WRN",
                    Severity::Info => "INF",
                };
                let loc = if let Some(line) = f.line {
                    format!("{}:{line}", f.file_path)
                } else {
                    f.file_path.clone()
                };
                result.push_str(&format!("  [{sev}] {loc}\n        {}\n", f.message));
            }
            if findings.len() > 50 {
                result.push_str(&format!("\n  ... +{} more\n", findings.len() - 50));
            }
            let tokens = count_tokens(&result);
            format!("{result}\n[ctx_smells: {tokens} tok]")
        }
    }
}

fn severity_bar(count: usize) -> &'static str {
    match count {
        0 => "",
        1..=5 => ".",
        6..=15 => "..",
        16..=30 => "...",
        _ => "....",
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rules_returns_all() {
        let result = handle("rules", None, None, "/tmp", None);
        assert!(result.contains("dead_code"));
        assert!(result.contains("long_function"));
    }

    #[test]
    fn unknown_action() {
        let result = handle("invalid", None, None, "/tmp", None);
        assert!(result.contains("Unknown action"));
    }
}