Skip to main content

cha_core/
reporter.rs

1use crate::Finding;
2
3/// Output format for analysis results.
4pub trait Reporter {
5    fn render(&self, findings: &[Finding]) -> String;
6}
7
8/// Colored terminal output.
9pub struct TerminalReporter {
10    /// If true, show all findings without aggregation.
11    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
120/// JSON output.
121pub 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    /// Render findings with health scores.
131    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
149/// Structured context for LLM-assisted refactoring.
150pub 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
166/// Render a single finding as an LLM-readable markdown section.
167fn 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
192/// SARIF output for GitHub Code Scanning.
193pub 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    /// Render SARIF with optional health score properties.
203    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
239/// Collect unique rule descriptors from findings.
240fn 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
255/// Convert findings into SARIF result entries.
256fn 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}