Skip to main content

tokmd_analysis_html/
lib.rs

1//! # tokmd-analysis-html
2//!
3//! **Tier 3 (Formatting Adapter)**
4//!
5//! Single-responsibility HTML renderer for `AnalysisReceipt`.
6
7use time::OffsetDateTime;
8use time::macros::format_description;
9use tokmd_analysis_types::AnalysisReceipt;
10
11/// Render a self-contained HTML report for an analysis receipt.
12pub fn render(receipt: &AnalysisReceipt) -> String {
13    const TEMPLATE: &str = include_str!("templates/report.html");
14
15    let timestamp = timestamp_utc();
16    let metrics_cards = build_metrics_cards(receipt);
17    let table_rows = build_table_rows(receipt);
18    let report_json = build_report_json(receipt);
19
20    TEMPLATE
21        .replace("{{TIMESTAMP}}", &timestamp)
22        .replace("{{METRICS_CARDS}}", &metrics_cards)
23        .replace("{{TABLE_ROWS}}", &table_rows)
24        .replace("{{REPORT_JSON}}", &report_json)
25}
26
27fn timestamp_utc() -> String {
28    let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second] UTC");
29    OffsetDateTime::now_utc()
30        .format(&format)
31        .unwrap_or_else(|_| "1970-01-01 00:00:00 UTC".to_string())
32}
33
34fn build_metrics_cards(receipt: &AnalysisReceipt) -> String {
35    let mut cards = String::new();
36
37    if let Some(derived) = &receipt.derived {
38        let metrics = [
39            ("Files", derived.totals.files.to_string()),
40            ("Lines", format_number(derived.totals.lines)),
41            ("Code", format_number(derived.totals.code)),
42            ("Tokens", format_number(derived.totals.tokens)),
43            ("Doc%", format_pct(derived.doc_density.total.ratio)),
44        ];
45
46        for (label, value) in metrics {
47            cards.push_str(&format!(
48                r#"<div class="metric-card"><span class="value">{}</span><span class="label">{}</span></div>"#,
49                value, label
50            ));
51        }
52
53        if let Some(ctx) = &derived.context_window {
54            cards.push_str(&format!(
55                r#"<div class="metric-card"><span class="value">{}</span><span class="label">Context Fit</span></div>"#,
56                format_pct(ctx.pct)
57            ));
58        }
59    }
60
61    cards
62}
63
64fn build_table_rows(receipt: &AnalysisReceipt) -> String {
65    let mut rows = String::new();
66
67    if let Some(derived) = &receipt.derived {
68        for row in derived.top.largest_lines.iter().take(100) {
69            rows.push_str(&format!(
70                r#"<tr><td class="path" data-path="{path}">{path}</td><td data-module="{module}">{module}</td><td data-lang="{lang}"><span class="lang-badge">{lang}</span></td><td class="num" data-lines="{lines}">{lines_fmt}</td><td class="num" data-code="{code}">{code_fmt}</td><td class="num" data-tokens="{tokens}">{tokens_fmt}</td><td class="num" data-bytes="{bytes}">{bytes_fmt}</td></tr>"#,
71                path = escape_html(&row.path),
72                module = escape_html(&row.module),
73                lang = escape_html(&row.lang),
74                lines = row.lines,
75                lines_fmt = format_number(row.lines),
76                code = row.code,
77                code_fmt = format_number(row.code),
78                tokens = row.tokens,
79                tokens_fmt = format_number(row.tokens),
80                bytes = row.bytes,
81                bytes_fmt = format_number(row.bytes),
82            ));
83        }
84    }
85
86    rows
87}
88
89fn build_report_json(receipt: &AnalysisReceipt) -> String {
90    let mut files = Vec::new();
91
92    if let Some(derived) = &receipt.derived {
93        for row in &derived.top.largest_lines {
94            files.push(serde_json::json!({
95                "path": row.path,
96                "module": row.module,
97                "lang": row.lang,
98                "code": row.code,
99                "lines": row.lines,
100                "tokens": row.tokens,
101            }));
102        }
103    }
104
105    // Escape < and > to prevent </script> breakout XSS attacks.
106    // JSON remains valid because \u003c and \u003e are valid JSON string escapes.
107    serde_json::json!({ "files": files })
108        .to_string()
109        .replace('<', "\\u003c")
110        .replace('>', "\\u003e")
111}
112
113fn format_number(n: usize) -> String {
114    if n >= 1_000_000 {
115        format!("{:.1}M", n as f64 / 1_000_000.0)
116    } else if n >= 1_000 {
117        format!("{:.1}K", n as f64 / 1_000.0)
118    } else {
119        n.to_string()
120    }
121}
122
123fn format_pct(ratio: f64) -> String {
124    format!("{:.1}%", ratio * 100.0)
125}
126
127fn escape_html(value: &str) -> String {
128    value
129        .replace('&', "&amp;")
130        .replace('<', "&lt;")
131        .replace('>', "&gt;")
132        .replace('"', "&quot;")
133        .replace('\'', "&#x27;")
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use tokmd_analysis_types::*;
140
141    fn minimal_receipt() -> AnalysisReceipt {
142        AnalysisReceipt {
143            schema_version: 2,
144            generated_at_ms: 0,
145            tool: tokmd_types::ToolInfo {
146                name: "tokmd".to_string(),
147                version: "0.0.0".to_string(),
148            },
149            mode: "analysis".to_string(),
150            status: tokmd_types::ScanStatus::Complete,
151            warnings: vec![],
152            source: AnalysisSource {
153                inputs: vec!["test".to_string()],
154                export_path: None,
155                base_receipt_path: None,
156                export_schema_version: None,
157                export_generated_at_ms: None,
158                base_signature: None,
159                module_roots: vec![],
160                module_depth: 1,
161                children: "collapse".to_string(),
162            },
163            args: AnalysisArgsMeta {
164                preset: "receipt".to_string(),
165                format: "html".to_string(),
166                window_tokens: None,
167                git: None,
168                max_files: None,
169                max_bytes: None,
170                max_commits: None,
171                max_commit_files: None,
172                max_file_bytes: None,
173                import_granularity: "module".to_string(),
174            },
175            archetype: None,
176            topics: None,
177            entropy: None,
178            predictive_churn: None,
179            corporate_fingerprint: None,
180            license: None,
181            derived: None,
182            assets: None,
183            deps: None,
184            git: None,
185            imports: None,
186            dup: None,
187            complexity: None,
188            api_surface: None,
189            fun: None,
190        }
191    }
192
193    fn sample_derived() -> DerivedReport {
194        DerivedReport {
195            totals: DerivedTotals {
196                files: 10,
197                code: 1000,
198                comments: 200,
199                blanks: 100,
200                lines: 1300,
201                bytes: 50000,
202                tokens: 2500,
203            },
204            doc_density: RatioReport {
205                total: RatioRow {
206                    key: "total".to_string(),
207                    numerator: 200,
208                    denominator: 1200,
209                    ratio: 0.1667,
210                },
211                by_lang: vec![],
212                by_module: vec![],
213            },
214            whitespace: RatioReport {
215                total: RatioRow {
216                    key: "total".to_string(),
217                    numerator: 100,
218                    denominator: 1300,
219                    ratio: 0.0769,
220                },
221                by_lang: vec![],
222                by_module: vec![],
223            },
224            verbosity: RateReport {
225                total: RateRow {
226                    key: "total".to_string(),
227                    numerator: 50000,
228                    denominator: 1300,
229                    rate: 38.46,
230                },
231                by_lang: vec![],
232                by_module: vec![],
233            },
234            max_file: MaxFileReport {
235                overall: FileStatRow {
236                    path: "src/lib.rs".to_string(),
237                    module: "src".to_string(),
238                    lang: "Rust".to_string(),
239                    code: 500,
240                    comments: 100,
241                    blanks: 50,
242                    lines: 650,
243                    bytes: 25000,
244                    tokens: 1250,
245                    doc_pct: Some(0.167),
246                    bytes_per_line: Some(38.46),
247                    depth: 1,
248                },
249                by_lang: vec![],
250                by_module: vec![],
251            },
252            lang_purity: LangPurityReport { rows: vec![] },
253            nesting: NestingReport {
254                max: 3,
255                avg: 1.5,
256                by_module: vec![],
257            },
258            test_density: TestDensityReport {
259                test_lines: 200,
260                prod_lines: 1000,
261                test_files: 5,
262                prod_files: 5,
263                ratio: 0.2,
264            },
265            boilerplate: BoilerplateReport {
266                infra_lines: 100,
267                logic_lines: 1100,
268                ratio: 0.083,
269                infra_langs: vec!["TOML".to_string()],
270            },
271            polyglot: PolyglotReport {
272                lang_count: 2,
273                entropy: 0.5,
274                dominant_lang: "Rust".to_string(),
275                dominant_lines: 1000,
276                dominant_pct: 0.833,
277            },
278            distribution: DistributionReport {
279                count: 10,
280                min: 50,
281                max: 650,
282                mean: 130.0,
283                median: 100.0,
284                p90: 400.0,
285                p99: 650.0,
286                gini: 0.3,
287            },
288            histogram: vec![HistogramBucket {
289                label: "Small".to_string(),
290                min: 0,
291                max: Some(100),
292                files: 5,
293                pct: 0.5,
294            }],
295            top: TopOffenders {
296                largest_lines: vec![FileStatRow {
297                    path: "src/lib.rs".to_string(),
298                    module: "src".to_string(),
299                    lang: "Rust".to_string(),
300                    code: 500,
301                    comments: 100,
302                    blanks: 50,
303                    lines: 650,
304                    bytes: 25000,
305                    tokens: 1250,
306                    doc_pct: Some(0.167),
307                    bytes_per_line: Some(38.46),
308                    depth: 1,
309                }],
310                largest_tokens: vec![],
311                largest_bytes: vec![],
312                least_documented: vec![],
313                most_dense: vec![],
314            },
315            tree: Some("test-tree".to_string()),
316            reading_time: ReadingTimeReport {
317                minutes: 65.0,
318                lines_per_minute: 20,
319                basis_lines: 1300,
320            },
321            context_window: Some(ContextWindowReport {
322                window_tokens: 100000,
323                total_tokens: 2500,
324                pct: 0.025,
325                fits: true,
326            }),
327            cocomo: Some(CocomoReport {
328                mode: "organic".to_string(),
329                kloc: 1.0,
330                effort_pm: 2.4,
331                duration_months: 2.5,
332                staff: 1.0,
333                a: 2.4,
334                b: 1.05,
335                c: 2.5,
336                d: 0.38,
337            }),
338            todo: Some(TodoReport {
339                total: 5,
340                density_per_kloc: 5.0,
341                tags: vec![TodoTagRow {
342                    tag: "TODO".to_string(),
343                    count: 5,
344                }],
345            }),
346            integrity: IntegrityReport {
347                algo: "blake3".to_string(),
348                hash: "abc123".to_string(),
349                entries: 10,
350            },
351        }
352    }
353
354    #[test]
355    fn format_number_thresholds() {
356        assert_eq!(format_number(500), "500");
357        assert_eq!(format_number(1_000), "1.0K");
358        assert_eq!(format_number(1_500), "1.5K");
359        assert_eq!(format_number(1_000_000), "1.0M");
360        assert_eq!(format_number(2_500_000), "2.5M");
361    }
362
363    #[test]
364    fn escape_html_encodes_special_chars() {
365        assert_eq!(escape_html("hello"), "hello");
366        assert_eq!(escape_html("<script>"), "&lt;script&gt;");
367        assert_eq!(escape_html("a & b"), "a &amp; b");
368        assert_eq!(escape_html("\"quoted\""), "&quot;quoted&quot;");
369        assert_eq!(escape_html("it's"), "it&#x27;s");
370        assert_eq!(
371            escape_html("<a href=\"test\">&'"),
372            "&lt;a href=&quot;test&quot;&gt;&amp;&#x27;"
373        );
374    }
375
376    #[test]
377    fn timestamp_has_expected_shape() {
378        let ts = timestamp_utc();
379        assert!(ts.contains("UTC"));
380        assert!(ts.len() > 10);
381    }
382
383    #[test]
384    fn metrics_cards_empty_without_derived() {
385        let receipt = minimal_receipt();
386        assert!(build_metrics_cards(&receipt).is_empty());
387    }
388
389    #[test]
390    fn metrics_cards_include_context_fit_when_available() {
391        let mut receipt = minimal_receipt();
392        receipt.derived = Some(sample_derived());
393        let cards = build_metrics_cards(&receipt);
394        assert!(cards.contains("class=\"metric-card\""));
395        assert!(cards.contains("Context Fit"));
396    }
397
398    #[test]
399    fn table_rows_are_html_escaped() {
400        let mut receipt = minimal_receipt();
401        let mut derived = sample_derived();
402        derived.top.largest_lines[0].path = "src/<script>.rs".to_string();
403        derived.top.largest_lines[0].module = "mod&name".to_string();
404        derived.top.largest_lines[0].lang = "Ru\"st".to_string();
405        receipt.derived = Some(derived);
406
407        let rows = build_table_rows(&receipt);
408        assert!(rows.contains("src/&lt;script&gt;.rs"));
409        assert!(rows.contains("mod&amp;name"));
410        assert!(rows.contains("Ru&quot;st"));
411    }
412
413    #[test]
414    fn report_json_escapes_angle_brackets() {
415        let mut receipt = minimal_receipt();
416        let mut derived = sample_derived();
417        derived.top.largest_lines[0].path = "</script><script>alert(1)</script>".to_string();
418        receipt.derived = Some(derived);
419
420        let json = build_report_json(&receipt);
421        assert!(
422            json.contains("\\u003c/script\\u003e\\u003cscript\\u003ealert(1)\\u003c/script\\u003e")
423        );
424        assert!(!json.contains('<'));
425        assert!(!json.contains('>'));
426    }
427
428    #[test]
429    fn report_json_without_derived_is_empty_files_array() {
430        let receipt = minimal_receipt();
431        assert_eq!(build_report_json(&receipt), "{\"files\":[]}");
432    }
433
434    #[test]
435    fn render_inlines_template_content() {
436        let mut receipt = minimal_receipt();
437        receipt.derived = Some(sample_derived());
438
439        let html = render(&receipt);
440        assert!(html.contains("<!DOCTYPE html>"));
441        assert!(html.contains("metric-card"));
442        assert!(html.contains("src/lib.rs"));
443        assert!(html.contains("const REPORT_DATA ="));
444    }
445}