Skip to main content

lean_ctx/tools/
ctx_smells.rs

1//! `ctx_smells` — Code smell detection tool.
2//!
3//! Scans the Property Graph for structural issues: dead code, god files,
4//! long functions, fan-out skew, duplicate definitions, and more.
5
6use crate::core::property_graph::CodeGraph;
7use crate::core::smells::{self, Severity, SmellConfig, SmellFinding};
8use crate::core::tokens::count_tokens;
9use serde_json::{json, Value};
10
11pub fn handle(
12    action: &str,
13    rule: Option<&str>,
14    path: Option<&str>,
15    root: &str,
16    format: Option<&str>,
17) -> String {
18    let fmt = match parse_format(format) {
19        Ok(f) => f,
20        Err(e) => return e,
21    };
22
23    match action {
24        "scan" => handle_scan(rule, path, root, fmt),
25        "summary" => handle_summary(root, fmt),
26        "rules" => handle_rules(fmt),
27        "file" => handle_file(path, root, fmt),
28        _ => "Unknown action. Use: scan, summary, rules, file".to_string(),
29    }
30}
31
32#[derive(Clone, Copy)]
33enum OutputFormat {
34    Text,
35    Json,
36}
37
38fn parse_format(format: Option<&str>) -> Result<OutputFormat, String> {
39    let f = format.unwrap_or("text").trim().to_lowercase();
40    match f.as_str() {
41        "text" => Ok(OutputFormat::Text),
42        "json" => Ok(OutputFormat::Json),
43        _ => Err("Error: format must be text|json".to_string()),
44    }
45}
46
47fn open_graph(root: &str) -> Result<CodeGraph, String> {
48    CodeGraph::open(root).map_err(|e| format!("Failed to open graph: {e}"))
49}
50
51fn ensure_graph_built(root: &str) {
52    let Ok(graph) = CodeGraph::open(root) else {
53        return;
54    };
55    if graph.node_count().unwrap_or(0) == 0 {
56        drop(graph);
57        let result = crate::tools::ctx_impact::handle("build", None, root, None, None);
58        tracing::info!(
59            "Auto-built graph for smells: {}",
60            &result[..result.len().min(100)]
61        );
62    }
63}
64
65fn handle_scan(rule: Option<&str>, path: Option<&str>, root: &str, fmt: OutputFormat) -> String {
66    ensure_graph_built(root);
67    let graph = match open_graph(root) {
68        Ok(g) => g,
69        Err(e) => return e,
70    };
71
72    let cfg = SmellConfig::default();
73    let mut findings: Vec<SmellFinding> = if let Some(r) = rule {
74        smells::scan_rule(graph.connection(), r, &cfg)
75    } else {
76        smells::scan_all(graph.connection(), &cfg)
77    };
78
79    if let Some(p) = path {
80        findings.retain(|f| f.file_path.contains(p));
81    }
82
83    format_findings(&findings, rule, fmt)
84}
85
86fn handle_summary(root: &str, fmt: OutputFormat) -> String {
87    ensure_graph_built(root);
88    let graph = match open_graph(root) {
89        Ok(g) => g,
90        Err(e) => return e,
91    };
92
93    let cfg = SmellConfig::default();
94    let all = smells::scan_all(graph.connection(), &cfg);
95    let summary = smells::summarize(&all);
96    let total: usize = summary.iter().map(|s| s.findings).sum();
97
98    match fmt {
99        OutputFormat::Json => {
100            let items: Vec<Value> = summary
101                .iter()
102                .map(|s| {
103                    json!({
104                        "rule": s.rule,
105                        "description": s.description,
106                        "findings": s.findings
107                    })
108                })
109                .collect();
110            let v = json!({
111                "tool": "ctx_smells",
112                "action": "summary",
113                "total_findings": total,
114                "rules": items
115            });
116            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
117        }
118        OutputFormat::Text => {
119            let mut result = format!("Code Smell Summary ({total} findings)\n\n");
120            for s in &summary {
121                let bar = severity_bar(s.findings);
122                result.push_str(&format!(
123                    "  {:<25} {:>3} {bar}  {}\n",
124                    s.rule, s.findings, s.description
125                ));
126            }
127            let tokens = count_tokens(&result);
128            format!("{result}\n[ctx_smells summary: {tokens} tok]")
129        }
130    }
131}
132
133fn handle_rules(fmt: OutputFormat) -> String {
134    match fmt {
135        OutputFormat::Json => {
136            let items: Vec<Value> = smells::RULES
137                .iter()
138                .map(|&(rule, desc)| json!({"rule": rule, "description": desc}))
139                .collect();
140            let v = json!({
141                "tool": "ctx_smells",
142                "action": "rules",
143                "rules": items
144            });
145            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
146        }
147        OutputFormat::Text => {
148            let mut result = "Available smell rules:\n\n".to_string();
149            for &(rule, desc) in smells::RULES {
150                result.push_str(&format!("  {rule:<25} {desc}\n"));
151            }
152            result
153        }
154    }
155}
156
157fn handle_file(path: Option<&str>, root: &str, fmt: OutputFormat) -> String {
158    let Some(target) = path else {
159        return "path is required for 'file' action".to_string();
160    };
161
162    ensure_graph_built(root);
163    let graph = match open_graph(root) {
164        Ok(g) => g,
165        Err(e) => return e,
166    };
167
168    let cfg = SmellConfig::default();
169    let mut findings = smells::scan_all(graph.connection(), &cfg);
170    findings.retain(|f| f.file_path.contains(target));
171
172    format_findings(&findings, None, fmt)
173}
174
175fn format_findings(findings: &[SmellFinding], rule: Option<&str>, fmt: OutputFormat) -> String {
176    let label = rule.unwrap_or("all");
177
178    match fmt {
179        OutputFormat::Json => {
180            let items: Vec<Value> = findings
181                .iter()
182                .map(|f| {
183                    let mut v = json!({
184                        "rule": f.rule,
185                        "severity": f.severity,
186                        "file": f.file_path,
187                        "message": f.message,
188                    });
189                    if let Some(ref sym) = f.symbol {
190                        v["symbol"] = json!(sym);
191                    }
192                    if let Some(line) = f.line {
193                        v["line"] = json!(line);
194                    }
195                    if let Some(metric) = f.metric {
196                        v["metric"] = json!(metric);
197                    }
198                    v
199                })
200                .collect();
201            let v = json!({
202                "tool": "ctx_smells",
203                "action": "scan",
204                "rule_filter": label,
205                "total": findings.len(),
206                "findings": items
207            });
208            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
209        }
210        OutputFormat::Text => {
211            if findings.is_empty() {
212                return format!("No smells found for rule '{label}'.");
213            }
214
215            let mut result = format!(
216                "Code Smells ({} findings, rule: {label})\n\n",
217                findings.len()
218            );
219            for f in findings.iter().take(50) {
220                let sev = match f.severity {
221                    Severity::Error => "ERR",
222                    Severity::Warning => "WRN",
223                    Severity::Info => "INF",
224                };
225                let loc = if let Some(line) = f.line {
226                    format!("{}:{line}", f.file_path)
227                } else {
228                    f.file_path.clone()
229                };
230                result.push_str(&format!("  [{sev}] {loc}\n        {}\n", f.message));
231            }
232            if findings.len() > 50 {
233                result.push_str(&format!("\n  ... +{} more\n", findings.len() - 50));
234            }
235            let tokens = count_tokens(&result);
236            format!("{result}\n[ctx_smells: {tokens} tok]")
237        }
238    }
239}
240
241fn severity_bar(count: usize) -> &'static str {
242    match count {
243        0 => "",
244        1..=5 => ".",
245        6..=15 => "..",
246        16..=30 => "...",
247        _ => "....",
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn rules_returns_all() {
257        let result = handle("rules", None, None, "/tmp", None);
258        assert!(result.contains("dead_code"));
259        assert!(result.contains("long_function"));
260    }
261
262    #[test]
263    fn unknown_action() {
264        let result = handle("invalid", None, None, "/tmp", None);
265        assert!(result.contains("Unknown action"));
266    }
267}