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// ── Estimation ──────────────────────────────────────────
114
115struct HtmlLanguageEstimation {
116    language: String,
117    code_lines: usize,
118    effort_months: String,
119    cost: String,
120    /// Raw f64 values for chart.js data attributes.
121    cost_raw: f64,
122    effort_raw: f64,
123}
124
125struct HtmlEstimationParam {
126    key: String,
127    value: String,
128}
129
130#[derive(Template)]
131#[template(path = "estimation.html")]
132struct EstimationHtmlReport {
133    generated_at: String,
134    model: String,
135    total_sloc: usize,
136    estimated_cost: String,
137    schedule_months: String,
138    people_required: String,
139    by_language: Vec<HtmlLanguageEstimation>,
140    params: Vec<HtmlEstimationParam>,
141}
142
143// ── Estimation Comparison ────────────────────────────────
144
145struct HtmlComparisonRow {
146    model: String,
147    effort_months: String,
148    schedule_months: String,
149    people_required: String,
150    estimated_cost: String,
151    cost_raw: f64,
152}
153
154#[derive(Template)]
155#[template(path = "estimation_comparison.html")]
156struct EstimationComparisonHtmlReport {
157    generated_at: String,
158    total_sloc: usize,
159    rows: Vec<HtmlComparisonRow>,
160}
161
162// ── OutputFormat impl ────────────────────────────────────
163
164pub struct HtmlOutput;
165
166impl HtmlOutput {
167    pub fn new() -> Self {
168        Self
169    }
170}
171
172impl Default for HtmlOutput {
173    fn default() -> Self {
174        Self::new()
175    }
176}
177
178impl OutputFormat for HtmlOutput {
179    fn name(&self) -> &'static str {
180        "html"
181    }
182
183    fn extension(&self) -> &'static str {
184        "html"
185    }
186
187    fn write(
188        &self,
189        report: &Report,
190        options: &OutputOptions,
191        writer: &mut dyn Write,
192    ) -> Result<()> {
193        match report {
194            Report::Analysis(result) => self.write_analysis(result, options, writer),
195            Report::Health(report) => self.write_health(report, writer),
196            Report::Hotspot(report) => self.write_hotspot(report, writer),
197            Report::Trend(report) => self.write_trend(report, writer),
198            Report::Estimation(report) => self.write_estimation(report, writer),
199            Report::EstimationComparison(report) => {
200                self.write_estimation_comparison(report, writer)
201            }
202        }
203    }
204}
205
206impl HtmlOutput {
207    fn write_analysis(
208        &self,
209        result: &AnalysisResult,
210        options: &OutputOptions,
211        writer: &mut dyn Write,
212    ) -> Result<()> {
213        let mut by_language: Vec<_> = result
214            .summary
215            .by_language
216            .iter()
217            .map(|(k, v)| (k.as_str(), v))
218            .collect();
219
220        if let Some(n) = options.top_n {
221            by_language.truncate(n);
222        }
223
224        let report = AnalysisHtmlReport {
225            title: "Codelens - Code Statistics Report",
226            generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
227            summary: &result.summary,
228            by_language,
229            elapsed_secs: result.elapsed.as_secs_f64(),
230        };
231
232        write!(writer, "{}", report.render()?)?;
233        Ok(())
234    }
235
236    fn write_health(
237        &self,
238        report: &crate::insight::health::HealthReport,
239        writer: &mut dyn Write,
240    ) -> Result<()> {
241        let html = HealthHtmlReport {
242            generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
243            model: report.model.clone(),
244            grade: report.grade.to_string(),
245            score: report.score as u32,
246            dimensions: report
247                .dimensions
248                .iter()
249                .map(|d| HtmlDimensionScore {
250                    dimension: d.dimension.to_string(),
251                    score_display: d.score as u32,
252                    grade: d.grade,
253                })
254                .collect(),
255            by_directory: report
256                .by_directory
257                .iter()
258                .map(|d| HtmlDirectoryHealth {
259                    path: d.path.display().to_string(),
260                    score_display: d.score as u32,
261                    grade: d.grade,
262                    file_count: d.file_count,
263                })
264                .collect(),
265            worst_files: report
266                .worst_files
267                .iter()
268                .map(|f| HtmlFileHealth {
269                    path: f.path.display().to_string(),
270                    score_display: f.score as u32,
271                    grade: f.grade,
272                    top_issue: f.top_issue.to_string(),
273                })
274                .collect(),
275        };
276        write!(writer, "{}", html.render()?)?;
277        Ok(())
278    }
279
280    fn write_hotspot(
281        &self,
282        report: &crate::insight::hotspot::HotspotReport,
283        writer: &mut dyn Write,
284    ) -> Result<()> {
285        let html = HotspotHtmlReport {
286            generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
287            since: report.since.clone(),
288            total_commits: report.total_commits,
289            files: report
290                .files
291                .iter()
292                .map(|h| HtmlFileHotspot {
293                    path: h.path.display().to_string(),
294                    language: h.language.clone(),
295                    commits: h.churn.commits,
296                    lines_added: h.churn.lines_added,
297                    lines_deleted: h.churn.lines_deleted,
298                    cyclomatic: h.complexity.cyclomatic,
299                    score_display: format!("{:.2}", h.hotspot_score),
300                    score_pct: (h.hotspot_score * 100.0) as u32,
301                    risk: h.risk.to_string(),
302                })
303                .collect(),
304        };
305        write!(writer, "{}", html.render()?)?;
306        Ok(())
307    }
308
309    fn write_trend(
310        &self,
311        report: &crate::insight::trend::TrendReport,
312        writer: &mut dyn Write,
313    ) -> Result<()> {
314        let d = &report.delta;
315        let make_metric =
316            |label: &str, dv: &crate::insight::DeltaValue<usize>| -> HtmlTrendMetric {
317                let signed = dv.signed_delta();
318                HtmlTrendMetric {
319                    label: label.to_string(),
320                    from_value: dv.from,
321                    to_value: dv.to,
322                    signed_delta: signed,
323                    delta_display: if signed >= 0 {
324                        format!("+{signed}")
325                    } else {
326                        format!("{signed}")
327                    },
328                    percent_display: format!("{:+.1}%", dv.percent),
329                }
330            };
331
332        let metrics = vec![
333            make_metric("Files", &d.files),
334            make_metric("Code", &d.code),
335            make_metric("Comments", &d.comment),
336            make_metric("Blank", &d.blank),
337            make_metric("Complexity", &d.complexity),
338            make_metric("Functions", &d.functions),
339        ];
340
341        let by_language: Vec<HtmlLanguageTrend> = report
342            .by_language
343            .iter()
344            .map(|lt| {
345                let signed = lt.code.signed_delta();
346                HtmlLanguageTrend {
347                    language: lt.language.clone(),
348                    status: lt.status.to_string(),
349                    code_from: lt.code.from,
350                    code_to: lt.code.to,
351                    code_delta: signed,
352                    code_delta_display: if signed >= 0 {
353                        format!("+{signed}")
354                    } else {
355                        format!("{signed}")
356                    },
357                }
358            })
359            .collect();
360
361        let html = TrendHtmlReport {
362            from_date: report.from.timestamp.format("%Y-%m-%d").to_string(),
363            from_label: report.from.label.as_deref().unwrap_or("").to_string(),
364            to_date: report.to.timestamp.format("%Y-%m-%d").to_string(),
365            to_label: report.to.label.as_deref().unwrap_or("").to_string(),
366            metrics,
367            by_language,
368        };
369        write!(writer, "{}", html.render()?)?;
370        Ok(())
371    }
372
373    fn write_estimation(
374        &self,
375        report: &crate::insight::estimation::EstimationReport,
376        writer: &mut dyn Write,
377    ) -> Result<()> {
378        let html = EstimationHtmlReport {
379            generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
380            model: report.model.clone(),
381            total_sloc: report.total_sloc,
382            estimated_cost: format!("{:.2}", report.estimated_cost),
383            schedule_months: format!("{:.2}", report.schedule_months),
384            people_required: format!("{:.2}", report.people_required),
385            by_language: report
386                .by_language
387                .iter()
388                .map(|l| HtmlLanguageEstimation {
389                    language: l.language.clone(),
390                    code_lines: l.code_lines,
391                    effort_months: format!("{:.2}", l.effort_months),
392                    cost: format!("{:.2}", l.cost),
393                    cost_raw: l.cost,
394                    effort_raw: l.effort_months,
395                })
396                .collect(),
397            params: report
398                .params
399                .iter()
400                .map(|(k, v)| HtmlEstimationParam {
401                    key: k.clone(),
402                    value: v.clone(),
403                })
404                .collect(),
405        };
406        write!(writer, "{}", html.render()?)?;
407        Ok(())
408    }
409
410    fn write_estimation_comparison(
411        &self,
412        report: &crate::insight::estimation::EstimationComparison,
413        writer: &mut dyn Write,
414    ) -> Result<()> {
415        let html = EstimationComparisonHtmlReport {
416            generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
417            total_sloc: report.total_sloc,
418            rows: report
419                .reports
420                .iter()
421                .map(|r| HtmlComparisonRow {
422                    model: r.model.clone(),
423                    effort_months: format!("{:.2}", r.effort_months),
424                    schedule_months: format!("{:.2}", r.schedule_months),
425                    people_required: format!("{:.2}", r.people_required),
426                    estimated_cost: format!("{:.2}", r.estimated_cost),
427                    cost_raw: r.estimated_cost,
428                })
429                .collect(),
430        };
431        write!(writer, "{}", html.render()?)?;
432        Ok(())
433    }
434}