1use crate::Finding;
2
3pub trait Reporter {
5 fn render(&self, findings: &[Finding]) -> String;
6}
7
8pub struct TerminalReporter {
10 pub show_all: bool,
12 pub top: Option<usize>,
14}
15
16impl Reporter for TerminalReporter {
17 fn render(&self, findings: &[Finding]) -> String {
18 if findings.is_empty() {
19 return "No issues found.".into();
20 }
21 let total = findings.len();
22 let shown = match self.top {
23 Some(n) => &findings[..n.min(total)],
24 None => findings,
25 };
26 let mut out = String::new();
27 if self.show_all || self.top.is_some() {
28 for f in shown {
29 render_terminal_finding(&mut out, f);
30 }
31 } else {
32 render_grouped(&mut out, shown);
33 }
34 render_summary(&mut out, findings);
35 if let Some(n) = self.top
36 && n < total
37 {
38 out.push_str(&format!(" (showing top {n})"));
39 }
40 out
41 }
42}
43
44fn render_grouped(out: &mut String, findings: &[Finding]) {
45 let mut groups: std::collections::BTreeMap<&str, Vec<&Finding>> =
46 std::collections::BTreeMap::new();
47 for f in findings {
48 groups.entry(&f.smell_name).or_default().push(f);
49 }
50 for (name, group) in &groups {
51 if group.len() <= 5 {
52 for f in group {
53 render_terminal_finding(out, f);
54 }
55 } else {
56 render_aggregated(out, name, group);
57 }
58 }
59}
60
61fn render_aggregated(out: &mut String, name: &str, group: &[&Finding]) {
62 let mut sorted: Vec<&&Finding> = group.iter().collect();
63 sorted.sort_by(|a, b| {
64 b.actual_value
65 .unwrap_or(0.0)
66 .partial_cmp(&a.actual_value.unwrap_or(0.0))
67 .unwrap_or(std::cmp::Ordering::Equal)
68 });
69 let icon = severity_icon(&sorted[0].severity);
70 out.push_str(&format!("{icon} [{name}] {} occurrences\n", group.len()));
71 for f in sorted.iter().take(3) {
72 let loc = if f.location.start_col > 0 {
73 format!(
74 "{}:{}:{}",
75 f.location.path.display(),
76 f.location.start_line,
77 f.location.start_col
78 )
79 } else {
80 format!("{}:{}", f.location.path.display(), f.location.start_line)
81 };
82 out.push_str(&format!(" → {loc} {}\n", f.message));
83 }
84 if group.len() > 3 {
85 out.push_str(&format!(
86 " … and {} more (use --all to show all)\n",
87 group.len() - 3
88 ));
89 }
90}
91
92fn render_summary(out: &mut String, findings: &[Finding]) {
93 let errors = findings
94 .iter()
95 .filter(|f| f.severity == crate::Severity::Error)
96 .count();
97 let warnings = findings
98 .iter()
99 .filter(|f| f.severity == crate::Severity::Warning)
100 .count();
101 let hints = findings.len() - errors - warnings;
102 out.push_str(&format!(
103 "\n{} issue(s) found ({} error, {} warning, {} hint).",
104 findings.len(),
105 errors,
106 warnings,
107 hints
108 ));
109}
110
111fn severity_icon(s: &crate::Severity) -> &'static str {
112 match s {
113 crate::Severity::Error => "✗",
114 crate::Severity::Warning => "⚠",
115 crate::Severity::Hint => "ℹ",
116 }
117}
118
119fn render_terminal_finding(out: &mut String, f: &Finding) {
120 let loc = if f.location.start_col > 0 {
121 format!(
122 "{}:{}:{}-{}:{}",
123 f.location.path.display(),
124 f.location.start_line,
125 f.location.start_col,
126 f.location.end_line,
127 f.location.end_col,
128 )
129 } else {
130 format!(
131 "{}:{}-{}",
132 f.location.path.display(),
133 f.location.start_line,
134 f.location.end_line,
135 )
136 };
137 out.push_str(&format!(
138 "{} [{}] {loc} {}\n",
139 severity_icon(&f.severity),
140 f.smell_name,
141 f.message,
142 ));
143 if !f.suggested_refactorings.is_empty() {
144 out.push_str(&format!(
145 " → suggested: {}\n",
146 f.suggested_refactorings.join(", ")
147 ));
148 }
149}
150
151pub struct JsonReporter;
153
154impl Reporter for JsonReporter {
155 fn render(&self, findings: &[Finding]) -> String {
156 serde_json::to_string_pretty(findings).unwrap_or_default()
157 }
158}
159
160impl JsonReporter {
161 pub fn render_with_scores(
163 &self,
164 findings: &[Finding],
165 scores: &[crate::health::HealthScore],
166 ) -> String {
167 let report = serde_json::json!({
168 "findings": findings,
169 "health_scores": scores.iter().map(|s| serde_json::json!({
170 "path": s.path,
171 "grade": s.grade.to_string(),
172 "debt_minutes": s.debt_minutes,
173 "lines": s.lines,
174 })).collect::<Vec<_>>(),
175 });
176 serde_json::to_string_pretty(&report).unwrap_or_default()
177 }
178}
179
180pub struct LlmContextReporter;
182
183impl Reporter for LlmContextReporter {
184 fn render(&self, findings: &[Finding]) -> String {
185 if findings.is_empty() {
186 return "No code smells detected.".into();
187 }
188 let mut out = String::from("# Code Smell Analysis\n\n");
189 for (i, f) in findings.iter().enumerate() {
190 render_llm_issue(&mut out, i, f);
191 }
192 out.push_str("Please apply the suggested refactorings to improve code quality.\n");
193 out
194 }
195}
196
197fn render_llm_issue(out: &mut String, index: usize, f: &Finding) {
199 out.push_str(&format!("## Issue {}\n\n", index + 1));
200 out.push_str(&format!("- **Smell**: {}\n", f.smell_name));
201 out.push_str(&format!("- **Category**: {:?}\n", f.category));
202 out.push_str(&format!("- **Severity**: {:?}\n", f.severity));
203 let loc = if f.location.start_col > 0 {
204 format!(
205 "{}:{}:{}-{}:{}",
206 f.location.path.display(),
207 f.location.start_line,
208 f.location.start_col,
209 f.location.end_line,
210 f.location.end_col,
211 )
212 } else {
213 format!(
214 "{}:{}-{}",
215 f.location.path.display(),
216 f.location.start_line,
217 f.location.end_line,
218 )
219 };
220 out.push_str(&format!("- **Location**: {loc}"));
221 if let Some(name) = &f.location.name {
222 out.push_str(&format!(" (`{}`)", name));
223 }
224 out.push('\n');
225 out.push_str(&format!("- **Problem**: {}\n", f.message));
226 if !f.suggested_refactorings.is_empty() {
227 out.push_str("- **Suggested refactorings**:\n");
228 for r in &f.suggested_refactorings {
229 out.push_str(&format!(" - {}\n", r));
230 }
231 }
232 out.push('\n');
233}
234
235pub struct SarifReporter;
237
238impl Reporter for SarifReporter {
239 fn render(&self, findings: &[Finding]) -> String {
240 self.render_with_scores(findings, &[])
241 }
242}
243
244impl SarifReporter {
245 pub fn render_with_scores(
247 &self,
248 findings: &[Finding],
249 scores: &[crate::health::HealthScore],
250 ) -> String {
251 let rules = build_sarif_rules(findings);
252 let results = build_sarif_results(findings);
253 let mut run = serde_json::json!({
254 "tool": {
255 "driver": {
256 "name": "cha",
257 "version": env!("CARGO_PKG_VERSION"),
258 "rules": rules,
259 }
260 },
261 "results": results,
262 });
263 if !scores.is_empty() {
264 run["properties"] = serde_json::json!({
265 "health_scores": scores.iter().map(|s| serde_json::json!({
266 "path": s.path,
267 "grade": s.grade.to_string(),
268 "debt_minutes": s.debt_minutes,
269 "lines": s.lines,
270 })).collect::<Vec<_>>(),
271 });
272 }
273 let sarif = serde_json::json!({
274 "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
275 "version": "2.1.0",
276 "runs": [run],
277 });
278 serde_json::to_string_pretty(&sarif).unwrap_or_default()
279 }
280}
281
282fn build_sarif_rules(findings: &[Finding]) -> Vec<serde_json::Value> {
284 findings
285 .iter()
286 .map(|f| &f.smell_name)
287 .collect::<std::collections::BTreeSet<_>>()
288 .into_iter()
289 .map(|name| {
290 serde_json::json!({
291 "id": name,
292 "shortDescription": { "text": name },
293 })
294 })
295 .collect()
296}
297
298fn build_sarif_results(findings: &[Finding]) -> Vec<serde_json::Value> {
300 findings
301 .iter()
302 .map(|f| {
303 serde_json::json!({
304 "ruleId": f.smell_name,
305 "level": match f.severity {
306 crate::Severity::Error => "error",
307 crate::Severity::Warning => "warning",
308 crate::Severity::Hint => "note",
309 },
310 "message": { "text": f.message },
311 "locations": [{
312 "physicalLocation": {
313 "artifactLocation": {
314 "uri": f.location.path.to_string_lossy(),
315 },
316 "region": {
317 "startLine": f.location.start_line,
318 "startColumn": f.location.start_col + 1,
319 "endLine": f.location.end_line,
320 "endColumn": f.location.end_col + 1,
321 }
322 }
323 }]
324 })
325 })
326 .collect()
327}