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 let Some(age) = &git.age_distribution {
494 out.push_str("### Code age\n\n");
495 out.push_str(&format!(
496 "- Refresh trend: `{:?}` (recent: `{}`, prior: `{}`)\n\n",
497 age.refresh_trend, age.recent_refreshes, age.prior_refreshes
498 ));
499 if !age.buckets.is_empty() {
500 out.push_str("|Bucket|Min days|Max days|Files|Pct|\n");
501 out.push_str("|---|---:|---:|---:|---:|\n");
502 for bucket in &age.buckets {
503 let max = bucket
504 .max_days
505 .map(|v| v.to_string())
506 .unwrap_or_else(|| "∞".to_string());
507 out.push_str(&format!(
508 "|{}|{}|{}|{}|{}|\n",
509 bucket.label,
510 bucket.min_days,
511 max,
512 bucket.files,
513 fmt_pct(bucket.pct)
514 ));
515 }
516 out.push('\n');
517 }
518 }
519 if !git.coupling.is_empty() {
520 out.push_str("### Coupling\n\n");
521 out.push_str("|Left|Right|Count|\n");
522 out.push_str("|---|---|---:|\n");
523 for row in git.coupling.iter().take(10) {
524 out.push_str(&format!("|{}|{}|{}|\n", row.left, row.right, row.count));
525 }
526 out.push('\n');
527 }
528 }
529
530 if let Some(imports) = &receipt.imports {
531 out.push_str("## Imports\n\n");
532 out.push_str(&format!("- Granularity: `{}`\n\n", imports.granularity));
533 if !imports.edges.is_empty() {
534 out.push_str("|From|To|Count|\n");
535 out.push_str("|---|---|---:|\n");
536 for row in imports.edges.iter().take(20) {
537 out.push_str(&format!("|{}|{}|{}|\n", row.from, row.to, row.count));
538 }
539 out.push('\n');
540 }
541 }
542
543 if let Some(dup) = &receipt.dup {
544 out.push_str("## Duplicates\n\n");
545 out.push_str(&format!(
546 "- Wasted bytes: `{}`\n- Strategy: `{}`\n\n",
547 dup.wasted_bytes, dup.strategy
548 ));
549 if let Some(density) = &dup.density {
550 out.push_str("### Duplication density\n\n");
551 out.push_str(&format!(
552 "- Duplicate groups: `{}`\n- Duplicate files: `{}`\n- Duplicated bytes: `{}`\n- Waste vs codebase: `{}`\n\n",
553 density.duplicate_groups,
554 density.duplicate_files,
555 density.duplicated_bytes,
556 fmt_pct(density.wasted_pct_of_codebase)
557 ));
558 if !density.by_module.is_empty() {
559 out.push_str(
560 "|Module|Dup files|Wasted files|Dup bytes|Wasted bytes|Module bytes|Density|\n",
561 );
562 out.push_str("|---|---:|---:|---:|---:|---:|---:|\n");
563 for row in density.by_module.iter().take(10) {
564 out.push_str(&format!(
565 "|{}|{}|{}|{}|{}|{}|{}|\n",
566 row.module,
567 row.duplicate_files,
568 row.wasted_files,
569 row.duplicated_bytes,
570 row.wasted_bytes,
571 row.module_bytes,
572 fmt_pct(row.density)
573 ));
574 }
575 out.push('\n');
576 }
577 }
578 if !dup.groups.is_empty() {
579 out.push_str("|Hash|Bytes|Files|\n");
580 out.push_str("|---|---:|---:|\n");
581 for row in dup.groups.iter().take(10) {
582 out.push_str(&format!(
583 "|{}|{}|{}|\n",
584 row.hash,
585 row.bytes,
586 row.files.len()
587 ));
588 }
589 out.push('\n');
590 }
591 }
592
593 if let Some(fun) = &receipt.fun
594 && let Some(label) = &fun.eco_label
595 {
596 out.push_str("## Eco label\n\n");
597 out.push_str(&format!(
598 "- Label: `{}`\n- Score: `{}`\n- Bytes: `{}`\n- Notes: `{}`\n\n",
599 label.label,
600 fmt_f64(label.score, 1),
601 label.bytes,
602 label.notes
603 ));
604 }
605
606 out
607}
608
609fn render_file_table(rows: &[FileStatRow]) -> String {
610 let mut out = String::new();
611 out.push_str("|Path|Lang|Lines|Code|Bytes|Tokens|Doc%|B/Line|\n");
612 out.push_str("|---|---|---:|---:|---:|---:|---:|---:|\n");
613 for row in rows {
614 out.push_str(&format!(
615 "|{}|{}|{}|{}|{}|{}|{}|{}|\n",
616 row.path,
617 row.lang,
618 row.lines,
619 row.code,
620 row.bytes,
621 row.tokens,
622 row.doc_pct.map(fmt_pct).unwrap_or_else(|| "-".to_string()),
623 row.bytes_per_line
624 .map(|v| fmt_f64(v, 2))
625 .unwrap_or_else(|| "-".to_string())
626 ));
627 }
628 out
629}
630
631fn fmt_pct(ratio: f64) -> String {
632 format!("{:.1}%", ratio * 100.0)
633}
634
635fn fmt_f64(value: f64, decimals: usize) -> String {
636 format!("{value:.decimals$}")
637}
638
639fn render_jsonld(receipt: &AnalysisReceipt) -> String {
640 let name = receipt
641 .source
642 .inputs
643 .first()
644 .cloned()
645 .unwrap_or_else(|| "tokmd".to_string());
646 let totals = receipt.derived.as_ref().map(|d| &d.totals);
647 let payload = serde_json::json!({
648 "@context": "https://schema.org",
649 "@type": "SoftwareSourceCode",
650 "name": name,
651 "codeLines": totals.map(|t| t.code).unwrap_or(0),
652 "commentCount": totals.map(|t| t.comments).unwrap_or(0),
653 "lineCount": totals.map(|t| t.lines).unwrap_or(0),
654 "fileSize": totals.map(|t| t.bytes).unwrap_or(0),
655 "interactionStatistic": {
656 "@type": "InteractionCounter",
657 "interactionType": "http://schema.org/ReadAction",
658 "userInteractionCount": totals.map(|t| t.tokens).unwrap_or(0)
659 }
660 });
661 serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
662}
663
664fn render_xml(receipt: &AnalysisReceipt) -> String {
665 let totals = receipt.derived.as_ref().map(|d| &d.totals);
666 let mut out = String::new();
667 out.push_str("<analysis>");
668 if let Some(totals) = totals {
669 out.push_str(&format!(
670 "<totals files=\"{}\" code=\"{}\" comments=\"{}\" blanks=\"{}\" lines=\"{}\" bytes=\"{}\" tokens=\"{}\"/>",
671 totals.files,
672 totals.code,
673 totals.comments,
674 totals.blanks,
675 totals.lines,
676 totals.bytes,
677 totals.tokens
678 ));
679 }
680 out.push_str("</analysis>");
681 out
682}
683
684fn render_svg(receipt: &AnalysisReceipt) -> String {
685 let (label, value) = if let Some(derived) = &receipt.derived {
686 if let Some(ctx) = &derived.context_window {
687 ("context".to_string(), format!("{:.1}%", ctx.pct * 100.0))
688 } else {
689 ("tokens".to_string(), derived.totals.tokens.to_string())
690 }
691 } else {
692 ("tokens".to_string(), "0".to_string())
693 };
694
695 let width = 240;
696 let height = 32;
697 let label_width = 80;
698 let value_width = width - label_width;
699 format!(
700 "<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>",
701 width = width,
702 height = height,
703 label_width = label_width,
704 value_width = value_width,
705 lx = label_width / 2,
706 vx = label_width + value_width / 2,
707 ty = 20,
708 label = label,
709 value = value
710 )
711}
712
713fn render_mermaid(receipt: &AnalysisReceipt) -> String {
714 let mut out = String::from("graph TD\n");
715 if let Some(imports) = &receipt.imports {
716 for edge in imports.edges.iter().take(200) {
717 let from = sanitize_mermaid(&edge.from);
718 let to = sanitize_mermaid(&edge.to);
719 out.push_str(&format!(" {} -->|{}| {}\n", from, edge.count, to));
720 }
721 }
722 out
723}
724
725fn render_tree(receipt: &AnalysisReceipt) -> String {
726 receipt
727 .derived
728 .as_ref()
729 .and_then(|d| d.tree.clone())
730 .unwrap_or_else(|| "(tree unavailable)".to_string())
731}
732
733#[cfg(feature = "fun")]
735fn render_obj_fun(receipt: &AnalysisReceipt) -> Result<String> {
736 if let Some(derived) = &receipt.derived {
737 let buildings: Vec<tokmd_fun::ObjBuilding> = derived
738 .top
739 .largest_lines
740 .iter()
741 .enumerate()
742 .map(|(idx, row)| {
743 let x = (idx % 5) as f32 * 2.0;
744 let y = (idx / 5) as f32 * 2.0;
745 let h = (row.lines as f32 / 10.0).max(0.5);
746 tokmd_fun::ObjBuilding {
747 name: row.path.clone(),
748 x,
749 y,
750 w: 1.5,
751 d: 1.5,
752 h,
753 }
754 })
755 .collect();
756 return Ok(tokmd_fun::render_obj(&buildings));
757 }
758 Ok("# tokmd code city\n".to_string())
759}
760
761#[cfg(feature = "fun")]
762fn render_midi_fun(receipt: &AnalysisReceipt) -> Result<Vec<u8>> {
763 let mut notes = Vec::new();
764 if let Some(derived) = &receipt.derived {
765 for (idx, row) in derived.top.largest_lines.iter().enumerate() {
766 let key = 60u8 + (row.depth as u8 % 12);
767 let velocity = (40 + (row.lines.min(127) as u8 / 2)).min(120);
768 let start = (idx as u32) * 240;
769 notes.push(tokmd_fun::MidiNote {
770 key,
771 velocity,
772 start,
773 duration: 180,
774 channel: 0,
775 });
776 }
777 }
778 tokmd_fun::render_midi(¬es, 120)
779}
780
781#[cfg(not(feature = "fun"))]
783fn render_obj_disabled(_receipt: &AnalysisReceipt) -> Result<String> {
784 anyhow::bail!(
785 "OBJ format requires the `fun` feature: tokmd-analysis-format = {{ version = \"1.3\", features = [\"fun\"] }}"
786 )
787}
788
789#[cfg(not(feature = "fun"))]
790fn render_midi_disabled(_receipt: &AnalysisReceipt) -> Result<Vec<u8>> {
791 anyhow::bail!(
792 "MIDI format requires the `fun` feature: tokmd-analysis-format = {{ version = \"1.3\", features = [\"fun\"] }}"
793 )
794}
795
796fn render_obj(receipt: &AnalysisReceipt) -> Result<String> {
798 #[cfg(feature = "fun")]
799 {
800 render_obj_fun(receipt)
801 }
802 #[cfg(not(feature = "fun"))]
803 {
804 render_obj_disabled(receipt)
805 }
806}
807
808fn render_midi(receipt: &AnalysisReceipt) -> Result<Vec<u8>> {
809 #[cfg(feature = "fun")]
810 {
811 render_midi_fun(receipt)
812 }
813 #[cfg(not(feature = "fun"))]
814 {
815 render_midi_disabled(receipt)
816 }
817}
818
819fn sanitize_mermaid(name: &str) -> String {
820 name.chars()
821 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
822 .collect()
823}
824
825fn render_html(receipt: &AnalysisReceipt) -> String {
826 const TEMPLATE: &str = include_str!("templates/report.html");
827
828 let timestamp = chrono_lite_timestamp();
830
831 let metrics_cards = build_metrics_cards(receipt);
833
834 let table_rows = build_table_rows(receipt);
836
837 let report_json = build_report_json(receipt);
839
840 TEMPLATE
841 .replace("{{TIMESTAMP}}", ×tamp)
842 .replace("{{METRICS_CARDS}}", &metrics_cards)
843 .replace("{{TABLE_ROWS}}", &table_rows)
844 .replace("{{REPORT_JSON}}", &report_json)
845}
846
847fn chrono_lite_timestamp() -> String {
848 let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second] UTC");
849 OffsetDateTime::now_utc()
850 .format(&format)
851 .unwrap_or_else(|_| "1970-01-01 00:00:00 UTC".to_string())
852}
853
854fn build_metrics_cards(receipt: &AnalysisReceipt) -> String {
855 let mut cards = String::new();
856
857 if let Some(derived) = &receipt.derived {
858 let metrics = [
859 ("Files", derived.totals.files.to_string()),
860 ("Lines", format_number(derived.totals.lines)),
861 ("Code", format_number(derived.totals.code)),
862 ("Tokens", format_number(derived.totals.tokens)),
863 ("Doc%", fmt_pct(derived.doc_density.total.ratio)),
864 ];
865
866 for (label, value) in metrics {
867 cards.push_str(&format!(
868 r#"<div class="metric-card"><span class="value">{}</span><span class="label">{}</span></div>"#,
869 value, label
870 ));
871 }
872
873 if let Some(ctx) = &derived.context_window {
875 cards.push_str(&format!(
876 r#"<div class="metric-card"><span class="value">{}</span><span class="label">Context Fit</span></div>"#,
877 fmt_pct(ctx.pct)
878 ));
879 }
880 }
881
882 cards
883}
884
885fn build_table_rows(receipt: &AnalysisReceipt) -> String {
886 let mut rows = String::new();
887
888 if let Some(derived) = &receipt.derived {
889 for row in derived.top.largest_lines.iter().take(100) {
891 rows.push_str(&format!(
892 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>"#,
893 path = html_escape(&row.path),
894 module = html_escape(&row.module),
895 lang = html_escape(&row.lang),
896 lines = row.lines,
897 lines_fmt = format_number(row.lines),
898 code = row.code,
899 code_fmt = format_number(row.code),
900 tokens = row.tokens,
901 tokens_fmt = format_number(row.tokens),
902 bytes = row.bytes,
903 bytes_fmt = format_number(row.bytes),
904 ));
905 }
906 }
907
908 rows
909}
910
911fn build_report_json(receipt: &AnalysisReceipt) -> String {
912 let mut files = Vec::new();
914
915 if let Some(derived) = &receipt.derived {
916 for row in &derived.top.largest_lines {
917 files.push(serde_json::json!({
918 "path": row.path,
919 "module": row.module,
920 "lang": row.lang,
921 "code": row.code,
922 "lines": row.lines,
923 "tokens": row.tokens,
924 }));
925 }
926 }
927
928 serde_json::json!({ "files": files })
931 .to_string()
932 .replace('<', "\\u003c")
933 .replace('>', "\\u003e")
934}
935
936fn format_number(n: usize) -> String {
937 if n >= 1_000_000 {
938 format!("{:.1}M", n as f64 / 1_000_000.0)
939 } else if n >= 1_000 {
940 format!("{:.1}K", n as f64 / 1_000.0)
941 } else {
942 n.to_string()
943 }
944}
945
946fn html_escape(s: &str) -> String {
947 s.replace('&', "&")
948 .replace('<', "<")
949 .replace('>', ">")
950 .replace('"', """)
951 .replace('\'', "'")
952}
953
954#[cfg(test)]
955mod tests {
956 use super::*;
957 use tokmd_analysis_types::*;
958
959 fn minimal_receipt() -> AnalysisReceipt {
960 AnalysisReceipt {
961 schema_version: 2,
962 generated_at_ms: 0,
963 tool: tokmd_types::ToolInfo {
964 name: "tokmd".to_string(),
965 version: "0.0.0".to_string(),
966 },
967 mode: "analysis".to_string(),
968 status: tokmd_types::ScanStatus::Complete,
969 warnings: vec![],
970 source: AnalysisSource {
971 inputs: vec!["test".to_string()],
972 export_path: None,
973 base_receipt_path: None,
974 export_schema_version: None,
975 export_generated_at_ms: None,
976 base_signature: None,
977 module_roots: vec![],
978 module_depth: 1,
979 children: "collapse".to_string(),
980 },
981 args: AnalysisArgsMeta {
982 preset: "receipt".to_string(),
983 format: "md".to_string(),
984 window_tokens: None,
985 git: None,
986 max_files: None,
987 max_bytes: None,
988 max_commits: None,
989 max_commit_files: None,
990 max_file_bytes: None,
991 import_granularity: "module".to_string(),
992 },
993 archetype: None,
994 topics: None,
995 entropy: None,
996 predictive_churn: None,
997 corporate_fingerprint: None,
998 license: None,
999 derived: None,
1000 assets: None,
1001 deps: None,
1002 git: None,
1003 imports: None,
1004 dup: None,
1005 complexity: None,
1006 fun: None,
1007 }
1008 }
1009
1010 fn sample_derived() -> DerivedReport {
1011 DerivedReport {
1012 totals: DerivedTotals {
1013 files: 10,
1014 code: 1000,
1015 comments: 200,
1016 blanks: 100,
1017 lines: 1300,
1018 bytes: 50000,
1019 tokens: 2500,
1020 },
1021 doc_density: RatioReport {
1022 total: RatioRow {
1023 key: "total".to_string(),
1024 numerator: 200,
1025 denominator: 1200,
1026 ratio: 0.1667,
1027 },
1028 by_lang: vec![],
1029 by_module: vec![],
1030 },
1031 whitespace: RatioReport {
1032 total: RatioRow {
1033 key: "total".to_string(),
1034 numerator: 100,
1035 denominator: 1300,
1036 ratio: 0.0769,
1037 },
1038 by_lang: vec![],
1039 by_module: vec![],
1040 },
1041 verbosity: RateReport {
1042 total: RateRow {
1043 key: "total".to_string(),
1044 numerator: 50000,
1045 denominator: 1300,
1046 rate: 38.46,
1047 },
1048 by_lang: vec![],
1049 by_module: vec![],
1050 },
1051 max_file: MaxFileReport {
1052 overall: FileStatRow {
1053 path: "src/lib.rs".to_string(),
1054 module: "src".to_string(),
1055 lang: "Rust".to_string(),
1056 code: 500,
1057 comments: 100,
1058 blanks: 50,
1059 lines: 650,
1060 bytes: 25000,
1061 tokens: 1250,
1062 doc_pct: Some(0.167),
1063 bytes_per_line: Some(38.46),
1064 depth: 1,
1065 },
1066 by_lang: vec![],
1067 by_module: vec![],
1068 },
1069 lang_purity: LangPurityReport { rows: vec![] },
1070 nesting: NestingReport {
1071 max: 3,
1072 avg: 1.5,
1073 by_module: vec![],
1074 },
1075 test_density: TestDensityReport {
1076 test_lines: 200,
1077 prod_lines: 1000,
1078 test_files: 5,
1079 prod_files: 5,
1080 ratio: 0.2,
1081 },
1082 boilerplate: BoilerplateReport {
1083 infra_lines: 100,
1084 logic_lines: 1100,
1085 ratio: 0.083,
1086 infra_langs: vec!["TOML".to_string()],
1087 },
1088 polyglot: PolyglotReport {
1089 lang_count: 2,
1090 entropy: 0.5,
1091 dominant_lang: "Rust".to_string(),
1092 dominant_lines: 1000,
1093 dominant_pct: 0.833,
1094 },
1095 distribution: DistributionReport {
1096 count: 10,
1097 min: 50,
1098 max: 650,
1099 mean: 130.0,
1100 median: 100.0,
1101 p90: 400.0,
1102 p99: 650.0,
1103 gini: 0.3,
1104 },
1105 histogram: vec![HistogramBucket {
1106 label: "Small".to_string(),
1107 min: 0,
1108 max: Some(100),
1109 files: 5,
1110 pct: 0.5,
1111 }],
1112 top: TopOffenders {
1113 largest_lines: vec![FileStatRow {
1114 path: "src/lib.rs".to_string(),
1115 module: "src".to_string(),
1116 lang: "Rust".to_string(),
1117 code: 500,
1118 comments: 100,
1119 blanks: 50,
1120 lines: 650,
1121 bytes: 25000,
1122 tokens: 1250,
1123 doc_pct: Some(0.167),
1124 bytes_per_line: Some(38.46),
1125 depth: 1,
1126 }],
1127 largest_tokens: vec![],
1128 largest_bytes: vec![],
1129 least_documented: vec![],
1130 most_dense: vec![],
1131 },
1132 tree: Some("test-tree".to_string()),
1133 reading_time: ReadingTimeReport {
1134 minutes: 65.0,
1135 lines_per_minute: 20,
1136 basis_lines: 1300,
1137 },
1138 context_window: Some(ContextWindowReport {
1139 window_tokens: 100000,
1140 total_tokens: 2500,
1141 pct: 0.025,
1142 fits: true,
1143 }),
1144 cocomo: Some(CocomoReport {
1145 mode: "organic".to_string(),
1146 kloc: 1.0,
1147 effort_pm: 2.4,
1148 duration_months: 2.5,
1149 staff: 1.0,
1150 a: 2.4,
1151 b: 1.05,
1152 c: 2.5,
1153 d: 0.38,
1154 }),
1155 todo: Some(TodoReport {
1156 total: 5,
1157 density_per_kloc: 5.0,
1158 tags: vec![TodoTagRow {
1159 tag: "TODO".to_string(),
1160 count: 5,
1161 }],
1162 }),
1163 integrity: IntegrityReport {
1164 algo: "blake3".to_string(),
1165 hash: "abc123".to_string(),
1166 entries: 10,
1167 },
1168 }
1169 }
1170
1171 #[test]
1173 fn test_fmt_pct() {
1174 assert_eq!(fmt_pct(0.5), "50.0%");
1175 assert_eq!(fmt_pct(0.0), "0.0%");
1176 assert_eq!(fmt_pct(1.0), "100.0%");
1177 assert_eq!(fmt_pct(0.1234), "12.3%");
1178 }
1179
1180 #[test]
1182 #[allow(clippy::approx_constant)]
1183 fn test_fmt_f64() {
1184 assert_eq!(fmt_f64(3.14159, 2), "3.14");
1185 assert_eq!(fmt_f64(3.14159, 4), "3.1416");
1186 assert_eq!(fmt_f64(0.0, 2), "0.00");
1187 assert_eq!(fmt_f64(100.0, 0), "100");
1188 }
1189
1190 #[test]
1192 fn test_format_number() {
1193 assert_eq!(format_number(500), "500");
1194 assert_eq!(format_number(1000), "1.0K");
1195 assert_eq!(format_number(1500), "1.5K");
1196 assert_eq!(format_number(1000000), "1.0M");
1197 assert_eq!(format_number(2500000), "2.5M");
1198 assert_eq!(format_number(999), "999");
1200 assert_eq!(format_number(999999), "1000.0K");
1201 }
1202
1203 #[test]
1205 fn test_html_escape() {
1206 assert_eq!(html_escape("hello"), "hello");
1207 assert_eq!(html_escape("<script>"), "<script>");
1208 assert_eq!(html_escape("a & b"), "a & b");
1209 assert_eq!(html_escape("\"quoted\""), ""quoted"");
1210 assert_eq!(html_escape("it's"), "it's");
1211 assert_eq!(
1213 html_escape("<a href=\"test\">&'"),
1214 "<a href="test">&'"
1215 );
1216 }
1217
1218 #[test]
1220 fn test_sanitize_mermaid() {
1221 assert_eq!(sanitize_mermaid("hello"), "hello");
1222 assert_eq!(sanitize_mermaid("hello-world"), "hello_world");
1223 assert_eq!(sanitize_mermaid("src/lib.rs"), "src_lib_rs");
1224 assert_eq!(sanitize_mermaid("test123"), "test123");
1225 assert_eq!(sanitize_mermaid("a b c"), "a_b_c");
1226 }
1227
1228 #[test]
1230 fn test_render_file_table() {
1231 let rows = vec![FileStatRow {
1232 path: "src/lib.rs".to_string(),
1233 module: "src".to_string(),
1234 lang: "Rust".to_string(),
1235 code: 100,
1236 comments: 20,
1237 blanks: 10,
1238 lines: 130,
1239 bytes: 5000,
1240 tokens: 250,
1241 doc_pct: Some(0.167),
1242 bytes_per_line: Some(38.46),
1243 depth: 1,
1244 }];
1245 let result = render_file_table(&rows);
1246 assert!(result.contains("|Path|Lang|Lines|Code|Bytes|Tokens|Doc%|B/Line|"));
1247 assert!(result.contains("|src/lib.rs|Rust|130|100|5000|250|16.7%|38.46|"));
1248 }
1249
1250 #[test]
1252 fn test_render_file_table_none_values() {
1253 let rows = vec![FileStatRow {
1254 path: "test.txt".to_string(),
1255 module: "root".to_string(),
1256 lang: "Text".to_string(),
1257 code: 50,
1258 comments: 0,
1259 blanks: 5,
1260 lines: 55,
1261 bytes: 1000,
1262 tokens: 100,
1263 doc_pct: None,
1264 bytes_per_line: None,
1265 depth: 0,
1266 }];
1267 let result = render_file_table(&rows);
1268 assert!(result.contains("|-|-|")); }
1270
1271 #[test]
1273 fn test_render_xml() {
1274 let mut receipt = minimal_receipt();
1275 receipt.derived = Some(sample_derived());
1276 let result = render_xml(&receipt);
1277 assert!(result.starts_with("<analysis>"));
1278 assert!(result.ends_with("</analysis>"));
1279 assert!(result.contains("files=\"10\""));
1280 assert!(result.contains("code=\"1000\""));
1281 }
1282
1283 #[test]
1285 fn test_render_xml_no_derived() {
1286 let receipt = minimal_receipt();
1287 let result = render_xml(&receipt);
1288 assert_eq!(result, "<analysis></analysis>");
1289 }
1290
1291 #[test]
1293 fn test_render_jsonld() {
1294 let mut receipt = minimal_receipt();
1295 receipt.derived = Some(sample_derived());
1296 let result = render_jsonld(&receipt);
1297 assert!(result.contains("\"@context\": \"https://schema.org\""));
1298 assert!(result.contains("\"@type\": \"SoftwareSourceCode\""));
1299 assert!(result.contains("\"name\": \"test\""));
1300 assert!(result.contains("\"codeLines\": 1000"));
1301 }
1302
1303 #[test]
1305 fn test_render_jsonld_empty_inputs() {
1306 let mut receipt = minimal_receipt();
1307 receipt.source.inputs.clear();
1308 let result = render_jsonld(&receipt);
1309 assert!(result.contains("\"name\": \"tokmd\""));
1310 }
1311
1312 #[test]
1314 fn test_render_svg() {
1315 let mut receipt = minimal_receipt();
1316 receipt.derived = Some(sample_derived());
1317 let result = render_svg(&receipt);
1318 assert!(result.contains("<svg"));
1319 assert!(result.contains("</svg>"));
1320 assert!(result.contains("context")); assert!(result.contains("2.5%")); }
1323
1324 #[test]
1326 fn test_render_svg_no_context() {
1327 let mut receipt = minimal_receipt();
1328 let mut derived = sample_derived();
1329 derived.context_window = None;
1330 receipt.derived = Some(derived);
1331 let result = render_svg(&receipt);
1332 assert!(result.contains("tokens"));
1333 assert!(result.contains("2500")); }
1335
1336 #[test]
1338 fn test_render_svg_no_derived() {
1339 let receipt = minimal_receipt();
1340 let result = render_svg(&receipt);
1341 assert!(result.contains("tokens"));
1342 assert!(result.contains(">0<")); }
1344
1345 #[test]
1347 fn test_render_svg_dimensions() {
1348 let receipt = minimal_receipt();
1349 let result = render_svg(&receipt);
1350 assert!(result.contains("width=\"160\"")); }
1353
1354 #[test]
1356 fn test_render_mermaid() {
1357 let mut receipt = minimal_receipt();
1358 receipt.imports = Some(ImportReport {
1359 granularity: "module".to_string(),
1360 edges: vec![ImportEdge {
1361 from: "src/main".to_string(),
1362 to: "src/lib".to_string(),
1363 count: 5,
1364 }],
1365 });
1366 let result = render_mermaid(&receipt);
1367 assert!(result.starts_with("graph TD\n"));
1368 assert!(result.contains("src_main -->|5| src_lib"));
1369 }
1370
1371 #[test]
1373 fn test_render_mermaid_no_imports() {
1374 let receipt = minimal_receipt();
1375 let result = render_mermaid(&receipt);
1376 assert_eq!(result, "graph TD\n");
1377 }
1378
1379 #[test]
1381 fn test_render_tree() {
1382 let mut receipt = minimal_receipt();
1383 receipt.derived = Some(sample_derived());
1384 let result = render_tree(&receipt);
1385 assert_eq!(result, "test-tree");
1386 }
1387
1388 #[test]
1390 fn test_render_tree_no_derived() {
1391 let receipt = minimal_receipt();
1392 let result = render_tree(&receipt);
1393 assert_eq!(result, "(tree unavailable)");
1394 }
1395
1396 #[test]
1398 fn test_render_tree_none() {
1399 let mut receipt = minimal_receipt();
1400 let mut derived = sample_derived();
1401 derived.tree = None;
1402 receipt.derived = Some(derived);
1403 let result = render_tree(&receipt);
1404 assert_eq!(result, "(tree unavailable)");
1405 }
1406
1407 #[cfg(not(feature = "fun"))]
1409 #[test]
1410 fn test_render_obj_no_fun() {
1411 let receipt = minimal_receipt();
1412 let result = render_obj(&receipt);
1413 assert!(result.is_err());
1414 assert!(result.unwrap_err().to_string().contains("fun"));
1415 }
1416
1417 #[cfg(not(feature = "fun"))]
1419 #[test]
1420 fn test_render_midi_no_fun() {
1421 let receipt = minimal_receipt();
1422 let result = render_midi(&receipt);
1423 assert!(result.is_err());
1424 assert!(result.unwrap_err().to_string().contains("fun"));
1425 }
1426
1427 #[cfg(feature = "fun")]
1434 #[test]
1435 fn test_render_obj_coordinate_math() {
1436 let mut receipt = minimal_receipt();
1437 let mut derived = sample_derived();
1438 derived.top.largest_lines = vec![
1448 FileStatRow {
1449 path: "file0.rs".to_string(),
1450 module: "src".to_string(),
1451 lang: "Rust".to_string(),
1452 code: 100,
1453 comments: 10,
1454 blanks: 5,
1455 lines: 100, bytes: 1000,
1457 tokens: 200,
1458 doc_pct: None,
1459 bytes_per_line: None,
1460 depth: 1,
1461 },
1462 FileStatRow {
1463 path: "file1.rs".to_string(),
1464 module: "src".to_string(),
1465 lang: "Rust".to_string(),
1466 code: 50,
1467 comments: 5,
1468 blanks: 2,
1469 lines: 3, bytes: 500,
1471 tokens: 100,
1472 doc_pct: None,
1473 bytes_per_line: None,
1474 depth: 2,
1475 },
1476 FileStatRow {
1477 path: "file2.rs".to_string(),
1478 module: "src".to_string(),
1479 lang: "Rust".to_string(),
1480 code: 200,
1481 comments: 20,
1482 blanks: 10,
1483 lines: 200, bytes: 2000,
1485 tokens: 400,
1486 doc_pct: None,
1487 bytes_per_line: None,
1488 depth: 3,
1489 },
1490 FileStatRow {
1491 path: "file3.rs".to_string(),
1492 module: "src".to_string(),
1493 lang: "Rust".to_string(),
1494 code: 75,
1495 comments: 7,
1496 blanks: 3,
1497 lines: 75, bytes: 750,
1499 tokens: 150,
1500 doc_pct: None,
1501 bytes_per_line: None,
1502 depth: 0,
1503 },
1504 FileStatRow {
1505 path: "file4.rs".to_string(),
1506 module: "src".to_string(),
1507 lang: "Rust".to_string(),
1508 code: 150,
1509 comments: 15,
1510 blanks: 8,
1511 lines: 150, bytes: 1500,
1513 tokens: 300,
1514 doc_pct: None,
1515 bytes_per_line: None,
1516 depth: 1,
1517 },
1518 FileStatRow {
1520 path: "file5.rs".to_string(),
1521 module: "src".to_string(),
1522 lang: "Rust".to_string(),
1523 code: 80,
1524 comments: 8,
1525 blanks: 4,
1526 lines: 80, bytes: 800,
1528 tokens: 160,
1529 doc_pct: None,
1530 bytes_per_line: None,
1531 depth: 2,
1532 },
1533 FileStatRow {
1535 path: "file6.rs".to_string(),
1536 module: "src".to_string(),
1537 lang: "Rust".to_string(),
1538 code: 60,
1539 comments: 6,
1540 blanks: 3,
1541 lines: 60, bytes: 600,
1543 tokens: 120,
1544 doc_pct: None,
1545 bytes_per_line: None,
1546 depth: 1,
1547 },
1548 ];
1549 receipt.derived = Some(derived);
1550 let result = render_obj(&receipt).expect("render_obj should succeed with fun feature");
1551
1552 #[allow(clippy::type_complexity)]
1555 let objects: Vec<(&str, Vec<(f32, f32, f32)>)> = result
1556 .split("o ")
1557 .skip(1)
1558 .map(|section| {
1559 let lines: Vec<&str> = section.lines().collect();
1560 let name = lines[0];
1561 let vertices: Vec<(f32, f32, f32)> = lines[1..]
1562 .iter()
1563 .filter(|l| l.starts_with("v "))
1564 .take(8)
1565 .map(|l| {
1566 let parts: Vec<f32> = l[2..]
1567 .split_whitespace()
1568 .map(|p| p.parse().unwrap())
1569 .collect();
1570 (parts[0], parts[1], parts[2])
1571 })
1572 .collect();
1573 (name, vertices)
1574 })
1575 .collect();
1576
1577 assert_eq!(objects.len(), 7, "expected 7 buildings");
1579
1580 fn base_corner(obj: &(&str, Vec<(f32, f32, f32)>)) -> (f32, f32, f32) {
1582 obj.1[0]
1583 }
1584 fn top_corner(obj: &(&str, Vec<(f32, f32, f32)>)) -> (f32, f32, f32) {
1585 obj.1[4] }
1587
1588 assert_eq!(
1590 base_corner(&objects[0]),
1591 (0.0, 0.0, 0.0),
1592 "file0 base position"
1593 );
1594 assert_eq!(
1595 top_corner(&objects[0]).2,
1596 10.0,
1597 "file0 height should be 10.0 (100/10)"
1598 );
1599
1600 assert_eq!(
1603 base_corner(&objects[1]),
1604 (2.0, 0.0, 0.0),
1605 "file1 base position"
1606 );
1607 assert_eq!(
1608 top_corner(&objects[1]).2,
1609 0.5,
1610 "file1 height should be 0.5 (clamped from 3/10=0.3)"
1611 );
1612
1613 assert_eq!(
1615 base_corner(&objects[2]),
1616 (4.0, 0.0, 0.0),
1617 "file2 base position"
1618 );
1619 assert_eq!(
1620 top_corner(&objects[2]).2,
1621 20.0,
1622 "file2 height should be 20.0 (200/10)"
1623 );
1624
1625 assert_eq!(
1627 base_corner(&objects[3]),
1628 (6.0, 0.0, 0.0),
1629 "file3 base position"
1630 );
1631 assert_eq!(
1632 top_corner(&objects[3]).2,
1633 7.5,
1634 "file3 height should be 7.5 (75/10)"
1635 );
1636
1637 assert_eq!(
1640 base_corner(&objects[4]),
1641 (8.0, 0.0, 0.0),
1642 "file4 base position (x = 4*2 = 8)"
1643 );
1644 assert_eq!(
1645 top_corner(&objects[4]).2,
1646 15.0,
1647 "file4 height should be 15.0 (150/10)"
1648 );
1649
1650 assert_eq!(
1654 base_corner(&objects[5]),
1655 (0.0, 2.0, 0.0),
1656 "file5 base position (x=0 from 5%5, y=2 from 5/5*2)"
1657 );
1658 assert_eq!(
1659 top_corner(&objects[5]).2,
1660 8.0,
1661 "file5 height should be 8.0 (80/10)"
1662 );
1663
1664 assert_eq!(
1667 base_corner(&objects[6]),
1668 (2.0, 2.0, 0.0),
1669 "file6 base position (x=2 from 6%5*2, y=2 from 6/5*2)"
1670 );
1671 assert_eq!(
1672 top_corner(&objects[6]).2,
1673 6.0,
1674 "file6 height should be 6.0 (60/10)"
1675 );
1676
1677 assert!(result.contains("f 1 2 3 4"), "missing face definition");
1679 }
1680
1681 #[cfg(feature = "fun")]
1687 #[test]
1688 fn test_render_midi_note_math() {
1689 use midly::{MidiMessage, Smf, TrackEventKind};
1690
1691 let mut receipt = minimal_receipt();
1692 let mut derived = sample_derived();
1693 derived.top.largest_lines = vec![
1699 FileStatRow {
1701 path: "a.rs".to_string(),
1702 module: "src".to_string(),
1703 lang: "Rust".to_string(),
1704 code: 50,
1705 comments: 5,
1706 blanks: 2,
1707 lines: 60,
1708 bytes: 500,
1709 tokens: 100,
1710 doc_pct: None,
1711 bytes_per_line: None,
1712 depth: 5,
1713 },
1714 FileStatRow {
1717 path: "b.rs".to_string(),
1718 module: "src".to_string(),
1719 lang: "Rust".to_string(),
1720 code: 100,
1721 comments: 10,
1722 blanks: 5,
1723 lines: 200, bytes: 1000,
1725 tokens: 200,
1726 doc_pct: None,
1727 bytes_per_line: None,
1728 depth: 15,
1729 },
1730 FileStatRow {
1732 path: "c.rs".to_string(),
1733 module: "src".to_string(),
1734 lang: "Rust".to_string(),
1735 code: 20,
1736 comments: 2,
1737 blanks: 1,
1738 lines: 20,
1739 bytes: 200,
1740 tokens: 40,
1741 doc_pct: None,
1742 bytes_per_line: None,
1743 depth: 0,
1744 },
1745 FileStatRow {
1748 path: "d.rs".to_string(),
1749 module: "src".to_string(),
1750 lang: "Rust".to_string(),
1751 code: 160,
1752 comments: 16,
1753 blanks: 8,
1754 lines: 160,
1755 bytes: 1600,
1756 tokens: 320,
1757 doc_pct: None,
1758 bytes_per_line: None,
1759 depth: 12,
1760 },
1761 ];
1762 receipt.derived = Some(derived);
1763
1764 let result = render_midi(&receipt).unwrap();
1765
1766 let smf = Smf::parse(&result).expect("should parse as valid MIDI");
1768
1769 let mut notes: Vec<(u32, u8, u8)> = Vec::new(); let mut abs_time = 0u32;
1772
1773 for event in &smf.tracks[0] {
1774 abs_time += event.delta.as_int();
1775 if let TrackEventKind::Midi {
1776 message: MidiMessage::NoteOn { key, vel },
1777 ..
1778 } = event.kind
1779 {
1780 notes.push((abs_time, key.as_int(), vel.as_int()));
1781 }
1782 }
1783
1784 assert_eq!(notes.len(), 4, "expected 4 NoteOn events, got {:?}", notes);
1786
1787 assert_eq!(
1790 notes[0],
1791 (0, 65, 70),
1792 "note 0: expected (time=0, key=65=60+5, vel=70=40+60/2), got {:?}",
1793 notes[0]
1794 );
1795
1796 assert_eq!(
1799 notes[1],
1800 (240, 63, 103),
1801 "note 1: expected (time=240=1*240, key=63=60+(15%12), vel=103=40+127/2), got {:?}",
1802 notes[1]
1803 );
1804
1805 assert_eq!(
1807 notes[2],
1808 (480, 60, 50),
1809 "note 2: expected (time=480=2*240, key=60=60+0, vel=50=40+20/2), got {:?}",
1810 notes[2]
1811 );
1812
1813 assert_eq!(
1816 notes[3],
1817 (720, 60, 103),
1818 "note 3: expected (time=720=3*240, key=60=60+(12%12), vel=103=40+127/2), got {:?}",
1819 notes[3]
1820 );
1821
1822 let mut note_offs: Vec<(u32, u8)> = Vec::new(); abs_time = 0;
1825 for event in &smf.tracks[0] {
1826 abs_time += event.delta.as_int();
1827 if let TrackEventKind::Midi {
1828 message: MidiMessage::NoteOff { key, .. },
1829 ..
1830 } = event.kind
1831 {
1832 note_offs.push((abs_time, key.as_int()));
1833 }
1834 }
1835
1836 assert!(
1838 note_offs.iter().any(|&(t, k)| t == 180 && k == 65),
1839 "expected NoteOff for key 65 at time 180, got {:?}",
1840 note_offs
1841 );
1842 assert!(
1843 note_offs.iter().any(|&(t, k)| t == 420 && k == 63),
1844 "expected NoteOff for key 63 at time 420 (240+180), got {:?}",
1845 note_offs
1846 );
1847 assert!(
1848 note_offs.iter().any(|&(t, k)| t == 660 && k == 60),
1849 "expected NoteOff for key 60 at time 660 (480+180), got {:?}",
1850 note_offs
1851 );
1852 assert!(
1853 note_offs.iter().any(|&(t, k)| t == 900 && k == 60),
1854 "expected NoteOff for key 60 at time 900 (720+180), got {:?}",
1855 note_offs
1856 );
1857 }
1858
1859 #[cfg(feature = "fun")]
1861 #[test]
1862 fn test_render_midi_no_derived() {
1863 use midly::Smf;
1864
1865 let receipt = minimal_receipt();
1866 let result = render_midi(&receipt).unwrap();
1867
1868 assert!(!result.is_empty(), "MIDI output should not be empty");
1870 assert!(
1871 result.len() > 14,
1872 "MIDI should have header (14 bytes) + track data"
1873 );
1874
1875 let smf = Smf::parse(&result).expect("should be valid MIDI even with no notes");
1877 assert_eq!(smf.tracks.len(), 1, "should have exactly one track");
1878 }
1879
1880 #[cfg(feature = "fun")]
1882 #[test]
1883 fn test_render_obj_no_derived() {
1884 let receipt = minimal_receipt();
1885 let result = render_obj(&receipt).expect("render_obj should succeed");
1886
1887 assert_eq!(result, "# tokmd code city\n");
1889 }
1890
1891 #[test]
1893 fn test_render_md_basic() {
1894 let receipt = minimal_receipt();
1895 let result = render_md(&receipt);
1896 assert!(result.starts_with("# tokmd analysis\n"));
1897 assert!(result.contains("Preset: `receipt`"));
1898 }
1899
1900 #[test]
1902 fn test_render_md_inputs() {
1903 let mut receipt = minimal_receipt();
1904 receipt.source.inputs = vec!["path1".to_string(), "path2".to_string()];
1905 let result = render_md(&receipt);
1906 assert!(result.contains("## Inputs"));
1907 assert!(result.contains("- `path1`"));
1908 assert!(result.contains("- `path2`"));
1909 }
1910
1911 #[test]
1913 fn test_render_md_empty_inputs() {
1914 let mut receipt = minimal_receipt();
1915 receipt.source.inputs.clear();
1916 let result = render_md(&receipt);
1917 assert!(!result.contains("## Inputs"));
1918 }
1919
1920 #[test]
1922 fn test_render_md_archetype() {
1923 let mut receipt = minimal_receipt();
1924 receipt.archetype = Some(Archetype {
1925 kind: "library".to_string(),
1926 evidence: vec!["Cargo.toml".to_string(), "src/lib.rs".to_string()],
1927 });
1928 let result = render_md(&receipt);
1929 assert!(result.contains("## Archetype"));
1930 assert!(result.contains("- Kind: `library`"));
1931 assert!(result.contains("- Evidence: `Cargo.toml`, `src/lib.rs`"));
1932 }
1933
1934 #[test]
1936 fn test_render_md_archetype_no_evidence() {
1937 let mut receipt = minimal_receipt();
1938 receipt.archetype = Some(Archetype {
1939 kind: "app".to_string(),
1940 evidence: vec![],
1941 });
1942 let result = render_md(&receipt);
1943 assert!(result.contains("## Archetype"));
1944 assert!(result.contains("- Kind: `app`"));
1945 assert!(!result.contains("Evidence"));
1946 }
1947
1948 #[test]
1950 fn test_render_md_topics() {
1951 use std::collections::BTreeMap;
1952 let mut per_module = BTreeMap::new();
1953 per_module.insert(
1954 "src".to_string(),
1955 vec![TopicTerm {
1956 term: "parser".to_string(),
1957 score: 1.5,
1958 tf: 10,
1959 df: 2,
1960 }],
1961 );
1962 let mut receipt = minimal_receipt();
1963 receipt.topics = Some(TopicClouds {
1964 overall: vec![TopicTerm {
1965 term: "code".to_string(),
1966 score: 2.0,
1967 tf: 20,
1968 df: 5,
1969 }],
1970 per_module,
1971 });
1972 let result = render_md(&receipt);
1973 assert!(result.contains("## Topics"));
1974 assert!(result.contains("- Overall: `code`"));
1975 assert!(result.contains("- `src`: parser"));
1976 }
1977
1978 #[test]
1980 fn test_render_md_topics_empty_module() {
1981 use std::collections::BTreeMap;
1982 let mut per_module = BTreeMap::new();
1983 per_module.insert("empty_module".to_string(), vec![]);
1984 let mut receipt = minimal_receipt();
1985 receipt.topics = Some(TopicClouds {
1986 overall: vec![],
1987 per_module,
1988 });
1989 let result = render_md(&receipt);
1990 assert!(!result.contains("empty_module"));
1992 }
1993
1994 #[test]
1996 fn test_render_md_entropy() {
1997 let mut receipt = minimal_receipt();
1998 receipt.entropy = Some(EntropyReport {
1999 suspects: vec![EntropyFinding {
2000 path: "secret.bin".to_string(),
2001 module: "root".to_string(),
2002 entropy_bits_per_byte: 7.5,
2003 sample_bytes: 1024,
2004 class: EntropyClass::High,
2005 }],
2006 });
2007 let result = render_md(&receipt);
2008 assert!(result.contains("## Entropy profiling"));
2009 assert!(result.contains("|secret.bin|root|7.50|1024|High|"));
2010 }
2011
2012 #[test]
2014 fn test_render_md_entropy_no_suspects() {
2015 let mut receipt = minimal_receipt();
2016 receipt.entropy = Some(EntropyReport { suspects: vec![] });
2017 let result = render_md(&receipt);
2018 assert!(result.contains("## Entropy profiling"));
2019 assert!(result.contains("No entropy outliers detected"));
2020 }
2021
2022 #[test]
2024 fn test_render_md_license() {
2025 let mut receipt = minimal_receipt();
2026 receipt.license = Some(LicenseReport {
2027 effective: Some("MIT".to_string()),
2028 findings: vec![LicenseFinding {
2029 spdx: "MIT".to_string(),
2030 confidence: 0.95,
2031 source_path: "LICENSE".to_string(),
2032 source_kind: LicenseSourceKind::Text,
2033 }],
2034 });
2035 let result = render_md(&receipt);
2036 assert!(result.contains("## License radar"));
2037 assert!(result.contains("- Effective: `MIT`"));
2038 assert!(result.contains("|MIT|0.95|LICENSE|Text|"));
2039 }
2040
2041 #[test]
2043 fn test_render_md_license_no_findings() {
2044 let mut receipt = minimal_receipt();
2045 receipt.license = Some(LicenseReport {
2046 effective: None,
2047 findings: vec![],
2048 });
2049 let result = render_md(&receipt);
2050 assert!(result.contains("## License radar"));
2051 assert!(result.contains("Heuristic detection"));
2052 assert!(!result.contains("|SPDX|")); }
2054
2055 #[test]
2057 fn test_render_md_corporate_fingerprint() {
2058 let mut receipt = minimal_receipt();
2059 receipt.corporate_fingerprint = Some(CorporateFingerprint {
2060 domains: vec![DomainStat {
2061 domain: "example.com".to_string(),
2062 commits: 50,
2063 pct: 0.75,
2064 }],
2065 });
2066 let result = render_md(&receipt);
2067 assert!(result.contains("## Corporate fingerprint"));
2068 assert!(result.contains("|example.com|50|75.0%|"));
2069 }
2070
2071 #[test]
2073 fn test_render_md_corporate_fingerprint_no_domains() {
2074 let mut receipt = minimal_receipt();
2075 receipt.corporate_fingerprint = Some(CorporateFingerprint { domains: vec![] });
2076 let result = render_md(&receipt);
2077 assert!(result.contains("## Corporate fingerprint"));
2078 assert!(result.contains("No commit domains detected"));
2079 }
2080
2081 #[test]
2083 fn test_render_md_churn() {
2084 use std::collections::BTreeMap;
2085 let mut per_module = BTreeMap::new();
2086 per_module.insert(
2087 "src".to_string(),
2088 ChurnTrend {
2089 slope: 0.5,
2090 r2: 0.8,
2091 recent_change: 5,
2092 classification: TrendClass::Rising,
2093 },
2094 );
2095 let mut receipt = minimal_receipt();
2096 receipt.predictive_churn = Some(PredictiveChurnReport { per_module });
2097 let result = render_md(&receipt);
2098 assert!(result.contains("## Predictive churn"));
2099 assert!(result.contains("|src|0.5000|0.80|5|Rising|"));
2100 }
2101
2102 #[test]
2104 fn test_render_md_churn_empty() {
2105 use std::collections::BTreeMap;
2106 let mut receipt = minimal_receipt();
2107 receipt.predictive_churn = Some(PredictiveChurnReport {
2108 per_module: BTreeMap::new(),
2109 });
2110 let result = render_md(&receipt);
2111 assert!(result.contains("## Predictive churn"));
2112 assert!(result.contains("No churn signals detected"));
2113 }
2114
2115 #[test]
2117 fn test_render_md_assets() {
2118 let mut receipt = minimal_receipt();
2119 receipt.assets = Some(AssetReport {
2120 total_files: 5,
2121 total_bytes: 1000000,
2122 categories: vec![AssetCategoryRow {
2123 category: "images".to_string(),
2124 files: 3,
2125 bytes: 500000,
2126 extensions: vec!["png".to_string(), "jpg".to_string()],
2127 }],
2128 top_files: vec![AssetFileRow {
2129 path: "logo.png".to_string(),
2130 bytes: 100000,
2131 category: "images".to_string(),
2132 extension: "png".to_string(),
2133 }],
2134 });
2135 let result = render_md(&receipt);
2136 assert!(result.contains("## Assets"));
2137 assert!(result.contains("- Total files: `5`"));
2138 assert!(result.contains("|images|3|500000|png, jpg|"));
2139 assert!(result.contains("|logo.png|100000|images|"));
2140 }
2141
2142 #[test]
2144 fn test_render_md_assets_empty() {
2145 let mut receipt = minimal_receipt();
2146 receipt.assets = Some(AssetReport {
2147 total_files: 0,
2148 total_bytes: 0,
2149 categories: vec![],
2150 top_files: vec![],
2151 });
2152 let result = render_md(&receipt);
2153 assert!(result.contains("## Assets"));
2154 assert!(result.contains("- Total files: `0`"));
2155 assert!(!result.contains("|Category|")); }
2157
2158 #[test]
2160 fn test_render_md_deps() {
2161 let mut receipt = minimal_receipt();
2162 receipt.deps = Some(DependencyReport {
2163 total: 50,
2164 lockfiles: vec![LockfileReport {
2165 path: "Cargo.lock".to_string(),
2166 kind: "cargo".to_string(),
2167 dependencies: 50,
2168 }],
2169 });
2170 let result = render_md(&receipt);
2171 assert!(result.contains("## Dependencies"));
2172 assert!(result.contains("- Total: `50`"));
2173 assert!(result.contains("|Cargo.lock|cargo|50|"));
2174 }
2175
2176 #[test]
2178 fn test_render_md_deps_empty() {
2179 let mut receipt = minimal_receipt();
2180 receipt.deps = Some(DependencyReport {
2181 total: 0,
2182 lockfiles: vec![],
2183 });
2184 let result = render_md(&receipt);
2185 assert!(result.contains("## Dependencies"));
2186 assert!(!result.contains("|Lockfile|"));
2187 }
2188
2189 #[test]
2191 fn test_render_md_git() {
2192 let mut receipt = minimal_receipt();
2193 receipt.git = Some(GitReport {
2194 commits_scanned: 100,
2195 files_seen: 50,
2196 hotspots: vec![HotspotRow {
2197 path: "src/lib.rs".to_string(),
2198 commits: 25,
2199 lines: 500,
2200 score: 12500,
2201 }],
2202 bus_factor: vec![BusFactorRow {
2203 module: "src".to_string(),
2204 authors: 3,
2205 }],
2206 freshness: FreshnessReport {
2207 threshold_days: 90,
2208 stale_files: 5,
2209 total_files: 50,
2210 stale_pct: 0.1,
2211 by_module: vec![ModuleFreshnessRow {
2212 module: "src".to_string(),
2213 avg_days: 30.0,
2214 p90_days: 60.0,
2215 stale_pct: 0.05,
2216 }],
2217 },
2218 coupling: vec![CouplingRow {
2219 left: "src/a.rs".to_string(),
2220 right: "src/b.rs".to_string(),
2221 count: 10,
2222 }],
2223 age_distribution: Some(CodeAgeDistributionReport {
2224 buckets: vec![CodeAgeBucket {
2225 label: "0-30d".to_string(),
2226 min_days: 0,
2227 max_days: Some(30),
2228 files: 10,
2229 pct: 0.2,
2230 }],
2231 recent_refreshes: 12,
2232 prior_refreshes: 8,
2233 refresh_trend: TrendClass::Rising,
2234 }),
2235 });
2236 let result = render_md(&receipt);
2237 assert!(result.contains("## Git metrics"));
2238 assert!(result.contains("- Commits scanned: `100`"));
2239 assert!(result.contains("|src/lib.rs|25|500|12500|"));
2240 assert!(result.contains("|src|3|"));
2241 assert!(result.contains("Stale threshold (days): `90`"));
2242 assert!(result.contains("|src|30.00|60.00|5.0%|"));
2243 assert!(result.contains("### Code age"));
2244 assert!(result.contains("Refresh trend: `Rising`"));
2245 assert!(result.contains("|0-30d|0|30|10|20.0%|"));
2246 assert!(result.contains("|src/a.rs|src/b.rs|10|"));
2247 }
2248
2249 #[test]
2251 fn test_render_md_git_empty() {
2252 let mut receipt = minimal_receipt();
2253 receipt.git = Some(GitReport {
2254 commits_scanned: 0,
2255 files_seen: 0,
2256 hotspots: vec![],
2257 bus_factor: vec![],
2258 freshness: FreshnessReport {
2259 threshold_days: 90,
2260 stale_files: 0,
2261 total_files: 0,
2262 stale_pct: 0.0,
2263 by_module: vec![],
2264 },
2265 coupling: vec![],
2266 age_distribution: None,
2267 });
2268 let result = render_md(&receipt);
2269 assert!(result.contains("## Git metrics"));
2270 assert!(!result.contains("### Hotspots"));
2271 assert!(!result.contains("### Bus factor"));
2272 assert!(!result.contains("### Coupling"));
2273 }
2274
2275 #[test]
2277 fn test_render_md_imports() {
2278 let mut receipt = minimal_receipt();
2279 receipt.imports = Some(ImportReport {
2280 granularity: "file".to_string(),
2281 edges: vec![ImportEdge {
2282 from: "src/main.rs".to_string(),
2283 to: "src/lib.rs".to_string(),
2284 count: 5,
2285 }],
2286 });
2287 let result = render_md(&receipt);
2288 assert!(result.contains("## Imports"));
2289 assert!(result.contains("- Granularity: `file`"));
2290 assert!(result.contains("|src/main.rs|src/lib.rs|5|"));
2291 }
2292
2293 #[test]
2295 fn test_render_md_imports_empty() {
2296 let mut receipt = minimal_receipt();
2297 receipt.imports = Some(ImportReport {
2298 granularity: "module".to_string(),
2299 edges: vec![],
2300 });
2301 let result = render_md(&receipt);
2302 assert!(result.contains("## Imports"));
2303 assert!(!result.contains("|From|To|"));
2304 }
2305
2306 #[test]
2308 fn test_render_md_dup() {
2309 let mut receipt = minimal_receipt();
2310 receipt.dup = Some(DuplicateReport {
2311 wasted_bytes: 50000,
2312 strategy: "content".to_string(),
2313 groups: vec![DuplicateGroup {
2314 hash: "abc123".to_string(),
2315 bytes: 1000,
2316 files: vec!["a.txt".to_string(), "b.txt".to_string()],
2317 }],
2318 density: Some(DuplicationDensityReport {
2319 duplicate_groups: 1,
2320 duplicate_files: 2,
2321 duplicated_bytes: 2000,
2322 wasted_bytes: 1000,
2323 wasted_pct_of_codebase: 0.1,
2324 by_module: vec![ModuleDuplicationDensityRow {
2325 module: "src".to_string(),
2326 duplicate_files: 2,
2327 wasted_files: 1,
2328 duplicated_bytes: 2000,
2329 wasted_bytes: 1000,
2330 module_bytes: 10_000,
2331 density: 0.1,
2332 }],
2333 }),
2334 });
2335 let result = render_md(&receipt);
2336 assert!(result.contains("## Duplicates"));
2337 assert!(result.contains("- Wasted bytes: `50000`"));
2338 assert!(result.contains("### Duplication density"));
2339 assert!(result.contains("Waste vs codebase: `10.0%`"));
2340 assert!(result.contains("|src|2|1|2000|1000|10000|10.0%|"));
2341 assert!(result.contains("|abc123|1000|2|")); }
2343
2344 #[test]
2346 fn test_render_md_dup_empty() {
2347 let mut receipt = minimal_receipt();
2348 receipt.dup = Some(DuplicateReport {
2349 wasted_bytes: 0,
2350 strategy: "content".to_string(),
2351 groups: vec![],
2352 density: None,
2353 });
2354 let result = render_md(&receipt);
2355 assert!(result.contains("## Duplicates"));
2356 assert!(!result.contains("|Hash|Bytes|"));
2357 }
2358
2359 #[test]
2361 fn test_render_md_fun() {
2362 let mut receipt = minimal_receipt();
2363 receipt.fun = Some(FunReport {
2364 eco_label: Some(EcoLabel {
2365 label: "A+".to_string(),
2366 score: 95.5,
2367 bytes: 10000,
2368 notes: "Very efficient".to_string(),
2369 }),
2370 });
2371 let result = render_md(&receipt);
2372 assert!(result.contains("## Eco label"));
2373 assert!(result.contains("- Label: `A+`"));
2374 assert!(result.contains("- Score: `95.5`"));
2375 }
2376
2377 #[test]
2379 fn test_render_md_fun_no_label() {
2380 let mut receipt = minimal_receipt();
2381 receipt.fun = Some(FunReport { eco_label: None });
2382 let result = render_md(&receipt);
2383 assert!(!result.contains("## Eco label"));
2385 }
2386
2387 #[test]
2389 fn test_render_md_derived() {
2390 let mut receipt = minimal_receipt();
2391 receipt.derived = Some(sample_derived());
2392 let result = render_md(&receipt);
2393 assert!(result.contains("## Totals"));
2394 assert!(result.contains("|10|1000|200|100|1300|50000|2500|"));
2395 assert!(result.contains("## Ratios"));
2396 assert!(result.contains("## Distribution"));
2397 assert!(result.contains("## File size histogram"));
2398 assert!(result.contains("## Top offenders"));
2399 assert!(result.contains("## Structure"));
2400 assert!(result.contains("## Test density"));
2401 assert!(result.contains("## TODOs"));
2402 assert!(result.contains("## Boilerplate ratio"));
2403 assert!(result.contains("## Polyglot"));
2404 assert!(result.contains("## Reading time"));
2405 assert!(result.contains("## Context window"));
2406 assert!(result.contains("## COCOMO estimate"));
2407 assert!(result.contains("## Integrity"));
2408 }
2409
2410 #[test]
2412 fn test_render_dispatch_md() {
2413 let receipt = minimal_receipt();
2414 let result = render(&receipt, tokmd_config::AnalysisFormat::Md).unwrap();
2415 match result {
2416 RenderedOutput::Text(s) => assert!(s.starts_with("# tokmd analysis")),
2417 RenderedOutput::Binary(_) => panic!("expected text"),
2418 }
2419 }
2420
2421 #[test]
2422 fn test_render_dispatch_json() {
2423 let receipt = minimal_receipt();
2424 let result = render(&receipt, tokmd_config::AnalysisFormat::Json).unwrap();
2425 match result {
2426 RenderedOutput::Text(s) => assert!(s.contains("\"schema_version\": 2")),
2427 RenderedOutput::Binary(_) => panic!("expected text"),
2428 }
2429 }
2430
2431 #[test]
2432 fn test_render_dispatch_xml() {
2433 let receipt = minimal_receipt();
2434 let result = render(&receipt, tokmd_config::AnalysisFormat::Xml).unwrap();
2435 match result {
2436 RenderedOutput::Text(s) => assert!(s.contains("<analysis>")),
2437 RenderedOutput::Binary(_) => panic!("expected text"),
2438 }
2439 }
2440
2441 #[test]
2442 fn test_render_dispatch_tree() {
2443 let receipt = minimal_receipt();
2444 let result = render(&receipt, tokmd_config::AnalysisFormat::Tree).unwrap();
2445 match result {
2446 RenderedOutput::Text(s) => assert!(s.contains("(tree unavailable)")),
2447 RenderedOutput::Binary(_) => panic!("expected text"),
2448 }
2449 }
2450
2451 #[test]
2452 fn test_render_dispatch_svg() {
2453 let receipt = minimal_receipt();
2454 let result = render(&receipt, tokmd_config::AnalysisFormat::Svg).unwrap();
2455 match result {
2456 RenderedOutput::Text(s) => assert!(s.contains("<svg")),
2457 RenderedOutput::Binary(_) => panic!("expected text"),
2458 }
2459 }
2460
2461 #[test]
2462 fn test_render_dispatch_mermaid() {
2463 let receipt = minimal_receipt();
2464 let result = render(&receipt, tokmd_config::AnalysisFormat::Mermaid).unwrap();
2465 match result {
2466 RenderedOutput::Text(s) => assert!(s.starts_with("graph TD")),
2467 RenderedOutput::Binary(_) => panic!("expected text"),
2468 }
2469 }
2470
2471 #[test]
2472 fn test_render_dispatch_jsonld() {
2473 let receipt = minimal_receipt();
2474 let result = render(&receipt, tokmd_config::AnalysisFormat::Jsonld).unwrap();
2475 match result {
2476 RenderedOutput::Text(s) => assert!(s.contains("@context")),
2477 RenderedOutput::Binary(_) => panic!("expected text"),
2478 }
2479 }
2480
2481 #[test]
2483 fn test_chrono_lite_timestamp() {
2484 let ts = chrono_lite_timestamp();
2485 assert!(ts.contains("UTC"));
2487 assert!(ts.len() > 10); }
2489
2490 #[test]
2492 fn test_build_metrics_cards() {
2493 let mut receipt = minimal_receipt();
2494 receipt.derived = Some(sample_derived());
2495 let result = build_metrics_cards(&receipt);
2496 assert!(result.contains("class=\"metric-card\""));
2497 assert!(result.contains("Files"));
2498 assert!(result.contains("Lines"));
2499 assert!(result.contains("Code"));
2500 assert!(result.contains("Context Fit")); }
2502
2503 #[test]
2505 fn test_build_metrics_cards_no_derived() {
2506 let receipt = minimal_receipt();
2507 let result = build_metrics_cards(&receipt);
2508 assert!(result.is_empty());
2509 }
2510
2511 #[test]
2513 fn test_build_table_rows() {
2514 let mut receipt = minimal_receipt();
2515 receipt.derived = Some(sample_derived());
2516 let result = build_table_rows(&receipt);
2517 assert!(result.contains("<tr>"));
2518 assert!(result.contains("src/lib.rs"));
2519 }
2520
2521 #[test]
2523 fn test_build_table_rows_no_derived() {
2524 let receipt = minimal_receipt();
2525 let result = build_table_rows(&receipt);
2526 assert!(result.is_empty());
2527 }
2528
2529 #[test]
2531 fn test_build_report_json() {
2532 let mut receipt = minimal_receipt();
2533 receipt.derived = Some(sample_derived());
2534 let result = build_report_json(&receipt);
2535 assert!(result.contains("files"));
2536 assert!(result.contains("src/lib.rs"));
2537 assert!(!result.contains("<"));
2539 assert!(!result.contains(">"));
2540 }
2541
2542 #[test]
2544 fn test_build_report_json_no_derived() {
2545 let receipt = minimal_receipt();
2546 let result = build_report_json(&receipt);
2547 assert!(result.contains("\"files\":[]"));
2548 }
2549
2550 #[test]
2552 fn test_render_html() {
2553 let mut receipt = minimal_receipt();
2554 receipt.derived = Some(sample_derived());
2555 let result = render_html(&receipt);
2556 assert!(result.contains("<!DOCTYPE html>") || result.contains("<html"));
2557 }
2558}