1use 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}