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 out.push_str(&format!(
61 " → {}:{} {}\n",
62 f.location.path.display(),
63 f.location.start_line,
64 f.message,
65 ));
66 }
67 if group.len() > 3 {
68 out.push_str(&format!(
69 " … and {} more (use --all to show all)\n",
70 group.len() - 3
71 ));
72 }
73}
74
75fn render_summary(out: &mut String, findings: &[Finding]) {
76 let errors = findings
77 .iter()
78 .filter(|f| f.severity == crate::Severity::Error)
79 .count();
80 let warnings = findings
81 .iter()
82 .filter(|f| f.severity == crate::Severity::Warning)
83 .count();
84 let hints = findings.len() - errors - warnings;
85 out.push_str(&format!(
86 "\n{} issue(s) found ({} error, {} warning, {} hint).",
87 findings.len(),
88 errors,
89 warnings,
90 hints
91 ));
92}
93
94fn severity_icon(s: &crate::Severity) -> &'static str {
95 match s {
96 crate::Severity::Error => "✗",
97 crate::Severity::Warning => "⚠",
98 crate::Severity::Hint => "ℹ",
99 }
100}
101
102fn render_terminal_finding(out: &mut String, f: &Finding) {
103 out.push_str(&format!(
104 "{} [{}] {}:{}-{} {}\n",
105 severity_icon(&f.severity),
106 f.smell_name,
107 f.location.path.display(),
108 f.location.start_line,
109 f.location.end_line,
110 f.message,
111 ));
112 if !f.suggested_refactorings.is_empty() {
113 out.push_str(&format!(
114 " → suggested: {}\n",
115 f.suggested_refactorings.join(", ")
116 ));
117 }
118}
119
120pub struct JsonReporter;
122
123impl Reporter for JsonReporter {
124 fn render(&self, findings: &[Finding]) -> String {
125 serde_json::to_string_pretty(findings).unwrap_or_default()
126 }
127}
128
129impl JsonReporter {
130 pub fn render_with_scores(
132 &self,
133 findings: &[Finding],
134 scores: &[crate::health::HealthScore],
135 ) -> String {
136 let report = serde_json::json!({
137 "findings": findings,
138 "health_scores": scores.iter().map(|s| serde_json::json!({
139 "path": s.path,
140 "grade": s.grade.to_string(),
141 "debt_minutes": s.debt_minutes,
142 "lines": s.lines,
143 })).collect::<Vec<_>>(),
144 });
145 serde_json::to_string_pretty(&report).unwrap_or_default()
146 }
147}
148
149pub struct LlmContextReporter;
151
152impl Reporter for LlmContextReporter {
153 fn render(&self, findings: &[Finding]) -> String {
154 if findings.is_empty() {
155 return "No code smells detected.".into();
156 }
157 let mut out = String::from("# Code Smell Analysis\n\n");
158 for (i, f) in findings.iter().enumerate() {
159 render_llm_issue(&mut out, i, f);
160 }
161 out.push_str("Please apply the suggested refactorings to improve code quality.\n");
162 out
163 }
164}
165
166fn render_llm_issue(out: &mut String, index: usize, f: &Finding) {
168 out.push_str(&format!("## Issue {}\n\n", index + 1));
169 out.push_str(&format!("- **Smell**: {}\n", f.smell_name));
170 out.push_str(&format!("- **Category**: {:?}\n", f.category));
171 out.push_str(&format!("- **Severity**: {:?}\n", f.severity));
172 out.push_str(&format!(
173 "- **Location**: {}:{}-{}",
174 f.location.path.display(),
175 f.location.start_line,
176 f.location.end_line,
177 ));
178 if let Some(name) = &f.location.name {
179 out.push_str(&format!(" (`{}`)", name));
180 }
181 out.push('\n');
182 out.push_str(&format!("- **Problem**: {}\n", f.message));
183 if !f.suggested_refactorings.is_empty() {
184 out.push_str("- **Suggested refactorings**:\n");
185 for r in &f.suggested_refactorings {
186 out.push_str(&format!(" - {}\n", r));
187 }
188 }
189 out.push('\n');
190}
191
192pub struct SarifReporter;
194
195impl Reporter for SarifReporter {
196 fn render(&self, findings: &[Finding]) -> String {
197 self.render_with_scores(findings, &[])
198 }
199}
200
201impl SarifReporter {
202 pub fn render_with_scores(
204 &self,
205 findings: &[Finding],
206 scores: &[crate::health::HealthScore],
207 ) -> String {
208 let rules = build_sarif_rules(findings);
209 let results = build_sarif_results(findings);
210 let mut run = serde_json::json!({
211 "tool": {
212 "driver": {
213 "name": "cha",
214 "version": env!("CARGO_PKG_VERSION"),
215 "rules": rules,
216 }
217 },
218 "results": results,
219 });
220 if !scores.is_empty() {
221 run["properties"] = serde_json::json!({
222 "health_scores": scores.iter().map(|s| serde_json::json!({
223 "path": s.path,
224 "grade": s.grade.to_string(),
225 "debt_minutes": s.debt_minutes,
226 "lines": s.lines,
227 })).collect::<Vec<_>>(),
228 });
229 }
230 let sarif = serde_json::json!({
231 "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
232 "version": "2.1.0",
233 "runs": [run],
234 });
235 serde_json::to_string_pretty(&sarif).unwrap_or_default()
236 }
237}
238
239fn build_sarif_rules(findings: &[Finding]) -> Vec<serde_json::Value> {
241 findings
242 .iter()
243 .map(|f| &f.smell_name)
244 .collect::<std::collections::BTreeSet<_>>()
245 .into_iter()
246 .map(|name| {
247 serde_json::json!({
248 "id": name,
249 "shortDescription": { "text": name },
250 })
251 })
252 .collect()
253}
254
255fn build_sarif_results(findings: &[Finding]) -> Vec<serde_json::Value> {
257 findings
258 .iter()
259 .map(|f| {
260 serde_json::json!({
261 "ruleId": f.smell_name,
262 "level": match f.severity {
263 crate::Severity::Error => "error",
264 crate::Severity::Warning => "warning",
265 crate::Severity::Hint => "note",
266 },
267 "message": { "text": f.message },
268 "locations": [{
269 "physicalLocation": {
270 "artifactLocation": {
271 "uri": f.location.path.to_string_lossy(),
272 },
273 "region": {
274 "startLine": f.location.start_line,
275 "endLine": f.location.end_line,
276 }
277 }
278 }]
279 })
280 })
281 .collect()
282}