1use time::OffsetDateTime;
8use time::macros::format_description;
9use tokmd_analysis_types::AnalysisReceipt;
10
11pub 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}}", ×tamp)
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 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('&', "&")
130 .replace('<', "<")
131 .replace('>', ">")
132 .replace('"', """)
133 .replace('\'', "'")
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>"), "<script>");
368 assert_eq!(escape_html("a & b"), "a & b");
369 assert_eq!(escape_html("\"quoted\""), ""quoted"");
370 assert_eq!(escape_html("it's"), "it's");
371 assert_eq!(
372 escape_html("<a href=\"test\">&'"),
373 "<a href="test">&'"
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/<script>.rs"));
410 assert!(rows.contains("mod&name"));
411 assert!(rows.contains("Ru"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}