1use crate::Finding;
2
3pub trait Reporter {
5 fn render(&self, findings: &[Finding]) -> String;
6}
7
8pub struct TerminalReporter;
10
11impl Reporter for TerminalReporter {
12 fn render(&self, findings: &[Finding]) -> String {
13 if findings.is_empty() {
14 return "No issues found.".into();
15 }
16 let mut out = String::new();
17 for f in findings {
18 render_terminal_finding(&mut out, f);
19 }
20 let errors = findings
21 .iter()
22 .filter(|f| f.severity == crate::Severity::Error)
23 .count();
24 let warnings = findings
25 .iter()
26 .filter(|f| f.severity == crate::Severity::Warning)
27 .count();
28 let hints = findings.len() - errors - warnings;
29 out.push_str(&format!(
30 "\n{} issue(s) found ({} error, {} warning, {} hint).",
31 findings.len(),
32 errors,
33 warnings,
34 hints
35 ));
36 out
37 }
38}
39
40fn severity_icon(s: &crate::Severity) -> &'static str {
41 match s {
42 crate::Severity::Error => "✗",
43 crate::Severity::Warning => "⚠",
44 crate::Severity::Hint => "ℹ",
45 }
46}
47
48fn render_terminal_finding(out: &mut String, f: &Finding) {
49 out.push_str(&format!(
50 "{} [{}] {}:{}-{} {}\n",
51 severity_icon(&f.severity),
52 f.smell_name,
53 f.location.path.display(),
54 f.location.start_line,
55 f.location.end_line,
56 f.message,
57 ));
58 if !f.suggested_refactorings.is_empty() {
59 out.push_str(&format!(
60 " → suggested: {}\n",
61 f.suggested_refactorings.join(", ")
62 ));
63 }
64}
65
66pub struct JsonReporter;
68
69impl Reporter for JsonReporter {
70 fn render(&self, findings: &[Finding]) -> String {
71 serde_json::to_string_pretty(findings).unwrap_or_default()
72 }
73}
74
75impl JsonReporter {
76 pub fn render_with_scores(
78 &self,
79 findings: &[Finding],
80 scores: &[crate::health::HealthScore],
81 ) -> String {
82 let report = serde_json::json!({
83 "findings": findings,
84 "health_scores": scores.iter().map(|s| serde_json::json!({
85 "path": s.path,
86 "grade": s.grade.to_string(),
87 "debt_minutes": s.debt_minutes,
88 "lines": s.lines,
89 })).collect::<Vec<_>>(),
90 });
91 serde_json::to_string_pretty(&report).unwrap_or_default()
92 }
93}
94
95pub struct LlmContextReporter;
97
98impl Reporter for LlmContextReporter {
99 fn render(&self, findings: &[Finding]) -> String {
100 if findings.is_empty() {
101 return "No code smells detected.".into();
102 }
103 let mut out = String::from("# Code Smell Analysis\n\n");
104 for (i, f) in findings.iter().enumerate() {
105 render_llm_issue(&mut out, i, f);
106 }
107 out.push_str("Please apply the suggested refactorings to improve code quality.\n");
108 out
109 }
110}
111
112fn render_llm_issue(out: &mut String, index: usize, f: &Finding) {
114 out.push_str(&format!("## Issue {}\n\n", index + 1));
115 out.push_str(&format!("- **Smell**: {}\n", f.smell_name));
116 out.push_str(&format!("- **Category**: {:?}\n", f.category));
117 out.push_str(&format!("- **Severity**: {:?}\n", f.severity));
118 out.push_str(&format!(
119 "- **Location**: {}:{}-{}",
120 f.location.path.display(),
121 f.location.start_line,
122 f.location.end_line,
123 ));
124 if let Some(name) = &f.location.name {
125 out.push_str(&format!(" (`{}`)", name));
126 }
127 out.push('\n');
128 out.push_str(&format!("- **Problem**: {}\n", f.message));
129 if !f.suggested_refactorings.is_empty() {
130 out.push_str("- **Suggested refactorings**:\n");
131 for r in &f.suggested_refactorings {
132 out.push_str(&format!(" - {}\n", r));
133 }
134 }
135 out.push('\n');
136}
137
138pub struct SarifReporter;
140
141impl Reporter for SarifReporter {
142 fn render(&self, findings: &[Finding]) -> String {
143 self.render_with_scores(findings, &[])
144 }
145}
146
147impl SarifReporter {
148 pub fn render_with_scores(
150 &self,
151 findings: &[Finding],
152 scores: &[crate::health::HealthScore],
153 ) -> String {
154 let rules = build_sarif_rules(findings);
155 let results = build_sarif_results(findings);
156 let mut run = serde_json::json!({
157 "tool": {
158 "driver": {
159 "name": "cha",
160 "version": env!("CARGO_PKG_VERSION"),
161 "rules": rules,
162 }
163 },
164 "results": results,
165 });
166 if !scores.is_empty() {
167 run["properties"] = serde_json::json!({
168 "health_scores": scores.iter().map(|s| serde_json::json!({
169 "path": s.path,
170 "grade": s.grade.to_string(),
171 "debt_minutes": s.debt_minutes,
172 "lines": s.lines,
173 })).collect::<Vec<_>>(),
174 });
175 }
176 let sarif = serde_json::json!({
177 "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
178 "version": "2.1.0",
179 "runs": [run],
180 });
181 serde_json::to_string_pretty(&sarif).unwrap_or_default()
182 }
183}
184
185fn build_sarif_rules(findings: &[Finding]) -> Vec<serde_json::Value> {
187 findings
188 .iter()
189 .map(|f| &f.smell_name)
190 .collect::<std::collections::BTreeSet<_>>()
191 .into_iter()
192 .map(|name| {
193 serde_json::json!({
194 "id": name,
195 "shortDescription": { "text": name },
196 })
197 })
198 .collect()
199}
200
201fn build_sarif_results(findings: &[Finding]) -> Vec<serde_json::Value> {
203 findings
204 .iter()
205 .map(|f| {
206 serde_json::json!({
207 "ruleId": f.smell_name,
208 "level": match f.severity {
209 crate::Severity::Error => "error",
210 crate::Severity::Warning => "warning",
211 crate::Severity::Hint => "note",
212 },
213 "message": { "text": f.message },
214 "locations": [{
215 "physicalLocation": {
216 "artifactLocation": {
217 "uri": f.location.path.to_string_lossy(),
218 },
219 "region": {
220 "startLine": f.location.start_line,
221 "endLine": f.location.end_line,
222 }
223 }
224 }]
225 })
226 })
227 .collect()
228}