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