Skip to main content

codelens_core/output/
html.rs

1//! HTML output format with interactive charts.
2
3use std::io::Write;
4
5use askama::Template;
6
7use crate::analyzer::stats::{AnalysisResult, LanguageSummary, Summary};
8use crate::error::Result;
9use crate::insight::Grade;
10
11use super::format::{OutputFormat, OutputOptions, Report};
12
13// ── Analysis (existing) ──────────────────────────────────
14
15#[derive(Template)]
16#[template(path = "report.html")]
17struct AnalysisHtmlReport<'a> {
18    title: &'a str,
19    generated_at: String,
20    summary: &'a Summary,
21    by_language: Vec<(&'a str, &'a LanguageSummary)>,
22    elapsed_secs: f64,
23}
24
25// ── Health ───────────────────────────────────────────────
26
27struct HtmlDimensionScore {
28    dimension: String,
29    score_display: u32,
30    grade: Grade,
31}
32
33struct HtmlDirectoryHealth {
34    path: String,
35    score_display: u32,
36    grade: Grade,
37    file_count: usize,
38}
39
40struct HtmlFileHealth {
41    path: String,
42    score_display: u32,
43    grade: Grade,
44    top_issue: String,
45}
46
47#[derive(Template)]
48#[template(path = "health.html")]
49struct HealthHtmlReport {
50    generated_at: String,
51    model: String,
52    grade: String,
53    score: u32,
54    dimensions: Vec<HtmlDimensionScore>,
55    by_directory: Vec<HtmlDirectoryHealth>,
56    worst_files: Vec<HtmlFileHealth>,
57}
58
59// ── Hotspot ──────────────────────────────────────────────
60
61struct HtmlFileHotspot {
62    path: String,
63    language: String,
64    commits: usize,
65    lines_added: usize,
66    lines_deleted: usize,
67    cyclomatic: usize,
68    score_display: String,
69    score_pct: u32,
70    risk: String,
71}
72
73#[derive(Template)]
74#[template(path = "hotspot.html")]
75struct HotspotHtmlReport {
76    generated_at: String,
77    since: String,
78    total_commits: usize,
79    files: Vec<HtmlFileHotspot>,
80}
81
82// ── Trend ────────────────────────────────────────────────
83
84struct HtmlTrendMetric {
85    label: String,
86    from_value: usize,
87    to_value: usize,
88    signed_delta: i64,
89    delta_display: String,
90    percent_display: String,
91}
92
93struct HtmlLanguageTrend {
94    language: String,
95    status: String,
96    code_from: usize,
97    code_to: usize,
98    code_delta: i64,
99    code_delta_display: String,
100}
101
102#[derive(Template)]
103#[template(path = "trend.html")]
104struct TrendHtmlReport {
105    from_date: String,
106    from_label: String,
107    to_date: String,
108    to_label: String,
109    metrics: Vec<HtmlTrendMetric>,
110    by_language: Vec<HtmlLanguageTrend>,
111}
112
113// ── OutputFormat impl ────────────────────────────────────
114
115pub struct HtmlOutput;
116
117impl HtmlOutput {
118    pub fn new() -> Self {
119        Self
120    }
121}
122
123impl Default for HtmlOutput {
124    fn default() -> Self {
125        Self::new()
126    }
127}
128
129impl OutputFormat for HtmlOutput {
130    fn name(&self) -> &'static str {
131        "html"
132    }
133
134    fn extension(&self) -> &'static str {
135        "html"
136    }
137
138    fn write(
139        &self,
140        report: &Report,
141        options: &OutputOptions,
142        writer: &mut dyn Write,
143    ) -> Result<()> {
144        match report {
145            Report::Analysis(result) => self.write_analysis(result, options, writer),
146            Report::Health(report) => self.write_health(report, writer),
147            Report::Hotspot(report) => self.write_hotspot(report, writer),
148            Report::Trend(report) => self.write_trend(report, writer),
149        }
150    }
151}
152
153impl HtmlOutput {
154    fn write_analysis(
155        &self,
156        result: &AnalysisResult,
157        options: &OutputOptions,
158        writer: &mut dyn Write,
159    ) -> Result<()> {
160        let mut by_language: Vec<_> = result
161            .summary
162            .by_language
163            .iter()
164            .map(|(k, v)| (k.as_str(), v))
165            .collect();
166
167        if let Some(n) = options.top_n {
168            by_language.truncate(n);
169        }
170
171        let report = AnalysisHtmlReport {
172            title: "Codelens - Code Statistics Report",
173            generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
174            summary: &result.summary,
175            by_language,
176            elapsed_secs: result.elapsed.as_secs_f64(),
177        };
178
179        write!(writer, "{}", report.render()?)?;
180        Ok(())
181    }
182
183    fn write_health(
184        &self,
185        report: &crate::insight::health::HealthReport,
186        writer: &mut dyn Write,
187    ) -> Result<()> {
188        let html = HealthHtmlReport {
189            generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
190            model: report.model.clone(),
191            grade: report.grade.to_string(),
192            score: report.score as u32,
193            dimensions: report
194                .dimensions
195                .iter()
196                .map(|d| HtmlDimensionScore {
197                    dimension: d.dimension.to_string(),
198                    score_display: d.score as u32,
199                    grade: d.grade,
200                })
201                .collect(),
202            by_directory: report
203                .by_directory
204                .iter()
205                .map(|d| HtmlDirectoryHealth {
206                    path: d.path.display().to_string(),
207                    score_display: d.score as u32,
208                    grade: d.grade,
209                    file_count: d.file_count,
210                })
211                .collect(),
212            worst_files: report
213                .worst_files
214                .iter()
215                .map(|f| HtmlFileHealth {
216                    path: f.path.display().to_string(),
217                    score_display: f.score as u32,
218                    grade: f.grade,
219                    top_issue: f.top_issue.to_string(),
220                })
221                .collect(),
222        };
223        write!(writer, "{}", html.render()?)?;
224        Ok(())
225    }
226
227    fn write_hotspot(
228        &self,
229        report: &crate::insight::hotspot::HotspotReport,
230        writer: &mut dyn Write,
231    ) -> Result<()> {
232        let html = HotspotHtmlReport {
233            generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
234            since: report.since.clone(),
235            total_commits: report.total_commits,
236            files: report
237                .files
238                .iter()
239                .map(|h| HtmlFileHotspot {
240                    path: h.path.display().to_string(),
241                    language: h.language.clone(),
242                    commits: h.churn.commits,
243                    lines_added: h.churn.lines_added,
244                    lines_deleted: h.churn.lines_deleted,
245                    cyclomatic: h.complexity.cyclomatic,
246                    score_display: format!("{:.2}", h.hotspot_score),
247                    score_pct: (h.hotspot_score * 100.0) as u32,
248                    risk: h.risk.to_string(),
249                })
250                .collect(),
251        };
252        write!(writer, "{}", html.render()?)?;
253        Ok(())
254    }
255
256    fn write_trend(
257        &self,
258        report: &crate::insight::trend::TrendReport,
259        writer: &mut dyn Write,
260    ) -> Result<()> {
261        let d = &report.delta;
262        let make_metric =
263            |label: &str, dv: &crate::insight::DeltaValue<usize>| -> HtmlTrendMetric {
264                let signed = dv.signed_delta();
265                HtmlTrendMetric {
266                    label: label.to_string(),
267                    from_value: dv.from,
268                    to_value: dv.to,
269                    signed_delta: signed,
270                    delta_display: if signed >= 0 {
271                        format!("+{signed}")
272                    } else {
273                        format!("{signed}")
274                    },
275                    percent_display: format!("{:+.1}%", dv.percent),
276                }
277            };
278
279        let metrics = vec![
280            make_metric("Files", &d.files),
281            make_metric("Code", &d.code),
282            make_metric("Comments", &d.comment),
283            make_metric("Blank", &d.blank),
284            make_metric("Complexity", &d.complexity),
285            make_metric("Functions", &d.functions),
286        ];
287
288        let by_language: Vec<HtmlLanguageTrend> = report
289            .by_language
290            .iter()
291            .map(|lt| {
292                let signed = lt.code.signed_delta();
293                HtmlLanguageTrend {
294                    language: lt.language.clone(),
295                    status: lt.status.to_string(),
296                    code_from: lt.code.from,
297                    code_to: lt.code.to,
298                    code_delta: signed,
299                    code_delta_display: if signed >= 0 {
300                        format!("+{signed}")
301                    } else {
302                        format!("{signed}")
303                    },
304                }
305            })
306            .collect();
307
308        let html = TrendHtmlReport {
309            from_date: report.from.timestamp.format("%Y-%m-%d").to_string(),
310            from_label: report.from.label.as_deref().unwrap_or("").to_string(),
311            to_date: report.to.timestamp.format("%Y-%m-%d").to_string(),
312            to_label: report.to.label.as_deref().unwrap_or("").to_string(),
313            metrics,
314            by_language,
315        };
316        write!(writer, "{}", html.render()?)?;
317        Ok(())
318    }
319}