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
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
66/// JSON output.
67pub 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    /// Render findings with health scores.
77    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
95/// Structured context for LLM-assisted refactoring.
96pub 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
112/// Render a single finding as an LLM-readable markdown section.
113fn 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
138/// SARIF output for GitHub Code Scanning.
139pub 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    /// Render SARIF with optional health score properties.
149    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
185/// Collect unique rule descriptors from findings.
186fn 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
201/// Convert findings into SARIF result entries.
202fn 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}