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        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
139/// JSON output.
140pub 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    /// Render findings with health scores.
150    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
168/// Structured context for LLM-assisted refactoring.
169pub 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
185/// Render a single finding as an LLM-readable markdown section.
186fn 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
223/// SARIF output for GitHub Code Scanning.
224pub 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    /// Render SARIF with optional health score properties.
234    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
270/// Collect unique rule descriptors from findings.
271fn 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
286/// Convert findings into SARIF result entries.
287fn 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}