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            effort: None,
190            fun: None,
191        }
192    }
193
194    fn sample_derived() -> DerivedReport {
195        DerivedReport {
196            totals: DerivedTotals {
197                files: 10,
198                code: 1000,
199                comments: 200,
200                blanks: 100,
201                lines: 1300,
202                bytes: 50000,
203                tokens: 2500,
204            },
205            doc_density: RatioReport {
206                total: RatioRow {
207                    key: "total".to_string(),
208                    numerator: 200,
209                    denominator: 1200,
210                    ratio: 0.1667,
211                },
212                by_lang: vec![],
213                by_module: vec![],
214            },
215            whitespace: RatioReport {
216                total: RatioRow {
217                    key: "total".to_string(),
218                    numerator: 100,
219                    denominator: 1300,
220                    ratio: 0.0769,
221                },
222                by_lang: vec![],
223                by_module: vec![],
224            },
225            verbosity: RateReport {
226                total: RateRow {
227                    key: "total".to_string(),
228                    numerator: 50000,
229                    denominator: 1300,
230                    rate: 38.46,
231                },
232                by_lang: vec![],
233                by_module: vec![],
234            },
235            max_file: MaxFileReport {
236                overall: FileStatRow {
237                    path: "src/lib.rs".to_string(),
238                    module: "src".to_string(),
239                    lang: "Rust".to_string(),
240                    code: 500,
241                    comments: 100,
242                    blanks: 50,
243                    lines: 650,
244                    bytes: 25000,
245                    tokens: 1250,
246                    doc_pct: Some(0.167),
247                    bytes_per_line: Some(38.46),
248                    depth: 1,
249                },
250                by_lang: vec![],
251                by_module: vec![],
252            },
253            lang_purity: LangPurityReport { rows: vec![] },
254            nesting: NestingReport {
255                max: 3,
256                avg: 1.5,
257                by_module: vec![],
258            },
259            test_density: TestDensityReport {
260                test_lines: 200,
261                prod_lines: 1000,
262                test_files: 5,
263                prod_files: 5,
264                ratio: 0.2,
265            },
266            boilerplate: BoilerplateReport {
267                infra_lines: 100,
268                logic_lines: 1100,
269                ratio: 0.083,
270                infra_langs: vec!["TOML".to_string()],
271            },
272            polyglot: PolyglotReport {
273                lang_count: 2,
274                entropy: 0.5,
275                dominant_lang: "Rust".to_string(),
276                dominant_lines: 1000,
277                dominant_pct: 0.833,
278            },
279            distribution: DistributionReport {
280                count: 10,
281                min: 50,
282                max: 650,
283                mean: 130.0,
284                median: 100.0,
285                p90: 400.0,
286                p99: 650.0,
287                gini: 0.3,
288            },
289            histogram: vec![HistogramBucket {
290                label: "Small".to_string(),
291                min: 0,
292                max: Some(100),
293                files: 5,
294                pct: 0.5,
295            }],
296            top: TopOffenders {
297                largest_lines: vec![FileStatRow {
298                    path: "src/lib.rs".to_string(),
299                    module: "src".to_string(),
300                    lang: "Rust".to_string(),
301                    code: 500,
302                    comments: 100,
303                    blanks: 50,
304                    lines: 650,
305                    bytes: 25000,
306                    tokens: 1250,
307                    doc_pct: Some(0.167),
308                    bytes_per_line: Some(38.46),
309                    depth: 1,
310                }],
311                largest_tokens: vec![],
312                largest_bytes: vec![],
313                least_documented: vec![],
314                most_dense: vec![],
315            },
316            tree: Some("test-tree".to_string()),
317            reading_time: ReadingTimeReport {
318                minutes: 65.0,
319                lines_per_minute: 20,
320                basis_lines: 1300,
321            },
322            context_window: Some(ContextWindowReport {
323                window_tokens: 100000,
324                total_tokens: 2500,
325                pct: 0.025,
326                fits: true,
327            }),
328            cocomo: Some(CocomoReport {
329                mode: "organic".to_string(),
330                kloc: 1.0,
331                effort_pm: 2.4,
332                duration_months: 2.5,
333                staff: 1.0,
334                a: 2.4,
335                b: 1.05,
336                c: 2.5,
337                d: 0.38,
338            }),
339            todo: Some(TodoReport {
340                total: 5,
341                density_per_kloc: 5.0,
342                tags: vec![TodoTagRow {
343                    tag: "TODO".to_string(),
344                    count: 5,
345                }],
346            }),
347            integrity: IntegrityReport {
348                algo: "blake3".to_string(),
349                hash: "abc123".to_string(),
350                entries: 10,
351            },
352        }
353    }
354
355    #[test]
356    fn format_number_thresholds() {
357        assert_eq!(format_number(500), "500");
358        assert_eq!(format_number(1_000), "1.0K");
359        assert_eq!(format_number(1_500), "1.5K");
360        assert_eq!(format_number(1_000_000), "1.0M");
361        assert_eq!(format_number(2_500_000), "2.5M");
362    }
363
364    #[test]
365    fn escape_html_encodes_special_chars() {
366        assert_eq!(escape_html("hello"), "hello");
367        assert_eq!(escape_html("<script>"), "&lt;script&gt;");
368        assert_eq!(escape_html("a & b"), "a &amp; b");
369        assert_eq!(escape_html("\"quoted\""), "&quot;quoted&quot;");
370        assert_eq!(escape_html("it's"), "it&#x27;s");
371        assert_eq!(
372            escape_html("<a href=\"test\">&'"),
373            "&lt;a href=&quot;test&quot;&gt;&amp;&#x27;"
374        );
375    }
376
377    #[test]
378    fn timestamp_has_expected_shape() {
379        let ts = timestamp_utc();
380        assert!(ts.contains("UTC"));
381        assert!(ts.len() > 10);
382    }
383
384    #[test]
385    fn metrics_cards_empty_without_derived() {
386        let receipt = minimal_receipt();
387        assert!(build_metrics_cards(&receipt).is_empty());
388    }
389
390    #[test]
391    fn metrics_cards_include_context_fit_when_available() {
392        let mut receipt = minimal_receipt();
393        receipt.derived = Some(sample_derived());
394        let cards = build_metrics_cards(&receipt);
395        assert!(cards.contains("class=\"metric-card\""));
396        assert!(cards.contains("Context Fit"));
397    }
398
399    #[test]
400    fn table_rows_are_html_escaped() {
401        let mut receipt = minimal_receipt();
402        let mut derived = sample_derived();
403        derived.top.largest_lines[0].path = "src/<script>.rs".to_string();
404        derived.top.largest_lines[0].module = "mod&name".to_string();
405        derived.top.largest_lines[0].lang = "Ru\"st".to_string();
406        receipt.derived = Some(derived);
407
408        let rows = build_table_rows(&receipt);
409        assert!(rows.contains("src/&lt;script&gt;.rs"));
410        assert!(rows.contains("mod&amp;name"));
411        assert!(rows.contains("Ru&quot;st"));
412    }
413
414    #[test]
415    fn report_json_escapes_angle_brackets() {
416        let mut receipt = minimal_receipt();
417        let mut derived = sample_derived();
418        derived.top.largest_lines[0].path = "</script><script>alert(1)</script>".to_string();
419        receipt.derived = Some(derived);
420
421        let json = build_report_json(&receipt);
422        assert!(
423            json.contains("\\u003c/script\\u003e\\u003cscript\\u003ealert(1)\\u003c/script\\u003e")
424        );
425        assert!(!json.contains('<'));
426        assert!(!json.contains('>'));
427    }
428
429    #[test]
430    fn report_json_without_derived_is_empty_files_array() {
431        let receipt = minimal_receipt();
432        assert_eq!(build_report_json(&receipt), "{\"files\":[]}");
433    }
434
435    #[test]
436    fn render_inlines_template_content() {
437        let mut receipt = minimal_receipt();
438        receipt.derived = Some(sample_derived());
439
440        let html = render(&receipt);
441        assert!(html.contains("<!DOCTYPE html>"));
442        assert!(html.contains("metric-card"));
443        assert!(html.contains("src/lib.rs"));
444        assert!(html.contains("const REPORT_DATA ="));
445    }
446}