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