1use anyhow::Result;
19use time::OffsetDateTime;
20use time::macros::format_description;
21use tokmd_analysis_types::{AnalysisReceipt, FileStatRow};
22use tokmd_config::AnalysisFormat;
23
24pub enum RenderedOutput {
25 Text(String),
26 Binary(Vec<u8>),
27}
28
29pub fn render(receipt: &AnalysisReceipt, format: AnalysisFormat) -> Result<RenderedOutput> {
30 match format {
31 AnalysisFormat::Md => Ok(RenderedOutput::Text(render_md(receipt))),
32 AnalysisFormat::Json => Ok(RenderedOutput::Text(serde_json::to_string_pretty(receipt)?)),
33 AnalysisFormat::Jsonld => Ok(RenderedOutput::Text(render_jsonld(receipt))),
34 AnalysisFormat::Xml => Ok(RenderedOutput::Text(render_xml(receipt))),
35 AnalysisFormat::Svg => Ok(RenderedOutput::Text(render_svg(receipt))),
36 AnalysisFormat::Mermaid => Ok(RenderedOutput::Text(render_mermaid(receipt))),
37 AnalysisFormat::Obj => Ok(RenderedOutput::Text(render_obj(receipt)?)),
38 AnalysisFormat::Midi => Ok(RenderedOutput::Binary(render_midi(receipt)?)),
39 AnalysisFormat::Tree => Ok(RenderedOutput::Text(render_tree(receipt))),
40 AnalysisFormat::Html => Ok(RenderedOutput::Text(render_html(receipt))),
41 }
42}
43
44fn render_md(receipt: &AnalysisReceipt) -> String {
45 let mut out = String::new();
46 out.push_str("# tokmd analysis\n\n");
47 out.push_str(&format!("Preset: `{}`\n\n", receipt.args.preset));
48
49 if !receipt.source.inputs.is_empty() {
50 out.push_str("## Inputs\n\n");
51 for input in &receipt.source.inputs {
52 out.push_str(&format!("- `{}`\n", input));
53 }
54 out.push('\n');
55 }
56
57 if let Some(archetype) = &receipt.archetype {
58 out.push_str("## Archetype\n\n");
59 out.push_str(&format!("- Kind: `{}`\n", archetype.kind));
60 if !archetype.evidence.is_empty() {
61 out.push_str(&format!(
62 "- Evidence: `{}`\n",
63 archetype.evidence.join("`, `")
64 ));
65 }
66 out.push('\n');
67 }
68
69 if let Some(topics) = &receipt.topics {
70 out.push_str("## Topics\n\n");
71 if !topics.overall.is_empty() {
72 out.push_str(&format!(
73 "- Overall: `{}`\n",
74 topics
75 .overall
76 .iter()
77 .map(|t| t.term.as_str())
78 .collect::<Vec<_>>()
79 .join(", ")
80 ));
81 }
82 for (module, terms) in &topics.per_module {
83 if terms.is_empty() {
84 continue;
85 }
86 let line = terms
87 .iter()
88 .map(|t| t.term.as_str())
89 .collect::<Vec<_>>()
90 .join(", ");
91 out.push_str(&format!("- `{}`: {}\n", module, line));
92 }
93 out.push('\n');
94 }
95
96 if let Some(entropy) = &receipt.entropy {
97 out.push_str("## Entropy profiling\n\n");
98 if entropy.suspects.is_empty() {
99 out.push_str("- No entropy outliers detected.\n\n");
100 } else {
101 out.push_str("|Path|Module|Entropy|Sample bytes|Class|\n");
102 out.push_str("|---|---|---:|---:|---|\n");
103 for row in entropy.suspects.iter().take(10) {
104 out.push_str(&format!(
105 "|{}|{}|{}|{}|{:?}|\n",
106 row.path,
107 row.module,
108 fmt_f64(row.entropy_bits_per_byte as f64, 2),
109 row.sample_bytes,
110 row.class
111 ));
112 }
113 out.push('\n');
114 }
115 }
116
117 if let Some(license) = &receipt.license {
118 out.push_str("## License radar\n\n");
119 if let Some(effective) = &license.effective {
120 out.push_str(&format!("- Effective: `{}`\n", effective));
121 }
122 out.push_str("- Heuristic detection; not legal advice.\n\n");
123 if !license.findings.is_empty() {
124 out.push_str("|SPDX|Confidence|Source|Kind|\n");
125 out.push_str("|---|---:|---|---|\n");
126 for row in license.findings.iter().take(10) {
127 out.push_str(&format!(
128 "|{}|{}|{}|{:?}|\n",
129 row.spdx,
130 fmt_f64(row.confidence as f64, 2),
131 row.source_path,
132 row.source_kind
133 ));
134 }
135 out.push('\n');
136 }
137 }
138
139 if let Some(fingerprint) = &receipt.corporate_fingerprint {
140 out.push_str("## Corporate fingerprint\n\n");
141 if fingerprint.domains.is_empty() {
142 out.push_str("- No commit domains detected.\n\n");
143 } else {
144 out.push_str("|Domain|Commits|Pct|\n");
145 out.push_str("|---|---:|---:|\n");
146 for row in fingerprint.domains.iter().take(10) {
147 out.push_str(&format!(
148 "|{}|{}|{}|\n",
149 row.domain,
150 row.commits,
151 fmt_pct(row.pct as f64)
152 ));
153 }
154 out.push('\n');
155 }
156 }
157
158 if let Some(churn) = &receipt.predictive_churn {
159 out.push_str("## Predictive churn\n\n");
160 let mut rows: Vec<_> = churn.per_module.iter().collect();
161 rows.sort_by(|a, b| {
162 b.1.slope
163 .partial_cmp(&a.1.slope)
164 .unwrap_or(std::cmp::Ordering::Equal)
165 .then_with(|| a.0.cmp(b.0))
166 });
167 if rows.is_empty() {
168 out.push_str("- No churn signals detected.\n\n");
169 } else {
170 out.push_str("|Module|Slope|R²|Recent change|Class|\n");
171 out.push_str("|---|---:|---:|---:|---|\n");
172 for (module, trend) in rows.into_iter().take(10) {
173 out.push_str(&format!(
174 "|{}|{}|{}|{}|{:?}|\n",
175 module,
176 fmt_f64(trend.slope, 4),
177 fmt_f64(trend.r2, 2),
178 trend.recent_change,
179 trend.classification
180 ));
181 }
182 out.push('\n');
183 }
184 }
185
186 if let Some(derived) = &receipt.derived {
187 out.push_str("## Totals\n\n");
188 out.push_str("|Files|Code|Comments|Blanks|Lines|Bytes|Tokens|\n");
189 out.push_str("|---:|---:|---:|---:|---:|---:|---:|\n");
190 out.push_str(&format!(
191 "|{}|{}|{}|{}|{}|{}|{}|\n\n",
192 derived.totals.files,
193 derived.totals.code,
194 derived.totals.comments,
195 derived.totals.blanks,
196 derived.totals.lines,
197 derived.totals.bytes,
198 derived.totals.tokens
199 ));
200
201 out.push_str("## Ratios\n\n");
202 out.push_str("|Metric|Value|\n");
203 out.push_str("|---|---:|\n");
204 out.push_str(&format!(
205 "|Doc density|{}|\n",
206 fmt_pct(derived.doc_density.total.ratio)
207 ));
208 out.push_str(&format!(
209 "|Whitespace ratio|{}|\n",
210 fmt_pct(derived.whitespace.total.ratio)
211 ));
212 out.push_str(&format!(
213 "|Bytes per line|{}|\n\n",
214 fmt_f64(derived.verbosity.total.rate, 2)
215 ));
216
217 out.push_str("### Doc density by language\n\n");
218 out.push_str("|Lang|Doc%|Comments|Code|\n");
219 out.push_str("|---|---:|---:|---:|\n");
220 for row in derived.doc_density.by_lang.iter().take(10) {
221 out.push_str(&format!(
222 "|{}|{}|{}|{}|\n",
223 row.key,
224 fmt_pct(row.ratio),
225 row.numerator,
226 row.denominator.saturating_sub(row.numerator)
227 ));
228 }
229 out.push('\n');
230
231 out.push_str("### Whitespace ratio by language\n\n");
232 out.push_str("|Lang|Blank%|Blanks|Code+Comments|\n");
233 out.push_str("|---|---:|---:|---:|\n");
234 for row in derived.whitespace.by_lang.iter().take(10) {
235 out.push_str(&format!(
236 "|{}|{}|{}|{}|\n",
237 row.key,
238 fmt_pct(row.ratio),
239 row.numerator,
240 row.denominator
241 ));
242 }
243 out.push('\n');
244
245 out.push_str("### Verbosity by language\n\n");
246 out.push_str("|Lang|Bytes/Line|Bytes|Lines|\n");
247 out.push_str("|---|---:|---:|---:|\n");
248 for row in derived.verbosity.by_lang.iter().take(10) {
249 out.push_str(&format!(
250 "|{}|{}|{}|{}|\n",
251 row.key,
252 fmt_f64(row.rate, 2),
253 row.numerator,
254 row.denominator
255 ));
256 }
257 out.push('\n');
258
259 out.push_str("## Distribution\n\n");
260 out.push_str("|Count|Min|Max|Mean|Median|P90|P99|Gini|\n");
261 out.push_str("|---:|---:|---:|---:|---:|---:|---:|---:|\n");
262 out.push_str(&format!(
263 "|{}|{}|{}|{}|{}|{}|{}|{}|\n\n",
264 derived.distribution.count,
265 derived.distribution.min,
266 derived.distribution.max,
267 fmt_f64(derived.distribution.mean, 2),
268 fmt_f64(derived.distribution.median, 2),
269 fmt_f64(derived.distribution.p90, 2),
270 fmt_f64(derived.distribution.p99, 2),
271 fmt_f64(derived.distribution.gini, 4)
272 ));
273
274 out.push_str("## File size histogram\n\n");
275 out.push_str("|Bucket|Min|Max|Files|Pct|\n");
276 out.push_str("|---|---:|---:|---:|---:|\n");
277 for bucket in &derived.histogram {
278 let max = bucket
279 .max
280 .map(|v| v.to_string())
281 .unwrap_or_else(|| "∞".to_string());
282 out.push_str(&format!(
283 "|{}|{}|{}|{}|{}|\n",
284 bucket.label,
285 bucket.min,
286 max,
287 bucket.files,
288 fmt_pct(bucket.pct)
289 ));
290 }
291 out.push('\n');
292
293 out.push_str("## Top offenders\n\n");
294 out.push_str("### Largest files by lines\n\n");
295 out.push_str(&render_file_table(&derived.top.largest_lines));
296 out.push('\n');
297
298 out.push_str("### Largest files by tokens\n\n");
299 out.push_str(&render_file_table(&derived.top.largest_tokens));
300 out.push('\n');
301
302 out.push_str("### Largest files by bytes\n\n");
303 out.push_str(&render_file_table(&derived.top.largest_bytes));
304 out.push('\n');
305
306 out.push_str("### Least documented (min LOC)\n\n");
307 out.push_str(&render_file_table(&derived.top.least_documented));
308 out.push('\n');
309
310 out.push_str("### Most dense (bytes/line)\n\n");
311 out.push_str(&render_file_table(&derived.top.most_dense));
312 out.push('\n');
313
314 out.push_str("## Structure\n\n");
315 out.push_str(&format!(
316 "- Max depth: `{}`\n- Avg depth: `{}`\n\n",
317 derived.nesting.max,
318 fmt_f64(derived.nesting.avg, 2)
319 ));
320
321 out.push_str("## Test density\n\n");
322 out.push_str(&format!(
323 "- Test lines: `{}`\n- Prod lines: `{}`\n- Test ratio: `{}`\n\n",
324 derived.test_density.test_lines,
325 derived.test_density.prod_lines,
326 fmt_pct(derived.test_density.ratio)
327 ));
328
329 if let Some(todo) = &derived.todo {
330 out.push_str("## TODOs\n\n");
331 out.push_str(&format!(
332 "- Total: `{}`\n- Density (per KLOC): `{}`\n\n",
333 todo.total,
334 fmt_f64(todo.density_per_kloc, 2)
335 ));
336 out.push_str("|Tag|Count|\n");
337 out.push_str("|---|---:|\n");
338 for tag in &todo.tags {
339 out.push_str(&format!("|{}|{}|\n", tag.tag, tag.count));
340 }
341 out.push('\n');
342 }
343
344 out.push_str("## Boilerplate ratio\n\n");
345 out.push_str(&format!(
346 "- Infra lines: `{}`\n- Logic lines: `{}`\n- Infra ratio: `{}`\n\n",
347 derived.boilerplate.infra_lines,
348 derived.boilerplate.logic_lines,
349 fmt_pct(derived.boilerplate.ratio)
350 ));
351
352 out.push_str("## Polyglot\n\n");
353 out.push_str(&format!(
354 "- Languages: `{}`\n- Dominant: `{}` ({})\n- Entropy: `{}`\n\n",
355 derived.polyglot.lang_count,
356 derived.polyglot.dominant_lang,
357 fmt_pct(derived.polyglot.dominant_pct),
358 fmt_f64(derived.polyglot.entropy, 4)
359 ));
360
361 out.push_str("## Reading time\n\n");
362 out.push_str(&format!(
363 "- Minutes: `{}` ({} lines/min)\n\n",
364 fmt_f64(derived.reading_time.minutes, 2),
365 derived.reading_time.lines_per_minute
366 ));
367
368 if let Some(context) = &derived.context_window {
369 out.push_str("## Context window\n\n");
370 out.push_str(&format!(
371 "- Window tokens: `{}`\n- Total tokens: `{}`\n- Utilization: `{}`\n- Fits: `{}`\n\n",
372 context.window_tokens,
373 context.total_tokens,
374 fmt_pct(context.pct),
375 context.fits
376 ));
377 }
378
379 if let Some(cocomo) = &derived.cocomo {
380 out.push_str("## COCOMO estimate\n\n");
381 out.push_str(&format!(
382 "- Mode: `{}`\n- KLOC: `{}`\n- Effort (PM): `{}`\n- Duration (months): `{}`\n- Staff: `{}`\n\n",
383 cocomo.mode,
384 fmt_f64(cocomo.kloc, 4),
385 fmt_f64(cocomo.effort_pm, 2),
386 fmt_f64(cocomo.duration_months, 2),
387 fmt_f64(cocomo.staff, 2)
388 ));
389 }
390
391 out.push_str("## Integrity\n\n");
392 out.push_str(&format!(
393 "- Hash: `{}` (`{}`)\n- Entries: `{}`\n\n",
394 derived.integrity.hash, derived.integrity.algo, derived.integrity.entries
395 ));
396 }
397
398 if let Some(assets) = &receipt.assets {
399 out.push_str("## Assets\n\n");
400 out.push_str(&format!(
401 "- Total files: `{}`\n- Total bytes: `{}`\n\n",
402 assets.total_files, assets.total_bytes
403 ));
404 if !assets.categories.is_empty() {
405 out.push_str("|Category|Files|Bytes|Extensions|\n");
406 out.push_str("|---|---:|---:|---|\n");
407 for row in &assets.categories {
408 out.push_str(&format!(
409 "|{}|{}|{}|{}|\n",
410 row.category,
411 row.files,
412 row.bytes,
413 row.extensions.join(", ")
414 ));
415 }
416 out.push('\n');
417 }
418 if !assets.top_files.is_empty() {
419 out.push_str("|File|Bytes|Category|\n");
420 out.push_str("|---|---:|---|\n");
421 for row in &assets.top_files {
422 out.push_str(&format!("|{}|{}|{}|\n", row.path, row.bytes, row.category));
423 }
424 out.push('\n');
425 }
426 }
427
428 if let Some(deps) = &receipt.deps {
429 out.push_str("## Dependencies\n\n");
430 out.push_str(&format!("- Total: `{}`\n\n", deps.total));
431 if !deps.lockfiles.is_empty() {
432 out.push_str("|Lockfile|Kind|Dependencies|\n");
433 out.push_str("|---|---|---:|\n");
434 for row in &deps.lockfiles {
435 out.push_str(&format!(
436 "|{}|{}|{}|\n",
437 row.path, row.kind, row.dependencies
438 ));
439 }
440 out.push('\n');
441 }
442 }
443
444 if let Some(git) = &receipt.git {
445 out.push_str("## Git metrics\n\n");
446 out.push_str(&format!(
447 "- Commits scanned: `{}`\n- Files seen: `{}`\n\n",
448 git.commits_scanned, git.files_seen
449 ));
450 if !git.hotspots.is_empty() {
451 out.push_str("### Hotspots\n\n");
452 out.push_str("|File|Commits|Lines|Score|\n");
453 out.push_str("|---|---:|---:|---:|\n");
454 for row in git.hotspots.iter().take(10) {
455 out.push_str(&format!(
456 "|{}|{}|{}|{}|\n",
457 row.path, row.commits, row.lines, row.score
458 ));
459 }
460 out.push('\n');
461 }
462 if !git.bus_factor.is_empty() {
463 out.push_str("### Bus factor\n\n");
464 out.push_str("|Module|Authors|\n");
465 out.push_str("|---|---:|\n");
466 for row in git.bus_factor.iter().take(10) {
467 out.push_str(&format!("|{}|{}|\n", row.module, row.authors));
468 }
469 out.push('\n');
470 }
471 out.push_str("### Freshness\n\n");
472 out.push_str(&format!(
473 "- Stale threshold (days): `{}`\n- Stale files: `{}` / `{}` ({})\n\n",
474 git.freshness.threshold_days,
475 git.freshness.stale_files,
476 git.freshness.total_files,
477 fmt_pct(git.freshness.stale_pct)
478 ));
479 if !git.freshness.by_module.is_empty() {
480 out.push_str("|Module|Avg days|P90 days|Stale%|\n");
481 out.push_str("|---|---:|---:|---:|\n");
482 for row in git.freshness.by_module.iter().take(10) {
483 out.push_str(&format!(
484 "|{}|{}|{}|{}|\n",
485 row.module,
486 fmt_f64(row.avg_days, 2),
487 fmt_f64(row.p90_days, 2),
488 fmt_pct(row.stale_pct)
489 ));
490 }
491 out.push('\n');
492 }
493 if !git.coupling.is_empty() {
494 out.push_str("### Coupling\n\n");
495 out.push_str("|Left|Right|Count|\n");
496 out.push_str("|---|---|---:|\n");
497 for row in git.coupling.iter().take(10) {
498 out.push_str(&format!("|{}|{}|{}|\n", row.left, row.right, row.count));
499 }
500 out.push('\n');
501 }
502 }
503
504 if let Some(imports) = &receipt.imports {
505 out.push_str("## Imports\n\n");
506 out.push_str(&format!("- Granularity: `{}`\n\n", imports.granularity));
507 if !imports.edges.is_empty() {
508 out.push_str("|From|To|Count|\n");
509 out.push_str("|---|---|---:|\n");
510 for row in imports.edges.iter().take(20) {
511 out.push_str(&format!("|{}|{}|{}|\n", row.from, row.to, row.count));
512 }
513 out.push('\n');
514 }
515 }
516
517 if let Some(dup) = &receipt.dup {
518 out.push_str("## Duplicates\n\n");
519 out.push_str(&format!(
520 "- Wasted bytes: `{}`\n- Strategy: `{}`\n\n",
521 dup.wasted_bytes, dup.strategy
522 ));
523 if !dup.groups.is_empty() {
524 out.push_str("|Hash|Bytes|Files|\n");
525 out.push_str("|---|---:|---:|\n");
526 for row in dup.groups.iter().take(10) {
527 out.push_str(&format!(
528 "|{}|{}|{}|\n",
529 row.hash,
530 row.bytes,
531 row.files.len()
532 ));
533 }
534 out.push('\n');
535 }
536 }
537
538 if let Some(fun) = &receipt.fun
539 && let Some(label) = &fun.eco_label
540 {
541 out.push_str("## Eco label\n\n");
542 out.push_str(&format!(
543 "- Label: `{}`\n- Score: `{}`\n- Bytes: `{}`\n- Notes: `{}`\n\n",
544 label.label,
545 fmt_f64(label.score, 1),
546 label.bytes,
547 label.notes
548 ));
549 }
550
551 out
552}
553
554fn render_file_table(rows: &[FileStatRow]) -> String {
555 let mut out = String::new();
556 out.push_str("|Path|Lang|Lines|Code|Bytes|Tokens|Doc%|B/Line|\n");
557 out.push_str("|---|---|---:|---:|---:|---:|---:|---:|\n");
558 for row in rows {
559 out.push_str(&format!(
560 "|{}|{}|{}|{}|{}|{}|{}|{}|\n",
561 row.path,
562 row.lang,
563 row.lines,
564 row.code,
565 row.bytes,
566 row.tokens,
567 row.doc_pct.map(fmt_pct).unwrap_or_else(|| "-".to_string()),
568 row.bytes_per_line
569 .map(|v| fmt_f64(v, 2))
570 .unwrap_or_else(|| "-".to_string())
571 ));
572 }
573 out
574}
575
576fn fmt_pct(ratio: f64) -> String {
577 format!("{:.1}%", ratio * 100.0)
578}
579
580fn fmt_f64(value: f64, decimals: usize) -> String {
581 format!("{value:.decimals$}")
582}
583
584fn render_jsonld(receipt: &AnalysisReceipt) -> String {
585 let name = receipt
586 .source
587 .inputs
588 .first()
589 .cloned()
590 .unwrap_or_else(|| "tokmd".to_string());
591 let totals = receipt.derived.as_ref().map(|d| &d.totals);
592 let payload = serde_json::json!({
593 "@context": "https://schema.org",
594 "@type": "SoftwareSourceCode",
595 "name": name,
596 "codeLines": totals.map(|t| t.code).unwrap_or(0),
597 "commentCount": totals.map(|t| t.comments).unwrap_or(0),
598 "lineCount": totals.map(|t| t.lines).unwrap_or(0),
599 "fileSize": totals.map(|t| t.bytes).unwrap_or(0),
600 "interactionStatistic": {
601 "@type": "InteractionCounter",
602 "interactionType": "http://schema.org/ReadAction",
603 "userInteractionCount": totals.map(|t| t.tokens).unwrap_or(0)
604 }
605 });
606 serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
607}
608
609fn render_xml(receipt: &AnalysisReceipt) -> String {
610 let totals = receipt.derived.as_ref().map(|d| &d.totals);
611 let mut out = String::new();
612 out.push_str("<analysis>");
613 if let Some(totals) = totals {
614 out.push_str(&format!(
615 "<totals files=\"{}\" code=\"{}\" comments=\"{}\" blanks=\"{}\" lines=\"{}\" bytes=\"{}\" tokens=\"{}\"/>",
616 totals.files,
617 totals.code,
618 totals.comments,
619 totals.blanks,
620 totals.lines,
621 totals.bytes,
622 totals.tokens
623 ));
624 }
625 out.push_str("</analysis>");
626 out
627}
628
629fn render_svg(receipt: &AnalysisReceipt) -> String {
630 let (label, value) = if let Some(derived) = &receipt.derived {
631 if let Some(ctx) = &derived.context_window {
632 ("context".to_string(), format!("{:.1}%", ctx.pct * 100.0))
633 } else {
634 ("tokens".to_string(), derived.totals.tokens.to_string())
635 }
636 } else {
637 ("tokens".to_string(), "0".to_string())
638 };
639
640 let width = 240;
641 let height = 32;
642 let label_width = 80;
643 let value_width = width - label_width;
644 format!(
645 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}\" height=\"{height}\" role=\"img\"><rect width=\"{label_width}\" height=\"{height}\" fill=\"#555\"/><rect x=\"{label_width}\" width=\"{value_width}\" height=\"{height}\" fill=\"#4c9aff\"/><text x=\"{lx}\" y=\"{ty}\" fill=\"#fff\" font-family=\"Verdana\" font-size=\"12\" text-anchor=\"middle\">{label}</text><text x=\"{vx}\" y=\"{ty}\" fill=\"#fff\" font-family=\"Verdana\" font-size=\"12\" text-anchor=\"middle\">{value}</text></svg>",
646 width = width,
647 height = height,
648 label_width = label_width,
649 value_width = value_width,
650 lx = label_width / 2,
651 vx = label_width + value_width / 2,
652 ty = 20,
653 label = label,
654 value = value
655 )
656}
657
658fn render_mermaid(receipt: &AnalysisReceipt) -> String {
659 let mut out = String::from("graph TD\n");
660 if let Some(imports) = &receipt.imports {
661 for edge in imports.edges.iter().take(200) {
662 let from = sanitize_mermaid(&edge.from);
663 let to = sanitize_mermaid(&edge.to);
664 out.push_str(&format!(" {} -->|{}| {}\n", from, edge.count, to));
665 }
666 }
667 out
668}
669
670fn render_tree(receipt: &AnalysisReceipt) -> String {
671 receipt
672 .derived
673 .as_ref()
674 .and_then(|d| d.tree.clone())
675 .unwrap_or_else(|| "(tree unavailable)".to_string())
676}
677
678#[cfg(feature = "fun")]
680fn render_obj_fun(receipt: &AnalysisReceipt) -> Result<String> {
681 if let Some(derived) = &receipt.derived {
682 let buildings: Vec<tokmd_fun::ObjBuilding> = derived
683 .top
684 .largest_lines
685 .iter()
686 .enumerate()
687 .map(|(idx, row)| {
688 let x = (idx % 5) as f32 * 2.0;
689 let y = (idx / 5) as f32 * 2.0;
690 let h = (row.lines as f32 / 10.0).max(0.5);
691 tokmd_fun::ObjBuilding {
692 name: row.path.clone(),
693 x,
694 y,
695 w: 1.5,
696 d: 1.5,
697 h,
698 }
699 })
700 .collect();
701 return Ok(tokmd_fun::render_obj(&buildings));
702 }
703 Ok("# tokmd code city\n".to_string())
704}
705
706#[cfg(feature = "fun")]
707fn render_midi_fun(receipt: &AnalysisReceipt) -> Result<Vec<u8>> {
708 let mut notes = Vec::new();
709 if let Some(derived) = &receipt.derived {
710 for (idx, row) in derived.top.largest_lines.iter().enumerate() {
711 let key = 60u8 + (row.depth as u8 % 12);
712 let velocity = (40 + (row.lines.min(127) as u8 / 2)).min(120);
713 let start = (idx as u32) * 240;
714 notes.push(tokmd_fun::MidiNote {
715 key,
716 velocity,
717 start,
718 duration: 180,
719 channel: 0,
720 });
721 }
722 }
723 tokmd_fun::render_midi(¬es, 120)
724}
725
726#[cfg(not(feature = "fun"))]
728fn render_obj_disabled(_receipt: &AnalysisReceipt) -> Result<String> {
729 anyhow::bail!(
730 "OBJ format requires the `fun` feature: tokmd-analysis-format = {{ version = \"1.3\", features = [\"fun\"] }}"
731 )
732}
733
734#[cfg(not(feature = "fun"))]
735fn render_midi_disabled(_receipt: &AnalysisReceipt) -> Result<Vec<u8>> {
736 anyhow::bail!(
737 "MIDI format requires the `fun` feature: tokmd-analysis-format = {{ version = \"1.3\", features = [\"fun\"] }}"
738 )
739}
740
741fn render_obj(receipt: &AnalysisReceipt) -> Result<String> {
743 #[cfg(feature = "fun")]
744 {
745 render_obj_fun(receipt)
746 }
747 #[cfg(not(feature = "fun"))]
748 {
749 render_obj_disabled(receipt)
750 }
751}
752
753fn render_midi(receipt: &AnalysisReceipt) -> Result<Vec<u8>> {
754 #[cfg(feature = "fun")]
755 {
756 render_midi_fun(receipt)
757 }
758 #[cfg(not(feature = "fun"))]
759 {
760 render_midi_disabled(receipt)
761 }
762}
763
764fn sanitize_mermaid(name: &str) -> String {
765 name.chars()
766 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
767 .collect()
768}
769
770fn render_html(receipt: &AnalysisReceipt) -> String {
771 const TEMPLATE: &str = include_str!("templates/report.html");
772
773 let timestamp = chrono_lite_timestamp();
775
776 let metrics_cards = build_metrics_cards(receipt);
778
779 let table_rows = build_table_rows(receipt);
781
782 let report_json = build_report_json(receipt);
784
785 TEMPLATE
786 .replace("{{TIMESTAMP}}", ×tamp)
787 .replace("{{METRICS_CARDS}}", &metrics_cards)
788 .replace("{{TABLE_ROWS}}", &table_rows)
789 .replace("{{REPORT_JSON}}", &report_json)
790}
791
792fn chrono_lite_timestamp() -> String {
793 let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second] UTC");
794 OffsetDateTime::now_utc()
795 .format(&format)
796 .unwrap_or_else(|_| "1970-01-01 00:00:00 UTC".to_string())
797}
798
799fn build_metrics_cards(receipt: &AnalysisReceipt) -> String {
800 let mut cards = String::new();
801
802 if let Some(derived) = &receipt.derived {
803 let metrics = [
804 ("Files", derived.totals.files.to_string()),
805 ("Lines", format_number(derived.totals.lines)),
806 ("Code", format_number(derived.totals.code)),
807 ("Tokens", format_number(derived.totals.tokens)),
808 ("Doc%", fmt_pct(derived.doc_density.total.ratio)),
809 ];
810
811 for (label, value) in metrics {
812 cards.push_str(&format!(
813 r#"<div class="metric-card"><span class="value">{}</span><span class="label">{}</span></div>"#,
814 value, label
815 ));
816 }
817
818 if let Some(ctx) = &derived.context_window {
820 cards.push_str(&format!(
821 r#"<div class="metric-card"><span class="value">{}</span><span class="label">Context Fit</span></div>"#,
822 fmt_pct(ctx.pct)
823 ));
824 }
825 }
826
827 cards
828}
829
830fn build_table_rows(receipt: &AnalysisReceipt) -> String {
831 let mut rows = String::new();
832
833 if let Some(derived) = &receipt.derived {
834 for row in derived.top.largest_lines.iter().take(100) {
836 rows.push_str(&format!(
837 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>"#,
838 path = html_escape(&row.path),
839 module = html_escape(&row.module),
840 lang = html_escape(&row.lang),
841 lines = row.lines,
842 lines_fmt = format_number(row.lines),
843 code = row.code,
844 code_fmt = format_number(row.code),
845 tokens = row.tokens,
846 tokens_fmt = format_number(row.tokens),
847 bytes = row.bytes,
848 bytes_fmt = format_number(row.bytes),
849 ));
850 }
851 }
852
853 rows
854}
855
856fn build_report_json(receipt: &AnalysisReceipt) -> String {
857 let mut files = Vec::new();
859
860 if let Some(derived) = &receipt.derived {
861 for row in &derived.top.largest_lines {
862 files.push(serde_json::json!({
863 "path": row.path,
864 "module": row.module,
865 "lang": row.lang,
866 "code": row.code,
867 "lines": row.lines,
868 "tokens": row.tokens,
869 }));
870 }
871 }
872
873 serde_json::json!({ "files": files })
876 .to_string()
877 .replace('<', "\\u003c")
878 .replace('>', "\\u003e")
879}
880
881fn format_number(n: usize) -> String {
882 if n >= 1_000_000 {
883 format!("{:.1}M", n as f64 / 1_000_000.0)
884 } else if n >= 1_000 {
885 format!("{:.1}K", n as f64 / 1_000.0)
886 } else {
887 n.to_string()
888 }
889}
890
891fn html_escape(s: &str) -> String {
892 s.replace('&', "&")
893 .replace('<', "<")
894 .replace('>', ">")
895 .replace('"', """)
896 .replace('\'', "'")
897}
898
899#[cfg(test)]
900mod tests {
901 use super::*;
902 use tokmd_analysis_types::*;
903
904 fn minimal_receipt() -> AnalysisReceipt {
905 AnalysisReceipt {
906 schema_version: 2,
907 generated_at_ms: 0,
908 tool: tokmd_types::ToolInfo {
909 name: "tokmd".to_string(),
910 version: "0.0.0".to_string(),
911 },
912 mode: "analysis".to_string(),
913 status: tokmd_types::ScanStatus::Complete,
914 warnings: vec![],
915 source: AnalysisSource {
916 inputs: vec!["test".to_string()],
917 export_path: None,
918 base_receipt_path: None,
919 export_schema_version: None,
920 export_generated_at_ms: None,
921 base_signature: None,
922 module_roots: vec![],
923 module_depth: 1,
924 children: "collapse".to_string(),
925 },
926 args: AnalysisArgsMeta {
927 preset: "receipt".to_string(),
928 format: "md".to_string(),
929 window_tokens: None,
930 git: None,
931 max_files: None,
932 max_bytes: None,
933 max_commits: None,
934 max_commit_files: None,
935 max_file_bytes: None,
936 import_granularity: "module".to_string(),
937 },
938 archetype: None,
939 topics: None,
940 entropy: None,
941 predictive_churn: None,
942 corporate_fingerprint: None,
943 license: None,
944 derived: None,
945 assets: None,
946 deps: None,
947 git: None,
948 imports: None,
949 dup: None,
950 fun: None,
951 }
952 }
953
954 fn sample_derived() -> DerivedReport {
955 DerivedReport {
956 totals: DerivedTotals {
957 files: 10,
958 code: 1000,
959 comments: 200,
960 blanks: 100,
961 lines: 1300,
962 bytes: 50000,
963 tokens: 2500,
964 },
965 doc_density: RatioReport {
966 total: RatioRow {
967 key: "total".to_string(),
968 numerator: 200,
969 denominator: 1200,
970 ratio: 0.1667,
971 },
972 by_lang: vec![],
973 by_module: vec![],
974 },
975 whitespace: RatioReport {
976 total: RatioRow {
977 key: "total".to_string(),
978 numerator: 100,
979 denominator: 1300,
980 ratio: 0.0769,
981 },
982 by_lang: vec![],
983 by_module: vec![],
984 },
985 verbosity: RateReport {
986 total: RateRow {
987 key: "total".to_string(),
988 numerator: 50000,
989 denominator: 1300,
990 rate: 38.46,
991 },
992 by_lang: vec![],
993 by_module: vec![],
994 },
995 max_file: MaxFileReport {
996 overall: FileStatRow {
997 path: "src/lib.rs".to_string(),
998 module: "src".to_string(),
999 lang: "Rust".to_string(),
1000 code: 500,
1001 comments: 100,
1002 blanks: 50,
1003 lines: 650,
1004 bytes: 25000,
1005 tokens: 1250,
1006 doc_pct: Some(0.167),
1007 bytes_per_line: Some(38.46),
1008 depth: 1,
1009 },
1010 by_lang: vec![],
1011 by_module: vec![],
1012 },
1013 lang_purity: LangPurityReport { rows: vec![] },
1014 nesting: NestingReport {
1015 max: 3,
1016 avg: 1.5,
1017 by_module: vec![],
1018 },
1019 test_density: TestDensityReport {
1020 test_lines: 200,
1021 prod_lines: 1000,
1022 test_files: 5,
1023 prod_files: 5,
1024 ratio: 0.2,
1025 },
1026 boilerplate: BoilerplateReport {
1027 infra_lines: 100,
1028 logic_lines: 1100,
1029 ratio: 0.083,
1030 infra_langs: vec!["TOML".to_string()],
1031 },
1032 polyglot: PolyglotReport {
1033 lang_count: 2,
1034 entropy: 0.5,
1035 dominant_lang: "Rust".to_string(),
1036 dominant_lines: 1000,
1037 dominant_pct: 0.833,
1038 },
1039 distribution: DistributionReport {
1040 count: 10,
1041 min: 50,
1042 max: 650,
1043 mean: 130.0,
1044 median: 100.0,
1045 p90: 400.0,
1046 p99: 650.0,
1047 gini: 0.3,
1048 },
1049 histogram: vec![HistogramBucket {
1050 label: "Small".to_string(),
1051 min: 0,
1052 max: Some(100),
1053 files: 5,
1054 pct: 0.5,
1055 }],
1056 top: TopOffenders {
1057 largest_lines: vec![FileStatRow {
1058 path: "src/lib.rs".to_string(),
1059 module: "src".to_string(),
1060 lang: "Rust".to_string(),
1061 code: 500,
1062 comments: 100,
1063 blanks: 50,
1064 lines: 650,
1065 bytes: 25000,
1066 tokens: 1250,
1067 doc_pct: Some(0.167),
1068 bytes_per_line: Some(38.46),
1069 depth: 1,
1070 }],
1071 largest_tokens: vec![],
1072 largest_bytes: vec![],
1073 least_documented: vec![],
1074 most_dense: vec![],
1075 },
1076 tree: Some("test-tree".to_string()),
1077 reading_time: ReadingTimeReport {
1078 minutes: 65.0,
1079 lines_per_minute: 20,
1080 basis_lines: 1300,
1081 },
1082 context_window: Some(ContextWindowReport {
1083 window_tokens: 100000,
1084 total_tokens: 2500,
1085 pct: 0.025,
1086 fits: true,
1087 }),
1088 cocomo: Some(CocomoReport {
1089 mode: "organic".to_string(),
1090 kloc: 1.0,
1091 effort_pm: 2.4,
1092 duration_months: 2.5,
1093 staff: 1.0,
1094 a: 2.4,
1095 b: 1.05,
1096 c: 2.5,
1097 d: 0.38,
1098 }),
1099 todo: Some(TodoReport {
1100 total: 5,
1101 density_per_kloc: 5.0,
1102 tags: vec![TodoTagRow {
1103 tag: "TODO".to_string(),
1104 count: 5,
1105 }],
1106 }),
1107 integrity: IntegrityReport {
1108 algo: "blake3".to_string(),
1109 hash: "abc123".to_string(),
1110 entries: 10,
1111 },
1112 }
1113 }
1114
1115 #[test]
1117 fn test_fmt_pct() {
1118 assert_eq!(fmt_pct(0.5), "50.0%");
1119 assert_eq!(fmt_pct(0.0), "0.0%");
1120 assert_eq!(fmt_pct(1.0), "100.0%");
1121 assert_eq!(fmt_pct(0.1234), "12.3%");
1122 }
1123
1124 #[test]
1126 fn test_fmt_f64() {
1127 assert_eq!(fmt_f64(3.14159, 2), "3.14");
1128 assert_eq!(fmt_f64(3.14159, 4), "3.1416");
1129 assert_eq!(fmt_f64(0.0, 2), "0.00");
1130 assert_eq!(fmt_f64(100.0, 0), "100");
1131 }
1132
1133 #[test]
1135 fn test_format_number() {
1136 assert_eq!(format_number(500), "500");
1137 assert_eq!(format_number(1000), "1.0K");
1138 assert_eq!(format_number(1500), "1.5K");
1139 assert_eq!(format_number(1000000), "1.0M");
1140 assert_eq!(format_number(2500000), "2.5M");
1141 assert_eq!(format_number(999), "999");
1143 assert_eq!(format_number(999999), "1000.0K");
1144 }
1145
1146 #[test]
1148 fn test_html_escape() {
1149 assert_eq!(html_escape("hello"), "hello");
1150 assert_eq!(html_escape("<script>"), "<script>");
1151 assert_eq!(html_escape("a & b"), "a & b");
1152 assert_eq!(html_escape("\"quoted\""), ""quoted"");
1153 assert_eq!(html_escape("it's"), "it's");
1154 assert_eq!(
1156 html_escape("<a href=\"test\">&'"),
1157 "<a href="test">&'"
1158 );
1159 }
1160
1161 #[test]
1163 fn test_sanitize_mermaid() {
1164 assert_eq!(sanitize_mermaid("hello"), "hello");
1165 assert_eq!(sanitize_mermaid("hello-world"), "hello_world");
1166 assert_eq!(sanitize_mermaid("src/lib.rs"), "src_lib_rs");
1167 assert_eq!(sanitize_mermaid("test123"), "test123");
1168 assert_eq!(sanitize_mermaid("a b c"), "a_b_c");
1169 }
1170
1171 #[test]
1173 fn test_render_file_table() {
1174 let rows = vec![FileStatRow {
1175 path: "src/lib.rs".to_string(),
1176 module: "src".to_string(),
1177 lang: "Rust".to_string(),
1178 code: 100,
1179 comments: 20,
1180 blanks: 10,
1181 lines: 130,
1182 bytes: 5000,
1183 tokens: 250,
1184 doc_pct: Some(0.167),
1185 bytes_per_line: Some(38.46),
1186 depth: 1,
1187 }];
1188 let result = render_file_table(&rows);
1189 assert!(result.contains("|Path|Lang|Lines|Code|Bytes|Tokens|Doc%|B/Line|"));
1190 assert!(result.contains("|src/lib.rs|Rust|130|100|5000|250|16.7%|38.46|"));
1191 }
1192
1193 #[test]
1195 fn test_render_file_table_none_values() {
1196 let rows = vec![FileStatRow {
1197 path: "test.txt".to_string(),
1198 module: "root".to_string(),
1199 lang: "Text".to_string(),
1200 code: 50,
1201 comments: 0,
1202 blanks: 5,
1203 lines: 55,
1204 bytes: 1000,
1205 tokens: 100,
1206 doc_pct: None,
1207 bytes_per_line: None,
1208 depth: 0,
1209 }];
1210 let result = render_file_table(&rows);
1211 assert!(result.contains("|-|-|")); }
1213
1214 #[test]
1216 fn test_render_xml() {
1217 let mut receipt = minimal_receipt();
1218 receipt.derived = Some(sample_derived());
1219 let result = render_xml(&receipt);
1220 assert!(result.starts_with("<analysis>"));
1221 assert!(result.ends_with("</analysis>"));
1222 assert!(result.contains("files=\"10\""));
1223 assert!(result.contains("code=\"1000\""));
1224 }
1225
1226 #[test]
1228 fn test_render_xml_no_derived() {
1229 let receipt = minimal_receipt();
1230 let result = render_xml(&receipt);
1231 assert_eq!(result, "<analysis></analysis>");
1232 }
1233
1234 #[test]
1236 fn test_render_jsonld() {
1237 let mut receipt = minimal_receipt();
1238 receipt.derived = Some(sample_derived());
1239 let result = render_jsonld(&receipt);
1240 assert!(result.contains("\"@context\": \"https://schema.org\""));
1241 assert!(result.contains("\"@type\": \"SoftwareSourceCode\""));
1242 assert!(result.contains("\"name\": \"test\""));
1243 assert!(result.contains("\"codeLines\": 1000"));
1244 }
1245
1246 #[test]
1248 fn test_render_jsonld_empty_inputs() {
1249 let mut receipt = minimal_receipt();
1250 receipt.source.inputs.clear();
1251 let result = render_jsonld(&receipt);
1252 assert!(result.contains("\"name\": \"tokmd\""));
1253 }
1254
1255 #[test]
1257 fn test_render_svg() {
1258 let mut receipt = minimal_receipt();
1259 receipt.derived = Some(sample_derived());
1260 let result = render_svg(&receipt);
1261 assert!(result.contains("<svg"));
1262 assert!(result.contains("</svg>"));
1263 assert!(result.contains("context")); assert!(result.contains("2.5%")); }
1266
1267 #[test]
1269 fn test_render_svg_no_context() {
1270 let mut receipt = minimal_receipt();
1271 let mut derived = sample_derived();
1272 derived.context_window = None;
1273 receipt.derived = Some(derived);
1274 let result = render_svg(&receipt);
1275 assert!(result.contains("tokens"));
1276 assert!(result.contains("2500")); }
1278
1279 #[test]
1281 fn test_render_svg_no_derived() {
1282 let receipt = minimal_receipt();
1283 let result = render_svg(&receipt);
1284 assert!(result.contains("tokens"));
1285 assert!(result.contains(">0<")); }
1287
1288 #[test]
1290 fn test_render_svg_dimensions() {
1291 let receipt = minimal_receipt();
1292 let result = render_svg(&receipt);
1293 assert!(result.contains("width=\"160\"")); }
1296
1297 #[test]
1299 fn test_render_mermaid() {
1300 let mut receipt = minimal_receipt();
1301 receipt.imports = Some(ImportReport {
1302 granularity: "module".to_string(),
1303 edges: vec![ImportEdge {
1304 from: "src/main".to_string(),
1305 to: "src/lib".to_string(),
1306 count: 5,
1307 }],
1308 });
1309 let result = render_mermaid(&receipt);
1310 assert!(result.starts_with("graph TD\n"));
1311 assert!(result.contains("src_main -->|5| src_lib"));
1312 }
1313
1314 #[test]
1316 fn test_render_mermaid_no_imports() {
1317 let receipt = minimal_receipt();
1318 let result = render_mermaid(&receipt);
1319 assert_eq!(result, "graph TD\n");
1320 }
1321
1322 #[test]
1324 fn test_render_tree() {
1325 let mut receipt = minimal_receipt();
1326 receipt.derived = Some(sample_derived());
1327 let result = render_tree(&receipt);
1328 assert_eq!(result, "test-tree");
1329 }
1330
1331 #[test]
1333 fn test_render_tree_no_derived() {
1334 let receipt = minimal_receipt();
1335 let result = render_tree(&receipt);
1336 assert_eq!(result, "(tree unavailable)");
1337 }
1338
1339 #[test]
1341 fn test_render_tree_none() {
1342 let mut receipt = minimal_receipt();
1343 let mut derived = sample_derived();
1344 derived.tree = None;
1345 receipt.derived = Some(derived);
1346 let result = render_tree(&receipt);
1347 assert_eq!(result, "(tree unavailable)");
1348 }
1349
1350 #[cfg(not(feature = "fun"))]
1352 #[test]
1353 fn test_render_obj_no_fun() {
1354 let receipt = minimal_receipt();
1355 let result = render_obj(&receipt);
1356 assert!(result.is_err());
1357 assert!(result.unwrap_err().to_string().contains("fun"));
1358 }
1359
1360 #[cfg(not(feature = "fun"))]
1362 #[test]
1363 fn test_render_midi_no_fun() {
1364 let receipt = minimal_receipt();
1365 let result = render_midi(&receipt);
1366 assert!(result.is_err());
1367 assert!(result.unwrap_err().to_string().contains("fun"));
1368 }
1369
1370 #[cfg(feature = "fun")]
1377 #[test]
1378 fn test_render_obj_coordinate_math() {
1379 let mut receipt = minimal_receipt();
1380 let mut derived = sample_derived();
1381 derived.top.largest_lines = vec![
1391 FileStatRow {
1392 path: "file0.rs".to_string(),
1393 module: "src".to_string(),
1394 lang: "Rust".to_string(),
1395 code: 100,
1396 comments: 10,
1397 blanks: 5,
1398 lines: 100, bytes: 1000,
1400 tokens: 200,
1401 doc_pct: None,
1402 bytes_per_line: None,
1403 depth: 1,
1404 },
1405 FileStatRow {
1406 path: "file1.rs".to_string(),
1407 module: "src".to_string(),
1408 lang: "Rust".to_string(),
1409 code: 50,
1410 comments: 5,
1411 blanks: 2,
1412 lines: 3, bytes: 500,
1414 tokens: 100,
1415 doc_pct: None,
1416 bytes_per_line: None,
1417 depth: 2,
1418 },
1419 FileStatRow {
1420 path: "file2.rs".to_string(),
1421 module: "src".to_string(),
1422 lang: "Rust".to_string(),
1423 code: 200,
1424 comments: 20,
1425 blanks: 10,
1426 lines: 200, bytes: 2000,
1428 tokens: 400,
1429 doc_pct: None,
1430 bytes_per_line: None,
1431 depth: 3,
1432 },
1433 FileStatRow {
1434 path: "file3.rs".to_string(),
1435 module: "src".to_string(),
1436 lang: "Rust".to_string(),
1437 code: 75,
1438 comments: 7,
1439 blanks: 3,
1440 lines: 75, bytes: 750,
1442 tokens: 150,
1443 doc_pct: None,
1444 bytes_per_line: None,
1445 depth: 0,
1446 },
1447 FileStatRow {
1448 path: "file4.rs".to_string(),
1449 module: "src".to_string(),
1450 lang: "Rust".to_string(),
1451 code: 150,
1452 comments: 15,
1453 blanks: 8,
1454 lines: 150, bytes: 1500,
1456 tokens: 300,
1457 doc_pct: None,
1458 bytes_per_line: None,
1459 depth: 1,
1460 },
1461 FileStatRow {
1463 path: "file5.rs".to_string(),
1464 module: "src".to_string(),
1465 lang: "Rust".to_string(),
1466 code: 80,
1467 comments: 8,
1468 blanks: 4,
1469 lines: 80, bytes: 800,
1471 tokens: 160,
1472 doc_pct: None,
1473 bytes_per_line: None,
1474 depth: 2,
1475 },
1476 FileStatRow {
1478 path: "file6.rs".to_string(),
1479 module: "src".to_string(),
1480 lang: "Rust".to_string(),
1481 code: 60,
1482 comments: 6,
1483 blanks: 3,
1484 lines: 60, bytes: 600,
1486 tokens: 120,
1487 doc_pct: None,
1488 bytes_per_line: None,
1489 depth: 1,
1490 },
1491 ];
1492 receipt.derived = Some(derived);
1493 let result = render_obj(&receipt).expect("render_obj should succeed with fun feature");
1494
1495 let objects: Vec<(&str, Vec<(f32, f32, f32)>)> = result
1498 .split("o ")
1499 .skip(1)
1500 .map(|section| {
1501 let lines: Vec<&str> = section.lines().collect();
1502 let name = lines[0];
1503 let vertices: Vec<(f32, f32, f32)> = lines[1..]
1504 .iter()
1505 .filter(|l| l.starts_with("v "))
1506 .take(8)
1507 .map(|l| {
1508 let parts: Vec<f32> = l[2..]
1509 .split_whitespace()
1510 .map(|p| p.parse().unwrap())
1511 .collect();
1512 (parts[0], parts[1], parts[2])
1513 })
1514 .collect();
1515 (name, vertices)
1516 })
1517 .collect();
1518
1519 assert_eq!(objects.len(), 7, "expected 7 buildings");
1521
1522 fn base_corner(obj: &(&str, Vec<(f32, f32, f32)>)) -> (f32, f32, f32) {
1524 obj.1[0]
1525 }
1526 fn top_corner(obj: &(&str, Vec<(f32, f32, f32)>)) -> (f32, f32, f32) {
1527 obj.1[4] }
1529
1530 assert_eq!(
1532 base_corner(&objects[0]),
1533 (0.0, 0.0, 0.0),
1534 "file0 base position"
1535 );
1536 assert_eq!(
1537 top_corner(&objects[0]).2,
1538 10.0,
1539 "file0 height should be 10.0 (100/10)"
1540 );
1541
1542 assert_eq!(
1545 base_corner(&objects[1]),
1546 (2.0, 0.0, 0.0),
1547 "file1 base position"
1548 );
1549 assert_eq!(
1550 top_corner(&objects[1]).2,
1551 0.5,
1552 "file1 height should be 0.5 (clamped from 3/10=0.3)"
1553 );
1554
1555 assert_eq!(
1557 base_corner(&objects[2]),
1558 (4.0, 0.0, 0.0),
1559 "file2 base position"
1560 );
1561 assert_eq!(
1562 top_corner(&objects[2]).2,
1563 20.0,
1564 "file2 height should be 20.0 (200/10)"
1565 );
1566
1567 assert_eq!(
1569 base_corner(&objects[3]),
1570 (6.0, 0.0, 0.0),
1571 "file3 base position"
1572 );
1573 assert_eq!(
1574 top_corner(&objects[3]).2,
1575 7.5,
1576 "file3 height should be 7.5 (75/10)"
1577 );
1578
1579 assert_eq!(
1582 base_corner(&objects[4]),
1583 (8.0, 0.0, 0.0),
1584 "file4 base position (x = 4*2 = 8)"
1585 );
1586 assert_eq!(
1587 top_corner(&objects[4]).2,
1588 15.0,
1589 "file4 height should be 15.0 (150/10)"
1590 );
1591
1592 assert_eq!(
1596 base_corner(&objects[5]),
1597 (0.0, 2.0, 0.0),
1598 "file5 base position (x=0 from 5%5, y=2 from 5/5*2)"
1599 );
1600 assert_eq!(
1601 top_corner(&objects[5]).2,
1602 8.0,
1603 "file5 height should be 8.0 (80/10)"
1604 );
1605
1606 assert_eq!(
1609 base_corner(&objects[6]),
1610 (2.0, 2.0, 0.0),
1611 "file6 base position (x=2 from 6%5*2, y=2 from 6/5*2)"
1612 );
1613 assert_eq!(
1614 top_corner(&objects[6]).2,
1615 6.0,
1616 "file6 height should be 6.0 (60/10)"
1617 );
1618
1619 assert!(result.contains("f 1 2 3 4"), "missing face definition");
1621 }
1622
1623 #[cfg(feature = "fun")]
1629 #[test]
1630 fn test_render_midi_note_math() {
1631 use midly::{MidiMessage, Smf, TrackEventKind};
1632
1633 let mut receipt = minimal_receipt();
1634 let mut derived = sample_derived();
1635 derived.top.largest_lines = vec![
1641 FileStatRow {
1643 path: "a.rs".to_string(),
1644 module: "src".to_string(),
1645 lang: "Rust".to_string(),
1646 code: 50,
1647 comments: 5,
1648 blanks: 2,
1649 lines: 60,
1650 bytes: 500,
1651 tokens: 100,
1652 doc_pct: None,
1653 bytes_per_line: None,
1654 depth: 5,
1655 },
1656 FileStatRow {
1659 path: "b.rs".to_string(),
1660 module: "src".to_string(),
1661 lang: "Rust".to_string(),
1662 code: 100,
1663 comments: 10,
1664 blanks: 5,
1665 lines: 200, bytes: 1000,
1667 tokens: 200,
1668 doc_pct: None,
1669 bytes_per_line: None,
1670 depth: 15,
1671 },
1672 FileStatRow {
1674 path: "c.rs".to_string(),
1675 module: "src".to_string(),
1676 lang: "Rust".to_string(),
1677 code: 20,
1678 comments: 2,
1679 blanks: 1,
1680 lines: 20,
1681 bytes: 200,
1682 tokens: 40,
1683 doc_pct: None,
1684 bytes_per_line: None,
1685 depth: 0,
1686 },
1687 FileStatRow {
1690 path: "d.rs".to_string(),
1691 module: "src".to_string(),
1692 lang: "Rust".to_string(),
1693 code: 160,
1694 comments: 16,
1695 blanks: 8,
1696 lines: 160,
1697 bytes: 1600,
1698 tokens: 320,
1699 doc_pct: None,
1700 bytes_per_line: None,
1701 depth: 12,
1702 },
1703 ];
1704 receipt.derived = Some(derived);
1705
1706 let result = render_midi(&receipt).unwrap();
1707
1708 let smf = Smf::parse(&result).expect("should parse as valid MIDI");
1710
1711 let mut notes: Vec<(u32, u8, u8)> = Vec::new(); let mut abs_time = 0u32;
1714
1715 for event in &smf.tracks[0] {
1716 abs_time += event.delta.as_int();
1717 if let TrackEventKind::Midi {
1718 message: MidiMessage::NoteOn { key, vel },
1719 ..
1720 } = event.kind
1721 {
1722 notes.push((abs_time, key.as_int(), vel.as_int()));
1723 }
1724 }
1725
1726 assert_eq!(notes.len(), 4, "expected 4 NoteOn events, got {:?}", notes);
1728
1729 assert_eq!(
1732 notes[0],
1733 (0, 65, 70),
1734 "note 0: expected (time=0, key=65=60+5, vel=70=40+60/2), got {:?}",
1735 notes[0]
1736 );
1737
1738 assert_eq!(
1741 notes[1],
1742 (240, 63, 103),
1743 "note 1: expected (time=240=1*240, key=63=60+(15%12), vel=103=40+127/2), got {:?}",
1744 notes[1]
1745 );
1746
1747 assert_eq!(
1749 notes[2],
1750 (480, 60, 50),
1751 "note 2: expected (time=480=2*240, key=60=60+0, vel=50=40+20/2), got {:?}",
1752 notes[2]
1753 );
1754
1755 assert_eq!(
1758 notes[3],
1759 (720, 60, 103),
1760 "note 3: expected (time=720=3*240, key=60=60+(12%12), vel=103=40+127/2), got {:?}",
1761 notes[3]
1762 );
1763
1764 let mut note_offs: Vec<(u32, u8)> = Vec::new(); abs_time = 0;
1767 for event in &smf.tracks[0] {
1768 abs_time += event.delta.as_int();
1769 if let TrackEventKind::Midi {
1770 message: MidiMessage::NoteOff { key, .. },
1771 ..
1772 } = event.kind
1773 {
1774 note_offs.push((abs_time, key.as_int()));
1775 }
1776 }
1777
1778 assert!(
1780 note_offs.iter().any(|&(t, k)| t == 180 && k == 65),
1781 "expected NoteOff for key 65 at time 180, got {:?}",
1782 note_offs
1783 );
1784 assert!(
1785 note_offs.iter().any(|&(t, k)| t == 420 && k == 63),
1786 "expected NoteOff for key 63 at time 420 (240+180), got {:?}",
1787 note_offs
1788 );
1789 assert!(
1790 note_offs.iter().any(|&(t, k)| t == 660 && k == 60),
1791 "expected NoteOff for key 60 at time 660 (480+180), got {:?}",
1792 note_offs
1793 );
1794 assert!(
1795 note_offs.iter().any(|&(t, k)| t == 900 && k == 60),
1796 "expected NoteOff for key 60 at time 900 (720+180), got {:?}",
1797 note_offs
1798 );
1799 }
1800
1801 #[cfg(feature = "fun")]
1803 #[test]
1804 fn test_render_midi_no_derived() {
1805 use midly::Smf;
1806
1807 let receipt = minimal_receipt();
1808 let result = render_midi(&receipt).unwrap();
1809
1810 assert!(!result.is_empty(), "MIDI output should not be empty");
1812 assert!(
1813 result.len() > 14,
1814 "MIDI should have header (14 bytes) + track data"
1815 );
1816
1817 let smf = Smf::parse(&result).expect("should be valid MIDI even with no notes");
1819 assert_eq!(smf.tracks.len(), 1, "should have exactly one track");
1820 }
1821
1822 #[cfg(feature = "fun")]
1824 #[test]
1825 fn test_render_obj_no_derived() {
1826 let receipt = minimal_receipt();
1827 let result = render_obj(&receipt).expect("render_obj should succeed");
1828
1829 assert_eq!(result, "# tokmd code city\n");
1831 }
1832
1833 #[test]
1835 fn test_render_md_basic() {
1836 let receipt = minimal_receipt();
1837 let result = render_md(&receipt);
1838 assert!(result.starts_with("# tokmd analysis\n"));
1839 assert!(result.contains("Preset: `receipt`"));
1840 }
1841
1842 #[test]
1844 fn test_render_md_inputs() {
1845 let mut receipt = minimal_receipt();
1846 receipt.source.inputs = vec!["path1".to_string(), "path2".to_string()];
1847 let result = render_md(&receipt);
1848 assert!(result.contains("## Inputs"));
1849 assert!(result.contains("- `path1`"));
1850 assert!(result.contains("- `path2`"));
1851 }
1852
1853 #[test]
1855 fn test_render_md_empty_inputs() {
1856 let mut receipt = minimal_receipt();
1857 receipt.source.inputs.clear();
1858 let result = render_md(&receipt);
1859 assert!(!result.contains("## Inputs"));
1860 }
1861
1862 #[test]
1864 fn test_render_md_archetype() {
1865 let mut receipt = minimal_receipt();
1866 receipt.archetype = Some(Archetype {
1867 kind: "library".to_string(),
1868 evidence: vec!["Cargo.toml".to_string(), "src/lib.rs".to_string()],
1869 });
1870 let result = render_md(&receipt);
1871 assert!(result.contains("## Archetype"));
1872 assert!(result.contains("- Kind: `library`"));
1873 assert!(result.contains("- Evidence: `Cargo.toml`, `src/lib.rs`"));
1874 }
1875
1876 #[test]
1878 fn test_render_md_archetype_no_evidence() {
1879 let mut receipt = minimal_receipt();
1880 receipt.archetype = Some(Archetype {
1881 kind: "app".to_string(),
1882 evidence: vec![],
1883 });
1884 let result = render_md(&receipt);
1885 assert!(result.contains("## Archetype"));
1886 assert!(result.contains("- Kind: `app`"));
1887 assert!(!result.contains("Evidence"));
1888 }
1889
1890 #[test]
1892 fn test_render_md_topics() {
1893 use std::collections::BTreeMap;
1894 let mut per_module = BTreeMap::new();
1895 per_module.insert(
1896 "src".to_string(),
1897 vec![TopicTerm {
1898 term: "parser".to_string(),
1899 score: 1.5,
1900 tf: 10,
1901 df: 2,
1902 }],
1903 );
1904 let mut receipt = minimal_receipt();
1905 receipt.topics = Some(TopicClouds {
1906 overall: vec![TopicTerm {
1907 term: "code".to_string(),
1908 score: 2.0,
1909 tf: 20,
1910 df: 5,
1911 }],
1912 per_module,
1913 });
1914 let result = render_md(&receipt);
1915 assert!(result.contains("## Topics"));
1916 assert!(result.contains("- Overall: `code`"));
1917 assert!(result.contains("- `src`: parser"));
1918 }
1919
1920 #[test]
1922 fn test_render_md_topics_empty_module() {
1923 use std::collections::BTreeMap;
1924 let mut per_module = BTreeMap::new();
1925 per_module.insert("empty_module".to_string(), vec![]);
1926 let mut receipt = minimal_receipt();
1927 receipt.topics = Some(TopicClouds {
1928 overall: vec![],
1929 per_module,
1930 });
1931 let result = render_md(&receipt);
1932 assert!(!result.contains("empty_module"));
1934 }
1935
1936 #[test]
1938 fn test_render_md_entropy() {
1939 let mut receipt = minimal_receipt();
1940 receipt.entropy = Some(EntropyReport {
1941 suspects: vec![EntropyFinding {
1942 path: "secret.bin".to_string(),
1943 module: "root".to_string(),
1944 entropy_bits_per_byte: 7.5,
1945 sample_bytes: 1024,
1946 class: EntropyClass::High,
1947 }],
1948 });
1949 let result = render_md(&receipt);
1950 assert!(result.contains("## Entropy profiling"));
1951 assert!(result.contains("|secret.bin|root|7.50|1024|High|"));
1952 }
1953
1954 #[test]
1956 fn test_render_md_entropy_no_suspects() {
1957 let mut receipt = minimal_receipt();
1958 receipt.entropy = Some(EntropyReport { suspects: vec![] });
1959 let result = render_md(&receipt);
1960 assert!(result.contains("## Entropy profiling"));
1961 assert!(result.contains("No entropy outliers detected"));
1962 }
1963
1964 #[test]
1966 fn test_render_md_license() {
1967 let mut receipt = minimal_receipt();
1968 receipt.license = Some(LicenseReport {
1969 effective: Some("MIT".to_string()),
1970 findings: vec![LicenseFinding {
1971 spdx: "MIT".to_string(),
1972 confidence: 0.95,
1973 source_path: "LICENSE".to_string(),
1974 source_kind: LicenseSourceKind::Text,
1975 }],
1976 });
1977 let result = render_md(&receipt);
1978 assert!(result.contains("## License radar"));
1979 assert!(result.contains("- Effective: `MIT`"));
1980 assert!(result.contains("|MIT|0.95|LICENSE|Text|"));
1981 }
1982
1983 #[test]
1985 fn test_render_md_license_no_findings() {
1986 let mut receipt = minimal_receipt();
1987 receipt.license = Some(LicenseReport {
1988 effective: None,
1989 findings: vec![],
1990 });
1991 let result = render_md(&receipt);
1992 assert!(result.contains("## License radar"));
1993 assert!(result.contains("Heuristic detection"));
1994 assert!(!result.contains("|SPDX|")); }
1996
1997 #[test]
1999 fn test_render_md_corporate_fingerprint() {
2000 let mut receipt = minimal_receipt();
2001 receipt.corporate_fingerprint = Some(CorporateFingerprint {
2002 domains: vec![DomainStat {
2003 domain: "example.com".to_string(),
2004 commits: 50,
2005 pct: 0.75,
2006 }],
2007 });
2008 let result = render_md(&receipt);
2009 assert!(result.contains("## Corporate fingerprint"));
2010 assert!(result.contains("|example.com|50|75.0%|"));
2011 }
2012
2013 #[test]
2015 fn test_render_md_corporate_fingerprint_no_domains() {
2016 let mut receipt = minimal_receipt();
2017 receipt.corporate_fingerprint = Some(CorporateFingerprint { domains: vec![] });
2018 let result = render_md(&receipt);
2019 assert!(result.contains("## Corporate fingerprint"));
2020 assert!(result.contains("No commit domains detected"));
2021 }
2022
2023 #[test]
2025 fn test_render_md_churn() {
2026 use std::collections::BTreeMap;
2027 let mut per_module = BTreeMap::new();
2028 per_module.insert(
2029 "src".to_string(),
2030 ChurnTrend {
2031 slope: 0.5,
2032 r2: 0.8,
2033 recent_change: 5,
2034 classification: TrendClass::Rising,
2035 },
2036 );
2037 let mut receipt = minimal_receipt();
2038 receipt.predictive_churn = Some(PredictiveChurnReport { per_module });
2039 let result = render_md(&receipt);
2040 assert!(result.contains("## Predictive churn"));
2041 assert!(result.contains("|src|0.5000|0.80|5|Rising|"));
2042 }
2043
2044 #[test]
2046 fn test_render_md_churn_empty() {
2047 use std::collections::BTreeMap;
2048 let mut receipt = minimal_receipt();
2049 receipt.predictive_churn = Some(PredictiveChurnReport {
2050 per_module: BTreeMap::new(),
2051 });
2052 let result = render_md(&receipt);
2053 assert!(result.contains("## Predictive churn"));
2054 assert!(result.contains("No churn signals detected"));
2055 }
2056
2057 #[test]
2059 fn test_render_md_assets() {
2060 let mut receipt = minimal_receipt();
2061 receipt.assets = Some(AssetReport {
2062 total_files: 5,
2063 total_bytes: 1000000,
2064 categories: vec![AssetCategoryRow {
2065 category: "images".to_string(),
2066 files: 3,
2067 bytes: 500000,
2068 extensions: vec!["png".to_string(), "jpg".to_string()],
2069 }],
2070 top_files: vec![AssetFileRow {
2071 path: "logo.png".to_string(),
2072 bytes: 100000,
2073 category: "images".to_string(),
2074 extension: "png".to_string(),
2075 }],
2076 });
2077 let result = render_md(&receipt);
2078 assert!(result.contains("## Assets"));
2079 assert!(result.contains("- Total files: `5`"));
2080 assert!(result.contains("|images|3|500000|png, jpg|"));
2081 assert!(result.contains("|logo.png|100000|images|"));
2082 }
2083
2084 #[test]
2086 fn test_render_md_assets_empty() {
2087 let mut receipt = minimal_receipt();
2088 receipt.assets = Some(AssetReport {
2089 total_files: 0,
2090 total_bytes: 0,
2091 categories: vec![],
2092 top_files: vec![],
2093 });
2094 let result = render_md(&receipt);
2095 assert!(result.contains("## Assets"));
2096 assert!(result.contains("- Total files: `0`"));
2097 assert!(!result.contains("|Category|")); }
2099
2100 #[test]
2102 fn test_render_md_deps() {
2103 let mut receipt = minimal_receipt();
2104 receipt.deps = Some(DependencyReport {
2105 total: 50,
2106 lockfiles: vec![LockfileReport {
2107 path: "Cargo.lock".to_string(),
2108 kind: "cargo".to_string(),
2109 dependencies: 50,
2110 }],
2111 });
2112 let result = render_md(&receipt);
2113 assert!(result.contains("## Dependencies"));
2114 assert!(result.contains("- Total: `50`"));
2115 assert!(result.contains("|Cargo.lock|cargo|50|"));
2116 }
2117
2118 #[test]
2120 fn test_render_md_deps_empty() {
2121 let mut receipt = minimal_receipt();
2122 receipt.deps = Some(DependencyReport {
2123 total: 0,
2124 lockfiles: vec![],
2125 });
2126 let result = render_md(&receipt);
2127 assert!(result.contains("## Dependencies"));
2128 assert!(!result.contains("|Lockfile|"));
2129 }
2130
2131 #[test]
2133 fn test_render_md_git() {
2134 let mut receipt = minimal_receipt();
2135 receipt.git = Some(GitReport {
2136 commits_scanned: 100,
2137 files_seen: 50,
2138 hotspots: vec![HotspotRow {
2139 path: "src/lib.rs".to_string(),
2140 commits: 25,
2141 lines: 500,
2142 score: 12500,
2143 }],
2144 bus_factor: vec![BusFactorRow {
2145 module: "src".to_string(),
2146 authors: 3,
2147 }],
2148 freshness: FreshnessReport {
2149 threshold_days: 90,
2150 stale_files: 5,
2151 total_files: 50,
2152 stale_pct: 0.1,
2153 by_module: vec![ModuleFreshnessRow {
2154 module: "src".to_string(),
2155 avg_days: 30.0,
2156 p90_days: 60.0,
2157 stale_pct: 0.05,
2158 }],
2159 },
2160 coupling: vec![CouplingRow {
2161 left: "src/a.rs".to_string(),
2162 right: "src/b.rs".to_string(),
2163 count: 10,
2164 }],
2165 });
2166 let result = render_md(&receipt);
2167 assert!(result.contains("## Git metrics"));
2168 assert!(result.contains("- Commits scanned: `100`"));
2169 assert!(result.contains("|src/lib.rs|25|500|12500|"));
2170 assert!(result.contains("|src|3|"));
2171 assert!(result.contains("Stale threshold (days): `90`"));
2172 assert!(result.contains("|src|30.00|60.00|5.0%|"));
2173 assert!(result.contains("|src/a.rs|src/b.rs|10|"));
2174 }
2175
2176 #[test]
2178 fn test_render_md_git_empty() {
2179 let mut receipt = minimal_receipt();
2180 receipt.git = Some(GitReport {
2181 commits_scanned: 0,
2182 files_seen: 0,
2183 hotspots: vec![],
2184 bus_factor: vec![],
2185 freshness: FreshnessReport {
2186 threshold_days: 90,
2187 stale_files: 0,
2188 total_files: 0,
2189 stale_pct: 0.0,
2190 by_module: vec![],
2191 },
2192 coupling: vec![],
2193 });
2194 let result = render_md(&receipt);
2195 assert!(result.contains("## Git metrics"));
2196 assert!(!result.contains("### Hotspots"));
2197 assert!(!result.contains("### Bus factor"));
2198 assert!(!result.contains("### Coupling"));
2199 }
2200
2201 #[test]
2203 fn test_render_md_imports() {
2204 let mut receipt = minimal_receipt();
2205 receipt.imports = Some(ImportReport {
2206 granularity: "file".to_string(),
2207 edges: vec![ImportEdge {
2208 from: "src/main.rs".to_string(),
2209 to: "src/lib.rs".to_string(),
2210 count: 5,
2211 }],
2212 });
2213 let result = render_md(&receipt);
2214 assert!(result.contains("## Imports"));
2215 assert!(result.contains("- Granularity: `file`"));
2216 assert!(result.contains("|src/main.rs|src/lib.rs|5|"));
2217 }
2218
2219 #[test]
2221 fn test_render_md_imports_empty() {
2222 let mut receipt = minimal_receipt();
2223 receipt.imports = Some(ImportReport {
2224 granularity: "module".to_string(),
2225 edges: vec![],
2226 });
2227 let result = render_md(&receipt);
2228 assert!(result.contains("## Imports"));
2229 assert!(!result.contains("|From|To|"));
2230 }
2231
2232 #[test]
2234 fn test_render_md_dup() {
2235 let mut receipt = minimal_receipt();
2236 receipt.dup = Some(DuplicateReport {
2237 wasted_bytes: 50000,
2238 strategy: "content".to_string(),
2239 groups: vec![DuplicateGroup {
2240 hash: "abc123".to_string(),
2241 bytes: 1000,
2242 files: vec!["a.txt".to_string(), "b.txt".to_string()],
2243 }],
2244 });
2245 let result = render_md(&receipt);
2246 assert!(result.contains("## Duplicates"));
2247 assert!(result.contains("- Wasted bytes: `50000`"));
2248 assert!(result.contains("|abc123|1000|2|")); }
2250
2251 #[test]
2253 fn test_render_md_dup_empty() {
2254 let mut receipt = minimal_receipt();
2255 receipt.dup = Some(DuplicateReport {
2256 wasted_bytes: 0,
2257 strategy: "content".to_string(),
2258 groups: vec![],
2259 });
2260 let result = render_md(&receipt);
2261 assert!(result.contains("## Duplicates"));
2262 assert!(!result.contains("|Hash|Bytes|"));
2263 }
2264
2265 #[test]
2267 fn test_render_md_fun() {
2268 let mut receipt = minimal_receipt();
2269 receipt.fun = Some(FunReport {
2270 eco_label: Some(EcoLabel {
2271 label: "A+".to_string(),
2272 score: 95.5,
2273 bytes: 10000,
2274 notes: "Very efficient".to_string(),
2275 }),
2276 });
2277 let result = render_md(&receipt);
2278 assert!(result.contains("## Eco label"));
2279 assert!(result.contains("- Label: `A+`"));
2280 assert!(result.contains("- Score: `95.5`"));
2281 }
2282
2283 #[test]
2285 fn test_render_md_fun_no_label() {
2286 let mut receipt = minimal_receipt();
2287 receipt.fun = Some(FunReport { eco_label: None });
2288 let result = render_md(&receipt);
2289 assert!(!result.contains("## Eco label"));
2291 }
2292
2293 #[test]
2295 fn test_render_md_derived() {
2296 let mut receipt = minimal_receipt();
2297 receipt.derived = Some(sample_derived());
2298 let result = render_md(&receipt);
2299 assert!(result.contains("## Totals"));
2300 assert!(result.contains("|10|1000|200|100|1300|50000|2500|"));
2301 assert!(result.contains("## Ratios"));
2302 assert!(result.contains("## Distribution"));
2303 assert!(result.contains("## File size histogram"));
2304 assert!(result.contains("## Top offenders"));
2305 assert!(result.contains("## Structure"));
2306 assert!(result.contains("## Test density"));
2307 assert!(result.contains("## TODOs"));
2308 assert!(result.contains("## Boilerplate ratio"));
2309 assert!(result.contains("## Polyglot"));
2310 assert!(result.contains("## Reading time"));
2311 assert!(result.contains("## Context window"));
2312 assert!(result.contains("## COCOMO estimate"));
2313 assert!(result.contains("## Integrity"));
2314 }
2315
2316 #[test]
2318 fn test_render_dispatch_md() {
2319 let receipt = minimal_receipt();
2320 let result = render(&receipt, tokmd_config::AnalysisFormat::Md).unwrap();
2321 match result {
2322 RenderedOutput::Text(s) => assert!(s.starts_with("# tokmd analysis")),
2323 RenderedOutput::Binary(_) => panic!("expected text"),
2324 }
2325 }
2326
2327 #[test]
2328 fn test_render_dispatch_json() {
2329 let receipt = minimal_receipt();
2330 let result = render(&receipt, tokmd_config::AnalysisFormat::Json).unwrap();
2331 match result {
2332 RenderedOutput::Text(s) => assert!(s.contains("\"schema_version\": 2")),
2333 RenderedOutput::Binary(_) => panic!("expected text"),
2334 }
2335 }
2336
2337 #[test]
2338 fn test_render_dispatch_xml() {
2339 let receipt = minimal_receipt();
2340 let result = render(&receipt, tokmd_config::AnalysisFormat::Xml).unwrap();
2341 match result {
2342 RenderedOutput::Text(s) => assert!(s.contains("<analysis>")),
2343 RenderedOutput::Binary(_) => panic!("expected text"),
2344 }
2345 }
2346
2347 #[test]
2348 fn test_render_dispatch_tree() {
2349 let receipt = minimal_receipt();
2350 let result = render(&receipt, tokmd_config::AnalysisFormat::Tree).unwrap();
2351 match result {
2352 RenderedOutput::Text(s) => assert!(s.contains("(tree unavailable)")),
2353 RenderedOutput::Binary(_) => panic!("expected text"),
2354 }
2355 }
2356
2357 #[test]
2358 fn test_render_dispatch_svg() {
2359 let receipt = minimal_receipt();
2360 let result = render(&receipt, tokmd_config::AnalysisFormat::Svg).unwrap();
2361 match result {
2362 RenderedOutput::Text(s) => assert!(s.contains("<svg")),
2363 RenderedOutput::Binary(_) => panic!("expected text"),
2364 }
2365 }
2366
2367 #[test]
2368 fn test_render_dispatch_mermaid() {
2369 let receipt = minimal_receipt();
2370 let result = render(&receipt, tokmd_config::AnalysisFormat::Mermaid).unwrap();
2371 match result {
2372 RenderedOutput::Text(s) => assert!(s.starts_with("graph TD")),
2373 RenderedOutput::Binary(_) => panic!("expected text"),
2374 }
2375 }
2376
2377 #[test]
2378 fn test_render_dispatch_jsonld() {
2379 let receipt = minimal_receipt();
2380 let result = render(&receipt, tokmd_config::AnalysisFormat::Jsonld).unwrap();
2381 match result {
2382 RenderedOutput::Text(s) => assert!(s.contains("@context")),
2383 RenderedOutput::Binary(_) => panic!("expected text"),
2384 }
2385 }
2386
2387 #[test]
2389 fn test_chrono_lite_timestamp() {
2390 let ts = chrono_lite_timestamp();
2391 assert!(ts.contains("UTC"));
2393 assert!(ts.len() > 10); }
2395
2396 #[test]
2398 fn test_build_metrics_cards() {
2399 let mut receipt = minimal_receipt();
2400 receipt.derived = Some(sample_derived());
2401 let result = build_metrics_cards(&receipt);
2402 assert!(result.contains("class=\"metric-card\""));
2403 assert!(result.contains("Files"));
2404 assert!(result.contains("Lines"));
2405 assert!(result.contains("Code"));
2406 assert!(result.contains("Context Fit")); }
2408
2409 #[test]
2411 fn test_build_metrics_cards_no_derived() {
2412 let receipt = minimal_receipt();
2413 let result = build_metrics_cards(&receipt);
2414 assert!(result.is_empty());
2415 }
2416
2417 #[test]
2419 fn test_build_table_rows() {
2420 let mut receipt = minimal_receipt();
2421 receipt.derived = Some(sample_derived());
2422 let result = build_table_rows(&receipt);
2423 assert!(result.contains("<tr>"));
2424 assert!(result.contains("src/lib.rs"));
2425 }
2426
2427 #[test]
2429 fn test_build_table_rows_no_derived() {
2430 let receipt = minimal_receipt();
2431 let result = build_table_rows(&receipt);
2432 assert!(result.is_empty());
2433 }
2434
2435 #[test]
2437 fn test_build_report_json() {
2438 let mut receipt = minimal_receipt();
2439 receipt.derived = Some(sample_derived());
2440 let result = build_report_json(&receipt);
2441 assert!(result.contains("files"));
2442 assert!(result.contains("src/lib.rs"));
2443 assert!(!result.contains("<"));
2445 assert!(!result.contains(">"));
2446 }
2447
2448 #[test]
2450 fn test_build_report_json_no_derived() {
2451 let receipt = minimal_receipt();
2452 let result = build_report_json(&receipt);
2453 assert!(result.contains("\"files\":[]"));
2454 }
2455
2456 #[test]
2458 fn test_render_html() {
2459 let mut receipt = minimal_receipt();
2460 receipt.derived = Some(sample_derived());
2461 let result = render_html(&receipt);
2462 assert!(result.contains("<!DOCTYPE html>") || result.contains("<html"));
2463 }
2464}