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