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 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>"), "<script>");
367 assert_eq!(escape_html("a & b"), "a & b");
368 assert_eq!(escape_html("\"quoted\""), ""quoted"");
369 assert_eq!(escape_html("it's"), "it's");
370 assert_eq!(
371 escape_html("<a href=\"test\">&'"),
372 "<a href="test">&'"
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/<script>.rs"));
409 assert!(rows.contains("mod&name"));
410 assert!(rows.contains("Ru"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}