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