1use anyhow::Result;
33use std::fmt::Write;
34use tokmd_analysis_types::AnalysisReceipt;
35use tokmd_types::AnalysisFormat;
36
37pub mod html;
38mod markdown;
39
40pub enum RenderedOutput {
41 Text(String),
42 Binary(Vec<u8>),
43}
44
45pub fn render(receipt: &AnalysisReceipt, format: AnalysisFormat) -> Result<RenderedOutput> {
46 match format {
47 AnalysisFormat::Md => Ok(RenderedOutput::Text(render_md(receipt))),
48 AnalysisFormat::Json => Ok(RenderedOutput::Text(serde_json::to_string_pretty(receipt)?)),
49 AnalysisFormat::Jsonld => Ok(RenderedOutput::Text(render_jsonld(receipt))),
50 AnalysisFormat::Xml => Ok(RenderedOutput::Text(render_xml(receipt))),
51 AnalysisFormat::Svg => Ok(RenderedOutput::Text(render_svg(receipt))),
52 AnalysisFormat::Mermaid => Ok(RenderedOutput::Text(render_mermaid(receipt))),
53 AnalysisFormat::Obj => Ok(RenderedOutput::Text(render_obj(receipt)?)),
54 AnalysisFormat::Midi => Ok(RenderedOutput::Binary(render_midi(receipt)?)),
55 AnalysisFormat::Tree => Ok(RenderedOutput::Text(render_tree(receipt))),
56 AnalysisFormat::Html => Ok(RenderedOutput::Text(render_html(receipt))),
57 }
58}
59
60fn render_md(receipt: &AnalysisReceipt) -> String {
61 markdown::render_md(receipt)
62}
63
64fn render_jsonld(receipt: &AnalysisReceipt) -> String {
65 let name = receipt
66 .source
67 .inputs
68 .first()
69 .cloned()
70 .unwrap_or_else(|| "tokmd".to_string());
71 let totals = receipt.derived.as_ref().map(|d| &d.totals);
72 let payload = serde_json::json!({
73 "@context": "https://schema.org",
74 "@type": "SoftwareSourceCode",
75 "name": name,
76 "codeLines": totals.map(|t| t.code).unwrap_or(0),
77 "commentCount": totals.map(|t| t.comments).unwrap_or(0),
78 "lineCount": totals.map(|t| t.lines).unwrap_or(0),
79 "fileSize": totals.map(|t| t.bytes).unwrap_or(0),
80 "interactionStatistic": {
81 "@type": "InteractionCounter",
82 "interactionType": "http://schema.org/ReadAction",
83 "userInteractionCount": totals.map(|t| t.tokens).unwrap_or(0)
84 }
85 });
86 serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
87}
88
89fn render_xml(receipt: &AnalysisReceipt) -> String {
90 let totals = receipt.derived.as_ref().map(|d| &d.totals);
91 let mut out = String::new();
92 out.push_str("<analysis>");
93 if let Some(totals) = totals {
94 let _ = write!(
95 out,
96 "<totals files=\"{}\" code=\"{}\" comments=\"{}\" blanks=\"{}\" lines=\"{}\" bytes=\"{}\" tokens=\"{}\"/>",
97 totals.files,
98 totals.code,
99 totals.comments,
100 totals.blanks,
101 totals.lines,
102 totals.bytes,
103 totals.tokens
104 );
105 }
106 out.push_str("</analysis>");
107 out
108}
109
110fn render_svg(receipt: &AnalysisReceipt) -> String {
111 let (label, value) = if let Some(derived) = &receipt.derived {
112 if let Some(ctx) = &derived.context_window {
113 ("context".to_string(), format!("{:.1}%", ctx.pct * 100.0))
114 } else {
115 ("tokens".to_string(), derived.totals.tokens.to_string())
116 }
117 } else {
118 ("tokens".to_string(), "0".to_string())
119 };
120
121 let width = 240;
122 let height = 32;
123 let label_width = 80;
124 let value_width = width - label_width;
125 format!(
126 "<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>",
127 width = width,
128 height = height,
129 label_width = label_width,
130 value_width = value_width,
131 lx = label_width / 2,
132 vx = label_width + value_width / 2,
133 ty = 20,
134 label = label,
135 value = value
136 )
137}
138
139fn render_mermaid(receipt: &AnalysisReceipt) -> String {
140 let mut out = String::from("graph TD\n");
141 if let Some(imports) = &receipt.imports {
142 for edge in imports.edges.iter().take(200) {
143 let from = sanitize_mermaid(&edge.from);
144 let to = sanitize_mermaid(&edge.to);
145 let _ = writeln!(out, " {} -->|{}| {}", from, edge.count, to);
146 }
147 }
148 out
149}
150
151fn render_tree(receipt: &AnalysisReceipt) -> String {
152 receipt
153 .derived
154 .as_ref()
155 .and_then(|d| d.tree.clone())
156 .unwrap_or_else(|| "(tree unavailable)".to_string())
157}
158
159#[cfg(feature = "fun")]
161fn render_obj_fun(receipt: &AnalysisReceipt) -> Result<String> {
162 if let Some(derived) = &receipt.derived {
163 let buildings: Vec<crate::fun::ObjBuilding> = derived
164 .top
165 .largest_lines
166 .iter()
167 .enumerate()
168 .map(|(idx, row)| {
169 let x = (idx % 5) as f32 * 2.0;
170 let y = (idx / 5) as f32 * 2.0;
171 let h = (row.lines as f32 / 10.0).max(0.5);
172 crate::fun::ObjBuilding {
173 name: row.path.clone(),
174 x,
175 y,
176 w: 1.5,
177 d: 1.5,
178 h,
179 }
180 })
181 .collect();
182 return Ok(crate::fun::render_obj(&buildings));
183 }
184 Ok("# tokmd code city\n".to_string())
185}
186
187#[cfg(feature = "fun")]
188fn render_midi_fun(receipt: &AnalysisReceipt) -> Result<Vec<u8>> {
189 let mut notes = Vec::new();
190 if let Some(derived) = &receipt.derived {
191 for (idx, row) in derived.top.largest_lines.iter().enumerate() {
192 let key = 60u8 + (row.depth as u8 % 12);
193 let velocity = (40 + (row.lines.min(127) as u8 / 2)).min(120);
194 let start = (idx as u32) * 240;
195 notes.push(crate::fun::MidiNote {
196 key,
197 velocity,
198 start,
199 duration: 180,
200 channel: 0,
201 });
202 }
203 }
204 crate::fun::render_midi(¬es, 120)
205}
206
207#[cfg(not(feature = "fun"))]
209fn render_obj_disabled(_receipt: &AnalysisReceipt) -> Result<String> {
210 anyhow::bail!(
211 "OBJ format requires the `fun` feature: tokmd-format = {{ version = \"1.9\", features = [\"fun\"] }}"
212 )
213}
214
215#[cfg(not(feature = "fun"))]
216fn render_midi_disabled(_receipt: &AnalysisReceipt) -> Result<Vec<u8>> {
217 anyhow::bail!(
218 "MIDI format requires the `fun` feature: tokmd-format = {{ version = \"1.9\", features = [\"fun\"] }}"
219 )
220}
221
222fn render_obj(receipt: &AnalysisReceipt) -> Result<String> {
224 #[cfg(feature = "fun")]
225 {
226 render_obj_fun(receipt)
227 }
228 #[cfg(not(feature = "fun"))]
229 {
230 render_obj_disabled(receipt)
231 }
232}
233
234fn render_midi(receipt: &AnalysisReceipt) -> Result<Vec<u8>> {
235 #[cfg(feature = "fun")]
236 {
237 render_midi_fun(receipt)
238 }
239 #[cfg(not(feature = "fun"))]
240 {
241 render_midi_disabled(receipt)
242 }
243}
244
245fn sanitize_mermaid(name: &str) -> String {
246 name.chars()
247 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
248 .collect()
249}
250
251fn render_html(receipt: &AnalysisReceipt) -> String {
252 html::render(receipt)
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use tokmd_analysis_types::*;
259
260 fn minimal_receipt() -> AnalysisReceipt {
261 AnalysisReceipt {
262 schema_version: 2,
263 generated_at_ms: 0,
264 tool: tokmd_types::ToolInfo {
265 name: "tokmd".to_string(),
266 version: "0.0.0".to_string(),
267 },
268 mode: "analysis".to_string(),
269 status: tokmd_types::ScanStatus::Complete,
270 warnings: vec![],
271 source: AnalysisSource {
272 inputs: vec!["test".to_string()],
273 export_path: None,
274 base_receipt_path: None,
275 export_schema_version: None,
276 export_generated_at_ms: None,
277 base_signature: None,
278 module_roots: vec![],
279 module_depth: 1,
280 children: "collapse".to_string(),
281 },
282 args: AnalysisArgsMeta {
283 preset: "receipt".to_string(),
284 format: "md".to_string(),
285 window_tokens: None,
286 git: None,
287 max_files: None,
288 max_bytes: None,
289 max_commits: None,
290 max_commit_files: None,
291 max_file_bytes: None,
292 import_granularity: "module".to_string(),
293 },
294 archetype: None,
295 topics: None,
296 entropy: None,
297 predictive_churn: None,
298 corporate_fingerprint: None,
299 license: None,
300 derived: None,
301 assets: None,
302 deps: None,
303 git: None,
304 imports: None,
305 dup: None,
306 complexity: None,
307 api_surface: None,
308 fun: None,
309 effort: None,
310 }
311 }
312
313 fn sample_derived() -> DerivedReport {
314 DerivedReport {
315 totals: DerivedTotals {
316 files: 10,
317 code: 1000,
318 comments: 200,
319 blanks: 100,
320 lines: 1300,
321 bytes: 50000,
322 tokens: 2500,
323 },
324 doc_density: RatioReport {
325 total: RatioRow {
326 key: "total".to_string(),
327 numerator: 200,
328 denominator: 1200,
329 ratio: 0.1667,
330 },
331 by_lang: vec![],
332 by_module: vec![],
333 },
334 whitespace: RatioReport {
335 total: RatioRow {
336 key: "total".to_string(),
337 numerator: 100,
338 denominator: 1300,
339 ratio: 0.0769,
340 },
341 by_lang: vec![],
342 by_module: vec![],
343 },
344 verbosity: RateReport {
345 total: RateRow {
346 key: "total".to_string(),
347 numerator: 50000,
348 denominator: 1300,
349 rate: 38.46,
350 },
351 by_lang: vec![],
352 by_module: vec![],
353 },
354 max_file: MaxFileReport {
355 overall: FileStatRow {
356 path: "src/lib.rs".to_string(),
357 module: "src".to_string(),
358 lang: "Rust".to_string(),
359 code: 500,
360 comments: 100,
361 blanks: 50,
362 lines: 650,
363 bytes: 25000,
364 tokens: 1250,
365 doc_pct: Some(0.167),
366 bytes_per_line: Some(38.46),
367 depth: 1,
368 },
369 by_lang: vec![],
370 by_module: vec![],
371 },
372 lang_purity: LangPurityReport { rows: vec![] },
373 nesting: NestingReport {
374 max: 3,
375 avg: 1.5,
376 by_module: vec![],
377 },
378 test_density: TestDensityReport {
379 test_lines: 200,
380 prod_lines: 1000,
381 test_files: 5,
382 prod_files: 5,
383 ratio: 0.2,
384 },
385 boilerplate: BoilerplateReport {
386 infra_lines: 100,
387 logic_lines: 1100,
388 ratio: 0.083,
389 infra_langs: vec!["TOML".to_string()],
390 },
391 polyglot: PolyglotReport {
392 lang_count: 2,
393 entropy: 0.5,
394 dominant_lang: "Rust".to_string(),
395 dominant_lines: 1000,
396 dominant_pct: 0.833,
397 },
398 distribution: DistributionReport {
399 count: 10,
400 min: 50,
401 max: 650,
402 mean: 130.0,
403 median: 100.0,
404 p90: 400.0,
405 p99: 650.0,
406 gini: 0.3,
407 },
408 histogram: vec![HistogramBucket {
409 label: "Small".to_string(),
410 min: 0,
411 max: Some(100),
412 files: 5,
413 pct: 0.5,
414 }],
415 top: TopOffenders {
416 largest_lines: vec![FileStatRow {
417 path: "src/lib.rs".to_string(),
418 module: "src".to_string(),
419 lang: "Rust".to_string(),
420 code: 500,
421 comments: 100,
422 blanks: 50,
423 lines: 650,
424 bytes: 25000,
425 tokens: 1250,
426 doc_pct: Some(0.167),
427 bytes_per_line: Some(38.46),
428 depth: 1,
429 }],
430 largest_tokens: vec![],
431 largest_bytes: vec![],
432 least_documented: vec![],
433 most_dense: vec![],
434 },
435 tree: Some("test-tree".to_string()),
436 reading_time: ReadingTimeReport {
437 minutes: 65.0,
438 lines_per_minute: 20,
439 basis_lines: 1300,
440 },
441 context_window: Some(ContextWindowReport {
442 window_tokens: 100000,
443 total_tokens: 2500,
444 pct: 0.025,
445 fits: true,
446 }),
447 cocomo: Some(CocomoReport {
448 mode: "organic".to_string(),
449 kloc: 1.0,
450 effort_pm: 2.4,
451 duration_months: 2.5,
452 staff: 1.0,
453 a: 2.4,
454 b: 1.05,
455 c: 2.5,
456 d: 0.38,
457 }),
458 todo: Some(TodoReport {
459 total: 5,
460 density_per_kloc: 5.0,
461 tags: vec![TodoTagRow {
462 tag: "TODO".to_string(),
463 count: 5,
464 }],
465 }),
466 integrity: IntegrityReport {
467 algo: "blake3".to_string(),
468 hash: "abc123".to_string(),
469 entries: 10,
470 },
471 }
472 }
473
474 #[test]
476 fn test_render_xml() {
477 let mut receipt = minimal_receipt();
478 receipt.derived = Some(sample_derived());
479 let result = render_xml(&receipt);
480 assert!(result.starts_with("<analysis>"));
481 assert!(result.ends_with("</analysis>"));
482 assert!(result.contains("files=\"10\""));
483 assert!(result.contains("code=\"1000\""));
484 }
485
486 #[test]
488 fn test_render_xml_no_derived() {
489 let receipt = minimal_receipt();
490 let result = render_xml(&receipt);
491 assert_eq!(result, "<analysis></analysis>");
492 }
493
494 #[test]
496 fn test_render_jsonld() {
497 let mut receipt = minimal_receipt();
498 receipt.derived = Some(sample_derived());
499 let result = render_jsonld(&receipt);
500 assert!(result.contains("\"@context\": \"https://schema.org\""));
501 assert!(result.contains("\"@type\": \"SoftwareSourceCode\""));
502 assert!(result.contains("\"name\": \"test\""));
503 assert!(result.contains("\"codeLines\": 1000"));
504 }
505
506 #[test]
508 fn test_render_jsonld_empty_inputs() {
509 let mut receipt = minimal_receipt();
510 receipt.source.inputs.clear();
511 let result = render_jsonld(&receipt);
512 assert!(result.contains("\"name\": \"tokmd\""));
513 }
514
515 #[test]
517 fn test_render_svg() {
518 let mut receipt = minimal_receipt();
519 receipt.derived = Some(sample_derived());
520 let result = render_svg(&receipt);
521 assert!(result.contains("<svg"));
522 assert!(result.contains("</svg>"));
523 assert!(result.contains("context")); assert!(result.contains("2.5%")); }
526
527 #[test]
529 fn test_render_svg_no_context() {
530 let mut receipt = minimal_receipt();
531 let mut derived = sample_derived();
532 derived.context_window = None;
533 receipt.derived = Some(derived);
534 let result = render_svg(&receipt);
535 assert!(result.contains("tokens"));
536 assert!(result.contains("2500")); }
538
539 #[test]
541 fn test_render_svg_no_derived() {
542 let receipt = minimal_receipt();
543 let result = render_svg(&receipt);
544 assert!(result.contains("tokens"));
545 assert!(result.contains(">0<")); }
547
548 #[test]
550 fn test_render_svg_dimensions() {
551 let receipt = minimal_receipt();
552 let result = render_svg(&receipt);
553 assert!(result.contains("width=\"160\"")); }
556
557 #[test]
559 fn test_render_mermaid() {
560 let mut receipt = minimal_receipt();
561 receipt.imports = Some(ImportReport {
562 granularity: "module".to_string(),
563 edges: vec![ImportEdge {
564 from: "src/main".to_string(),
565 to: "src/lib".to_string(),
566 count: 5,
567 }],
568 });
569 let result = render_mermaid(&receipt);
570 assert!(result.starts_with("graph TD\n"));
571 assert!(result.contains("src_main -->|5| src_lib"));
572 }
573
574 #[test]
576 fn test_render_mermaid_no_imports() {
577 let receipt = minimal_receipt();
578 let result = render_mermaid(&receipt);
579 assert_eq!(result, "graph TD\n");
580 }
581
582 #[test]
584 fn test_render_tree() {
585 let mut receipt = minimal_receipt();
586 receipt.derived = Some(sample_derived());
587 let result = render_tree(&receipt);
588 assert_eq!(result, "test-tree");
589 }
590
591 #[test]
593 fn test_render_tree_no_derived() {
594 let receipt = minimal_receipt();
595 let result = render_tree(&receipt);
596 assert_eq!(result, "(tree unavailable)");
597 }
598
599 #[test]
601 fn test_render_tree_none() {
602 let mut receipt = minimal_receipt();
603 let mut derived = sample_derived();
604 derived.tree = None;
605 receipt.derived = Some(derived);
606 let result = render_tree(&receipt);
607 assert_eq!(result, "(tree unavailable)");
608 }
609
610 #[cfg(not(feature = "fun"))]
612 #[test]
613 fn test_render_obj_no_fun() {
614 let receipt = minimal_receipt();
615 let result = render_obj(&receipt);
616 assert!(result.is_err());
617 assert!(result.unwrap_err().to_string().contains("fun"));
618 }
619
620 #[cfg(not(feature = "fun"))]
622 #[test]
623 fn test_render_midi_no_fun() {
624 let receipt = minimal_receipt();
625 let result = render_midi(&receipt);
626 assert!(result.is_err());
627 assert!(result.unwrap_err().to_string().contains("fun"));
628 }
629
630 #[cfg(feature = "fun")]
637 #[test]
638 fn test_render_obj_coordinate_math() {
639 let mut receipt = minimal_receipt();
640 let mut derived = sample_derived();
641 derived.top.largest_lines = vec![
651 FileStatRow {
652 path: "file0.rs".to_string(),
653 module: "src".to_string(),
654 lang: "Rust".to_string(),
655 code: 100,
656 comments: 10,
657 blanks: 5,
658 lines: 100, bytes: 1000,
660 tokens: 200,
661 doc_pct: None,
662 bytes_per_line: None,
663 depth: 1,
664 },
665 FileStatRow {
666 path: "file1.rs".to_string(),
667 module: "src".to_string(),
668 lang: "Rust".to_string(),
669 code: 50,
670 comments: 5,
671 blanks: 2,
672 lines: 3, bytes: 500,
674 tokens: 100,
675 doc_pct: None,
676 bytes_per_line: None,
677 depth: 2,
678 },
679 FileStatRow {
680 path: "file2.rs".to_string(),
681 module: "src".to_string(),
682 lang: "Rust".to_string(),
683 code: 200,
684 comments: 20,
685 blanks: 10,
686 lines: 200, bytes: 2000,
688 tokens: 400,
689 doc_pct: None,
690 bytes_per_line: None,
691 depth: 3,
692 },
693 FileStatRow {
694 path: "file3.rs".to_string(),
695 module: "src".to_string(),
696 lang: "Rust".to_string(),
697 code: 75,
698 comments: 7,
699 blanks: 3,
700 lines: 75, bytes: 750,
702 tokens: 150,
703 doc_pct: None,
704 bytes_per_line: None,
705 depth: 0,
706 },
707 FileStatRow {
708 path: "file4.rs".to_string(),
709 module: "src".to_string(),
710 lang: "Rust".to_string(),
711 code: 150,
712 comments: 15,
713 blanks: 8,
714 lines: 150, bytes: 1500,
716 tokens: 300,
717 doc_pct: None,
718 bytes_per_line: None,
719 depth: 1,
720 },
721 FileStatRow {
723 path: "file5.rs".to_string(),
724 module: "src".to_string(),
725 lang: "Rust".to_string(),
726 code: 80,
727 comments: 8,
728 blanks: 4,
729 lines: 80, bytes: 800,
731 tokens: 160,
732 doc_pct: None,
733 bytes_per_line: None,
734 depth: 2,
735 },
736 FileStatRow {
738 path: "file6.rs".to_string(),
739 module: "src".to_string(),
740 lang: "Rust".to_string(),
741 code: 60,
742 comments: 6,
743 blanks: 3,
744 lines: 60, bytes: 600,
746 tokens: 120,
747 doc_pct: None,
748 bytes_per_line: None,
749 depth: 1,
750 },
751 ];
752 receipt.derived = Some(derived);
753 let result = render_obj(&receipt).expect("render_obj should succeed with fun feature");
754
755 #[allow(clippy::type_complexity)]
758 let objects: Vec<(&str, Vec<(f32, f32, f32)>)> = result
759 .split("o ")
760 .skip(1)
761 .map(|section| {
762 let lines: Vec<&str> = section.lines().collect();
763 let name = lines[0];
764 let vertices: Vec<(f32, f32, f32)> = lines[1..]
765 .iter()
766 .filter(|l| l.starts_with("v "))
767 .take(8)
768 .map(|l| {
769 let parts: Vec<f32> = l[2..]
770 .split_whitespace()
771 .map(|p| p.parse().unwrap())
772 .collect();
773 (parts[0], parts[1], parts[2])
774 })
775 .collect();
776 (name, vertices)
777 })
778 .collect();
779
780 assert_eq!(objects.len(), 7, "expected 7 buildings");
782
783 fn base_corner(obj: &(&str, Vec<(f32, f32, f32)>)) -> (f32, f32, f32) {
785 obj.1[0]
786 }
787 fn top_corner(obj: &(&str, Vec<(f32, f32, f32)>)) -> (f32, f32, f32) {
788 obj.1[4] }
790
791 assert_eq!(
793 base_corner(&objects[0]),
794 (0.0, 0.0, 0.0),
795 "file0 base position"
796 );
797 assert_eq!(
798 top_corner(&objects[0]).2,
799 10.0,
800 "file0 height should be 10.0 (100/10)"
801 );
802
803 assert_eq!(
806 base_corner(&objects[1]),
807 (2.0, 0.0, 0.0),
808 "file1 base position"
809 );
810 assert_eq!(
811 top_corner(&objects[1]).2,
812 0.5,
813 "file1 height should be 0.5 (clamped from 3/10=0.3)"
814 );
815
816 assert_eq!(
818 base_corner(&objects[2]),
819 (4.0, 0.0, 0.0),
820 "file2 base position"
821 );
822 assert_eq!(
823 top_corner(&objects[2]).2,
824 20.0,
825 "file2 height should be 20.0 (200/10)"
826 );
827
828 assert_eq!(
830 base_corner(&objects[3]),
831 (6.0, 0.0, 0.0),
832 "file3 base position"
833 );
834 assert_eq!(
835 top_corner(&objects[3]).2,
836 7.5,
837 "file3 height should be 7.5 (75/10)"
838 );
839
840 assert_eq!(
843 base_corner(&objects[4]),
844 (8.0, 0.0, 0.0),
845 "file4 base position (x = 4*2 = 8)"
846 );
847 assert_eq!(
848 top_corner(&objects[4]).2,
849 15.0,
850 "file4 height should be 15.0 (150/10)"
851 );
852
853 assert_eq!(
857 base_corner(&objects[5]),
858 (0.0, 2.0, 0.0),
859 "file5 base position (x=0 from 5%5, y=2 from 5/5*2)"
860 );
861 assert_eq!(
862 top_corner(&objects[5]).2,
863 8.0,
864 "file5 height should be 8.0 (80/10)"
865 );
866
867 assert_eq!(
870 base_corner(&objects[6]),
871 (2.0, 2.0, 0.0),
872 "file6 base position (x=2 from 6%5*2, y=2 from 6/5*2)"
873 );
874 assert_eq!(
875 top_corner(&objects[6]).2,
876 6.0,
877 "file6 height should be 6.0 (60/10)"
878 );
879
880 assert!(result.contains("f 1 2 3 4"), "missing face definition");
882 }
883
884 #[cfg(feature = "fun")]
890 #[test]
891 fn test_render_midi_note_math() {
892 use midly::{MidiMessage, Smf, TrackEventKind};
893
894 let mut receipt = minimal_receipt();
895 let mut derived = sample_derived();
896 derived.top.largest_lines = vec![
902 FileStatRow {
904 path: "a.rs".to_string(),
905 module: "src".to_string(),
906 lang: "Rust".to_string(),
907 code: 50,
908 comments: 5,
909 blanks: 2,
910 lines: 60,
911 bytes: 500,
912 tokens: 100,
913 doc_pct: None,
914 bytes_per_line: None,
915 depth: 5,
916 },
917 FileStatRow {
920 path: "b.rs".to_string(),
921 module: "src".to_string(),
922 lang: "Rust".to_string(),
923 code: 100,
924 comments: 10,
925 blanks: 5,
926 lines: 200, bytes: 1000,
928 tokens: 200,
929 doc_pct: None,
930 bytes_per_line: None,
931 depth: 15,
932 },
933 FileStatRow {
935 path: "c.rs".to_string(),
936 module: "src".to_string(),
937 lang: "Rust".to_string(),
938 code: 20,
939 comments: 2,
940 blanks: 1,
941 lines: 20,
942 bytes: 200,
943 tokens: 40,
944 doc_pct: None,
945 bytes_per_line: None,
946 depth: 0,
947 },
948 FileStatRow {
951 path: "d.rs".to_string(),
952 module: "src".to_string(),
953 lang: "Rust".to_string(),
954 code: 160,
955 comments: 16,
956 blanks: 8,
957 lines: 160,
958 bytes: 1600,
959 tokens: 320,
960 doc_pct: None,
961 bytes_per_line: None,
962 depth: 12,
963 },
964 ];
965 receipt.derived = Some(derived);
966
967 let result = render_midi(&receipt).unwrap();
968
969 let smf = Smf::parse(&result).expect("should parse as valid MIDI");
971
972 let mut notes: Vec<(u32, u8, u8)> = Vec::new(); let mut abs_time = 0u32;
975
976 for event in &smf.tracks[0] {
977 abs_time += event.delta.as_int();
978 if let TrackEventKind::Midi {
979 message: MidiMessage::NoteOn { key, vel },
980 ..
981 } = event.kind
982 {
983 notes.push((abs_time, key.as_int(), vel.as_int()));
984 }
985 }
986
987 assert_eq!(notes.len(), 4, "expected 4 NoteOn events, got {:?}", notes);
989
990 assert_eq!(
993 notes[0],
994 (0, 65, 70),
995 "note 0: expected (time=0, key=65=60+5, vel=70=40+60/2), got {:?}",
996 notes[0]
997 );
998
999 assert_eq!(
1002 notes[1],
1003 (240, 63, 103),
1004 "note 1: expected (time=240=1*240, key=63=60+(15%12), vel=103=40+127/2), got {:?}",
1005 notes[1]
1006 );
1007
1008 assert_eq!(
1010 notes[2],
1011 (480, 60, 50),
1012 "note 2: expected (time=480=2*240, key=60=60+0, vel=50=40+20/2), got {:?}",
1013 notes[2]
1014 );
1015
1016 assert_eq!(
1019 notes[3],
1020 (720, 60, 103),
1021 "note 3: expected (time=720=3*240, key=60=60+(12%12), vel=103=40+127/2), got {:?}",
1022 notes[3]
1023 );
1024
1025 let mut note_offs: Vec<(u32, u8)> = Vec::new(); abs_time = 0;
1028 for event in &smf.tracks[0] {
1029 abs_time += event.delta.as_int();
1030 if let TrackEventKind::Midi {
1031 message: MidiMessage::NoteOff { key, .. },
1032 ..
1033 } = event.kind
1034 {
1035 note_offs.push((abs_time, key.as_int()));
1036 }
1037 }
1038
1039 assert!(
1041 note_offs.iter().any(|&(t, k)| t == 180 && k == 65),
1042 "expected NoteOff for key 65 at time 180, got {:?}",
1043 note_offs
1044 );
1045 assert!(
1046 note_offs.iter().any(|&(t, k)| t == 420 && k == 63),
1047 "expected NoteOff for key 63 at time 420 (240+180), got {:?}",
1048 note_offs
1049 );
1050 assert!(
1051 note_offs.iter().any(|&(t, k)| t == 660 && k == 60),
1052 "expected NoteOff for key 60 at time 660 (480+180), got {:?}",
1053 note_offs
1054 );
1055 assert!(
1056 note_offs.iter().any(|&(t, k)| t == 900 && k == 60),
1057 "expected NoteOff for key 60 at time 900 (720+180), got {:?}",
1058 note_offs
1059 );
1060 }
1061
1062 #[cfg(feature = "fun")]
1064 #[test]
1065 fn test_render_midi_no_derived() {
1066 use midly::Smf;
1067
1068 let receipt = minimal_receipt();
1069 let result = render_midi(&receipt).unwrap();
1070
1071 assert!(!result.is_empty(), "MIDI output should not be empty");
1073 assert!(
1074 result.len() > 14,
1075 "MIDI should have header (14 bytes) + track data"
1076 );
1077
1078 let smf = Smf::parse(&result).expect("should be valid MIDI even with no notes");
1080 assert_eq!(smf.tracks.len(), 1, "should have exactly one track");
1081 }
1082
1083 #[cfg(feature = "fun")]
1085 #[test]
1086 fn test_render_obj_no_derived() {
1087 let receipt = minimal_receipt();
1088 let result = render_obj(&receipt).expect("render_obj should succeed");
1089
1090 assert_eq!(result, "# tokmd code city\n");
1092 }
1093
1094 #[test]
1096 fn test_render_md_basic() {
1097 let receipt = minimal_receipt();
1098 let result = render_md(&receipt);
1099 assert!(result.starts_with("# tokmd analysis\n"));
1100 assert!(result.contains("Preset: `receipt`"));
1101 }
1102
1103 #[test]
1105 fn test_render_md_inputs() {
1106 let mut receipt = minimal_receipt();
1107 receipt.source.inputs = vec!["path1".to_string(), "path2".to_string()];
1108 let result = render_md(&receipt);
1109 assert!(result.contains("## Inputs"));
1110 assert!(result.contains("- `path1`"));
1111 assert!(result.contains("- `path2`"));
1112 }
1113
1114 #[test]
1116 fn test_render_md_empty_inputs() {
1117 let mut receipt = minimal_receipt();
1118 receipt.source.inputs.clear();
1119 let result = render_md(&receipt);
1120 assert!(!result.contains("## Inputs"));
1121 }
1122
1123 #[test]
1125 fn test_render_md_archetype() {
1126 let mut receipt = minimal_receipt();
1127 receipt.archetype = Some(Archetype {
1128 kind: "library".to_string(),
1129 evidence: vec!["Cargo.toml".to_string(), "src/lib.rs".to_string()],
1130 });
1131 let result = render_md(&receipt);
1132 assert!(result.contains("## Archetype"));
1133 assert!(result.contains("- Kind: `library`"));
1134 assert!(result.contains("- Evidence: `Cargo.toml`, `src/lib.rs`"));
1135 }
1136
1137 #[test]
1139 fn test_render_md_archetype_no_evidence() {
1140 let mut receipt = minimal_receipt();
1141 receipt.archetype = Some(Archetype {
1142 kind: "app".to_string(),
1143 evidence: vec![],
1144 });
1145 let result = render_md(&receipt);
1146 assert!(result.contains("## Archetype"));
1147 assert!(result.contains("- Kind: `app`"));
1148 assert!(!result.contains("Evidence"));
1149 }
1150
1151 #[test]
1153 fn test_render_md_topics() {
1154 use std::collections::BTreeMap;
1155 let mut per_module = BTreeMap::new();
1156 per_module.insert(
1157 "src".to_string(),
1158 vec![TopicTerm {
1159 term: "parser".to_string(),
1160 score: 1.5,
1161 tf: 10,
1162 df: 2,
1163 }],
1164 );
1165 let mut receipt = minimal_receipt();
1166 receipt.topics = Some(TopicClouds {
1167 overall: vec![TopicTerm {
1168 term: "code".to_string(),
1169 score: 2.0,
1170 tf: 20,
1171 df: 5,
1172 }],
1173 per_module,
1174 });
1175 let result = render_md(&receipt);
1176 assert!(result.contains("## Topics"));
1177 assert!(result.contains("- Overall: `code`"));
1178 assert!(result.contains("- `src`: parser"));
1179 }
1180
1181 #[test]
1183 fn test_render_md_topics_empty_module() {
1184 use std::collections::BTreeMap;
1185 let mut per_module = BTreeMap::new();
1186 per_module.insert("empty_module".to_string(), vec![]);
1187 let mut receipt = minimal_receipt();
1188 receipt.topics = Some(TopicClouds {
1189 overall: vec![],
1190 per_module,
1191 });
1192 let result = render_md(&receipt);
1193 assert!(!result.contains("empty_module"));
1195 }
1196
1197 #[test]
1199 fn test_render_md_entropy() {
1200 let mut receipt = minimal_receipt();
1201 receipt.entropy = Some(EntropyReport {
1202 suspects: vec![EntropyFinding {
1203 path: "secret.bin".to_string(),
1204 module: "root".to_string(),
1205 entropy_bits_per_byte: 7.5,
1206 sample_bytes: 1024,
1207 class: EntropyClass::High,
1208 }],
1209 });
1210 let result = render_md(&receipt);
1211 assert!(result.contains("## Entropy profiling"));
1212 assert!(result.contains("|secret.bin|root|7.50|1024|High|"));
1213 }
1214
1215 #[test]
1217 fn test_render_md_entropy_no_suspects() {
1218 let mut receipt = minimal_receipt();
1219 receipt.entropy = Some(EntropyReport { suspects: vec![] });
1220 let result = render_md(&receipt);
1221 assert!(result.contains("## Entropy profiling"));
1222 assert!(result.contains("No entropy outliers detected"));
1223 }
1224
1225 #[test]
1227 fn test_render_md_license() {
1228 let mut receipt = minimal_receipt();
1229 receipt.license = Some(LicenseReport {
1230 effective: Some("MIT".to_string()),
1231 findings: vec![LicenseFinding {
1232 spdx: "MIT".to_string(),
1233 confidence: 0.95,
1234 source_path: "LICENSE".to_string(),
1235 source_kind: LicenseSourceKind::Text,
1236 }],
1237 });
1238 let result = render_md(&receipt);
1239 assert!(result.contains("## License radar"));
1240 assert!(result.contains("- Effective: `MIT`"));
1241 assert!(result.contains("|MIT|0.95|LICENSE|Text|"));
1242 }
1243
1244 #[test]
1246 fn test_render_md_license_no_findings() {
1247 let mut receipt = minimal_receipt();
1248 receipt.license = Some(LicenseReport {
1249 effective: None,
1250 findings: vec![],
1251 });
1252 let result = render_md(&receipt);
1253 assert!(result.contains("## License radar"));
1254 assert!(result.contains("Heuristic detection"));
1255 assert!(!result.contains("|SPDX|")); }
1257
1258 #[test]
1260 fn test_render_md_corporate_fingerprint() {
1261 let mut receipt = minimal_receipt();
1262 receipt.corporate_fingerprint = Some(CorporateFingerprint {
1263 domains: vec![DomainStat {
1264 domain: "example.com".to_string(),
1265 commits: 50,
1266 pct: 0.75,
1267 }],
1268 });
1269 let result = render_md(&receipt);
1270 assert!(result.contains("## Corporate fingerprint"));
1271 assert!(result.contains("|example.com|50|75.0%|"));
1272 }
1273
1274 #[test]
1276 fn test_render_md_corporate_fingerprint_no_domains() {
1277 let mut receipt = minimal_receipt();
1278 receipt.corporate_fingerprint = Some(CorporateFingerprint { domains: vec![] });
1279 let result = render_md(&receipt);
1280 assert!(result.contains("## Corporate fingerprint"));
1281 assert!(result.contains("No commit domains detected"));
1282 }
1283
1284 #[test]
1286 fn test_render_md_churn() {
1287 use std::collections::BTreeMap;
1288 let mut per_module = BTreeMap::new();
1289 per_module.insert(
1290 "src".to_string(),
1291 ChurnTrend {
1292 slope: 0.5,
1293 r2: 0.8,
1294 recent_change: 5,
1295 classification: TrendClass::Rising,
1296 },
1297 );
1298 let mut receipt = minimal_receipt();
1299 receipt.predictive_churn = Some(PredictiveChurnReport { per_module });
1300 let result = render_md(&receipt);
1301 assert!(result.contains("## Predictive churn"));
1302 assert!(result.contains("|src|0.5000|0.80|5|Rising|"));
1303 }
1304
1305 #[test]
1307 fn test_render_md_churn_deterministic_tiebreak() {
1308 use std::collections::BTreeMap;
1309
1310 let mut receipt = minimal_receipt();
1311 let mut per_module = BTreeMap::new();
1312 per_module.insert(
1313 "z_module".to_string(),
1314 tokmd_analysis_types::ChurnTrend {
1315 slope: -0.5,
1316 r2: 0.8,
1317 recent_change: 5,
1318 classification: tokmd_analysis_types::TrendClass::Rising,
1319 },
1320 );
1321 per_module.insert(
1322 "a_module".to_string(),
1323 tokmd_analysis_types::ChurnTrend {
1324 slope: -0.5,
1325 r2: 0.8,
1326 recent_change: 5,
1327 classification: tokmd_analysis_types::TrendClass::Rising,
1328 },
1329 );
1330 receipt.predictive_churn = Some(tokmd_analysis_types::PredictiveChurnReport { per_module });
1331
1332 let result = render_md(&receipt);
1333 let a_idx = result.find("|a_module|-0.5000|0.80|5|Rising|").unwrap();
1334 let z_idx = result.find("|z_module|-0.5000|0.80|5|Rising|").unwrap();
1335 assert!(
1336 a_idx < z_idx,
1337 "a_module should appear before z_module for identical slopes"
1338 );
1339 }
1340
1341 #[test]
1342 fn test_render_md_maintenance_deterministic_tiebreak() {
1343 let mut receipt = minimal_receipt();
1344 receipt.git = Some(tokmd_analysis_types::GitReport {
1345 commits_scanned: 10,
1346 files_seen: 10,
1347 hotspots: vec![],
1348 bus_factor: vec![],
1349 freshness: tokmd_analysis_types::FreshnessReport {
1350 threshold_days: 90,
1351 stale_files: 0,
1352 total_files: 0,
1353 stale_pct: 0.0,
1354 by_module: vec![],
1355 },
1356 age_distribution: None,
1357 coupling: vec![],
1358 intent: Some(tokmd_analysis_types::CommitIntentReport {
1359 overall: tokmd_analysis_types::CommitIntentCounts::default(),
1360 by_module: vec![
1361 tokmd_analysis_types::ModuleIntentRow {
1362 module: "z_module".to_string(),
1363 counts: tokmd_analysis_types::CommitIntentCounts {
1364 total: 10,
1365 feat: 0,
1366 fix: 5,
1367 refactor: 0,
1368 chore: 0,
1369 revert: 0,
1370 docs: 0,
1371 test: 0,
1372 ci: 0,
1373 build: 0,
1374 perf: 0,
1375 style: 0,
1376 other: 0,
1377 },
1378 },
1379 tokmd_analysis_types::ModuleIntentRow {
1380 module: "a_module".to_string(),
1381 counts: tokmd_analysis_types::CommitIntentCounts {
1382 total: 10,
1383 feat: 0,
1384 fix: 5,
1385 refactor: 0,
1386 chore: 0,
1387 revert: 0,
1388 docs: 0,
1389 test: 0,
1390 ci: 0,
1391 build: 0,
1392 perf: 0,
1393 style: 0,
1394 other: 0,
1395 },
1396 },
1397 ],
1398 unknown_pct: 0.0,
1399 corrective_ratio: Some(0.0),
1400 }),
1401 });
1402
1403 let result = render_md(&receipt);
1404 let a_idx = result.find("|a_module|5|10|50.0%|").unwrap();
1405 let z_idx = result.find("|z_module|5|10|50.0%|").unwrap();
1406 assert!(
1407 a_idx < z_idx,
1408 "a_module should appear before z_module for identical maintenance shares"
1409 );
1410 }
1411
1412 #[test]
1413 fn test_render_md_churn_empty() {
1414 use std::collections::BTreeMap;
1415 let mut receipt = minimal_receipt();
1416 receipt.predictive_churn = Some(PredictiveChurnReport {
1417 per_module: BTreeMap::new(),
1418 });
1419 let result = render_md(&receipt);
1420 assert!(result.contains("## Predictive churn"));
1421 assert!(result.contains("No churn signals detected"));
1422 }
1423
1424 #[test]
1426 fn test_render_md_assets() {
1427 let mut receipt = minimal_receipt();
1428 receipt.assets = Some(AssetReport {
1429 total_files: 5,
1430 total_bytes: 1000000,
1431 categories: vec![AssetCategoryRow {
1432 category: "images".to_string(),
1433 files: 3,
1434 bytes: 500000,
1435 extensions: vec!["png".to_string(), "jpg".to_string()],
1436 }],
1437 top_files: vec![AssetFileRow {
1438 path: "logo.png".to_string(),
1439 bytes: 100000,
1440 category: "images".to_string(),
1441 extension: "png".to_string(),
1442 }],
1443 });
1444 let result = render_md(&receipt);
1445 assert!(result.contains("## Assets"));
1446 assert!(result.contains("- Total files: `5`"));
1447 assert!(result.contains("|images|3|500000|png, jpg|"));
1448 assert!(result.contains("|logo.png|100000|images|"));
1449 }
1450
1451 #[test]
1453 fn test_render_md_assets_empty() {
1454 let mut receipt = minimal_receipt();
1455 receipt.assets = Some(AssetReport {
1456 total_files: 0,
1457 total_bytes: 0,
1458 categories: vec![],
1459 top_files: vec![],
1460 });
1461 let result = render_md(&receipt);
1462 assert!(result.contains("## Assets"));
1463 assert!(result.contains("- Total files: `0`"));
1464 assert!(!result.contains("|Category|")); }
1466
1467 #[test]
1469 fn test_render_md_deps() {
1470 let mut receipt = minimal_receipt();
1471 receipt.deps = Some(DependencyReport {
1472 total: 50,
1473 lockfiles: vec![LockfileReport {
1474 path: "Cargo.lock".to_string(),
1475 kind: "cargo".to_string(),
1476 dependencies: 50,
1477 }],
1478 });
1479 let result = render_md(&receipt);
1480 assert!(result.contains("## Dependencies"));
1481 assert!(result.contains("- Total: `50`"));
1482 assert!(result.contains("|Cargo.lock|cargo|50|"));
1483 }
1484
1485 #[test]
1487 fn test_render_md_deps_empty() {
1488 let mut receipt = minimal_receipt();
1489 receipt.deps = Some(DependencyReport {
1490 total: 0,
1491 lockfiles: vec![],
1492 });
1493 let result = render_md(&receipt);
1494 assert!(result.contains("## Dependencies"));
1495 assert!(!result.contains("|Lockfile|"));
1496 }
1497
1498 #[test]
1500 fn test_render_md_git() {
1501 let mut receipt = minimal_receipt();
1502 receipt.git = Some(GitReport {
1503 commits_scanned: 100,
1504 files_seen: 50,
1505 hotspots: vec![HotspotRow {
1506 path: "src/lib.rs".to_string(),
1507 commits: 25,
1508 lines: 500,
1509 score: 12500,
1510 }],
1511 bus_factor: vec![BusFactorRow {
1512 module: "src".to_string(),
1513 authors: 3,
1514 }],
1515 freshness: FreshnessReport {
1516 threshold_days: 90,
1517 stale_files: 5,
1518 total_files: 50,
1519 stale_pct: 0.1,
1520 by_module: vec![ModuleFreshnessRow {
1521 module: "src".to_string(),
1522 avg_days: 30.0,
1523 p90_days: 60.0,
1524 stale_pct: 0.05,
1525 }],
1526 },
1527 coupling: vec![CouplingRow {
1528 left: "src/a.rs".to_string(),
1529 right: "src/b.rs".to_string(),
1530 count: 10,
1531 jaccard: Some(0.5),
1532 lift: Some(1.2),
1533 n_left: Some(15),
1534 n_right: Some(12),
1535 }],
1536 age_distribution: Some(CodeAgeDistributionReport {
1537 buckets: vec![CodeAgeBucket {
1538 label: "0-30d".to_string(),
1539 min_days: 0,
1540 max_days: Some(30),
1541 files: 10,
1542 pct: 0.2,
1543 }],
1544 recent_refreshes: 12,
1545 prior_refreshes: 8,
1546 refresh_trend: TrendClass::Rising,
1547 }),
1548 intent: None,
1549 });
1550 let result = render_md(&receipt);
1551 assert!(result.contains("## Git metrics"));
1552 assert!(result.contains("- Commits scanned: `100`"));
1553 assert!(result.contains("|src/lib.rs|25|500|12500|"));
1554 assert!(result.contains("|src|3|"));
1555 assert!(result.contains("Stale threshold (days): `90`"));
1556 assert!(result.contains("|src|30.00|60.00|5.0%|"));
1557 assert!(result.contains("### Code age"));
1558 assert!(result.contains("Refresh trend: `Rising`"));
1559 assert!(result.contains("|0-30d|0|30|10|20.0%|"));
1560 assert!(result.contains("|src/a.rs|src/b.rs|10|"));
1561 }
1562
1563 #[test]
1565 fn test_render_md_git_empty() {
1566 let mut receipt = minimal_receipt();
1567 receipt.git = Some(GitReport {
1568 commits_scanned: 0,
1569 files_seen: 0,
1570 hotspots: vec![],
1571 bus_factor: vec![],
1572 freshness: FreshnessReport {
1573 threshold_days: 90,
1574 stale_files: 0,
1575 total_files: 0,
1576 stale_pct: 0.0,
1577 by_module: vec![],
1578 },
1579 coupling: vec![],
1580 age_distribution: None,
1581 intent: None,
1582 });
1583 let result = render_md(&receipt);
1584 assert!(result.contains("## Git metrics"));
1585 assert!(!result.contains("### Hotspots"));
1586 assert!(!result.contains("### Bus factor"));
1587 assert!(!result.contains("### Coupling"));
1588 }
1589
1590 #[test]
1592 fn test_render_md_imports() {
1593 let mut receipt = minimal_receipt();
1594 receipt.imports = Some(ImportReport {
1595 granularity: "file".to_string(),
1596 edges: vec![ImportEdge {
1597 from: "src/main.rs".to_string(),
1598 to: "src/lib.rs".to_string(),
1599 count: 5,
1600 }],
1601 });
1602 let result = render_md(&receipt);
1603 assert!(result.contains("## Imports"));
1604 assert!(result.contains("- Granularity: `file`"));
1605 assert!(result.contains("|src/main.rs|src/lib.rs|5|"));
1606 }
1607
1608 #[test]
1610 fn test_render_md_imports_empty() {
1611 let mut receipt = minimal_receipt();
1612 receipt.imports = Some(ImportReport {
1613 granularity: "module".to_string(),
1614 edges: vec![],
1615 });
1616 let result = render_md(&receipt);
1617 assert!(result.contains("## Imports"));
1618 assert!(!result.contains("|From|To|"));
1619 }
1620
1621 #[test]
1623 fn test_render_md_dup() {
1624 let mut receipt = minimal_receipt();
1625 receipt.dup = Some(DuplicateReport {
1626 wasted_bytes: 50000,
1627 strategy: "content".to_string(),
1628 groups: vec![DuplicateGroup {
1629 hash: "abc123".to_string(),
1630 bytes: 1000,
1631 files: vec!["a.txt".to_string(), "b.txt".to_string()],
1632 }],
1633 density: Some(DuplicationDensityReport {
1634 duplicate_groups: 1,
1635 duplicate_files: 2,
1636 duplicated_bytes: 2000,
1637 wasted_bytes: 1000,
1638 wasted_pct_of_codebase: 0.1,
1639 by_module: vec![ModuleDuplicationDensityRow {
1640 module: "src".to_string(),
1641 duplicate_files: 2,
1642 wasted_files: 1,
1643 duplicated_bytes: 2000,
1644 wasted_bytes: 1000,
1645 module_bytes: 10_000,
1646 density: 0.1,
1647 }],
1648 }),
1649 near: None,
1650 });
1651 let result = render_md(&receipt);
1652 assert!(result.contains("## Duplicates"));
1653 assert!(result.contains("- Wasted bytes: `50000`"));
1654 assert!(result.contains("### Duplication density"));
1655 assert!(result.contains("Waste vs codebase: `10.0%`"));
1656 assert!(result.contains("|src|2|1|2000|1000|10000|10.0%|"));
1657 assert!(result.contains("|abc123|1000|2|")); }
1659
1660 #[test]
1662 fn test_render_md_dup_empty() {
1663 let mut receipt = minimal_receipt();
1664 receipt.dup = Some(DuplicateReport {
1665 wasted_bytes: 0,
1666 strategy: "content".to_string(),
1667 groups: vec![],
1668 density: None,
1669 near: None,
1670 });
1671 let result = render_md(&receipt);
1672 assert!(result.contains("## Duplicates"));
1673 assert!(!result.contains("|Hash|Bytes|"));
1674 }
1675
1676 #[test]
1678 fn test_render_md_fun() {
1679 let mut receipt = minimal_receipt();
1680 receipt.fun = Some(FunReport {
1681 eco_label: Some(EcoLabel {
1682 label: "A+".to_string(),
1683 score: 95.5,
1684 bytes: 10000,
1685 notes: "Very efficient".to_string(),
1686 }),
1687 });
1688 let result = render_md(&receipt);
1689 assert!(result.contains("## Eco label"));
1690 assert!(result.contains("- Label: `A+`"));
1691 assert!(result.contains("- Score: `95.5`"));
1692 }
1693
1694 #[test]
1696 fn test_render_md_fun_no_label() {
1697 let mut receipt = minimal_receipt();
1698 receipt.fun = Some(FunReport { eco_label: None });
1699 let result = render_md(&receipt);
1700 assert!(!result.contains("## Eco label"));
1702 }
1703
1704 #[test]
1706 fn test_render_md_derived() {
1707 let mut receipt = minimal_receipt();
1708 receipt.derived = Some(sample_derived());
1709 let result = render_md(&receipt);
1710 assert!(result.contains("## Totals"));
1711 assert!(result.contains("|10|1000|200|100|1300|50000|2500|"));
1712 assert!(result.contains("## Ratios"));
1713 assert!(result.contains("## Distribution"));
1714 assert!(result.contains("## File size histogram"));
1715 assert!(result.contains("## Top offenders"));
1716 assert!(result.contains("## Structure"));
1717 assert!(result.contains("## Test density"));
1718 assert!(result.contains("## TODOs"));
1719 assert!(result.contains("## Boilerplate ratio"));
1720 assert!(result.contains("## Polyglot"));
1721 assert!(result.contains("## Reading time"));
1722 assert!(result.contains("## Context window"));
1723 assert!(result.contains("## Effort estimate"));
1724 assert!(result.contains("### Size basis"));
1725 assert!(result.contains("### Headline"));
1726 assert!(result.contains("### Why"));
1727 assert!(result.contains("### Delta"));
1728 assert!(result.contains("## Integrity"));
1729 }
1730
1731 #[test]
1733 fn test_render_dispatch_md() {
1734 let receipt = minimal_receipt();
1735 let result = render(&receipt, AnalysisFormat::Md).unwrap();
1736 match result {
1737 RenderedOutput::Text(s) => assert!(s.starts_with("# tokmd analysis")),
1738 RenderedOutput::Binary(_) => panic!("expected text"),
1739 }
1740 }
1741
1742 #[test]
1743 fn test_render_dispatch_json() {
1744 let receipt = minimal_receipt();
1745 let result = render(&receipt, AnalysisFormat::Json).unwrap();
1746 match result {
1747 RenderedOutput::Text(s) => assert!(s.contains("\"schema_version\": 2")),
1748 RenderedOutput::Binary(_) => panic!("expected text"),
1749 }
1750 }
1751
1752 #[test]
1753 fn test_render_dispatch_xml() {
1754 let receipt = minimal_receipt();
1755 let result = render(&receipt, AnalysisFormat::Xml).unwrap();
1756 match result {
1757 RenderedOutput::Text(s) => assert!(s.contains("<analysis>")),
1758 RenderedOutput::Binary(_) => panic!("expected text"),
1759 }
1760 }
1761
1762 #[test]
1763 fn test_render_dispatch_tree() {
1764 let receipt = minimal_receipt();
1765 let result = render(&receipt, AnalysisFormat::Tree).unwrap();
1766 match result {
1767 RenderedOutput::Text(s) => assert!(s.contains("(tree unavailable)")),
1768 RenderedOutput::Binary(_) => panic!("expected text"),
1769 }
1770 }
1771
1772 #[test]
1773 fn test_render_dispatch_svg() {
1774 let receipt = minimal_receipt();
1775 let result = render(&receipt, AnalysisFormat::Svg).unwrap();
1776 match result {
1777 RenderedOutput::Text(s) => assert!(s.contains("<svg")),
1778 RenderedOutput::Binary(_) => panic!("expected text"),
1779 }
1780 }
1781
1782 #[test]
1783 fn test_render_dispatch_mermaid() {
1784 let receipt = minimal_receipt();
1785 let result = render(&receipt, AnalysisFormat::Mermaid).unwrap();
1786 match result {
1787 RenderedOutput::Text(s) => assert!(s.starts_with("graph TD")),
1788 RenderedOutput::Binary(_) => panic!("expected text"),
1789 }
1790 }
1791
1792 #[test]
1793 fn test_render_dispatch_jsonld() {
1794 let receipt = minimal_receipt();
1795 let result = render(&receipt, AnalysisFormat::Jsonld).unwrap();
1796 match result {
1797 RenderedOutput::Text(s) => assert!(s.contains("@context")),
1798 RenderedOutput::Binary(_) => panic!("expected text"),
1799 }
1800 }
1801
1802 #[test]
1804 fn test_render_html() {
1805 let mut receipt = minimal_receipt();
1806 receipt.derived = Some(sample_derived());
1807 let result = render_html(&receipt);
1808 assert!(result.contains("<!DOCTYPE html>") || result.contains("<html"));
1809 }
1810
1811 #[allow(dead_code)]
1813 fn test_derived_report_for_effort(code_lines: usize) -> DerivedReport {
1814 let ratio_zero = RatioReport {
1815 total: RatioRow {
1816 key: "total".into(),
1817 numerator: 0,
1818 denominator: code_lines,
1819 ratio: 0.0,
1820 },
1821 by_lang: vec![],
1822 by_module: vec![],
1823 };
1824
1825 let rate_zero = RateReport {
1826 total: RateRow {
1827 key: "total".into(),
1828 numerator: 0,
1829 denominator: code_lines,
1830 rate: 0.0,
1831 },
1832 by_lang: vec![],
1833 by_module: vec![],
1834 };
1835
1836 DerivedReport {
1837 totals: DerivedTotals {
1838 files: 10,
1839 code: code_lines,
1840 comments: 100,
1841 blanks: 50,
1842 lines: code_lines + 150,
1843 bytes: code_lines * 40,
1844 tokens: code_lines * 3,
1845 },
1846 doc_density: ratio_zero.clone(),
1847 whitespace: ratio_zero,
1848 verbosity: rate_zero,
1849 max_file: MaxFileReport {
1850 overall: FileStatRow {
1851 path: "src/main.rs".into(),
1852 module: "src".into(),
1853 lang: "Rust".into(),
1854 code: code_lines,
1855 comments: 0,
1856 blanks: 0,
1857 lines: code_lines,
1858 bytes: code_lines * 40,
1859 tokens: code_lines * 3,
1860 doc_pct: None,
1861 bytes_per_line: Some(40.0),
1862 depth: 1,
1863 },
1864 by_lang: vec![],
1865 by_module: vec![],
1866 },
1867 lang_purity: LangPurityReport { rows: vec![] },
1868 nesting: NestingReport {
1869 max: 1,
1870 avg: 1.0,
1871 by_module: vec![],
1872 },
1873 test_density: TestDensityReport {
1874 test_lines: 0,
1875 prod_lines: code_lines,
1876 test_files: 0,
1877 prod_files: 10,
1878 ratio: 0.0,
1879 },
1880 boilerplate: BoilerplateReport {
1881 infra_lines: 0,
1882 logic_lines: code_lines,
1883 ratio: 0.0,
1884 infra_langs: vec![],
1885 },
1886 polyglot: PolyglotReport {
1887 lang_count: 1,
1888 entropy: 0.0,
1889 dominant_lang: "Rust".into(),
1890 dominant_lines: code_lines,
1891 dominant_pct: 1.0,
1892 },
1893 distribution: DistributionReport {
1894 count: 10,
1895 min: 10,
1896 max: code_lines,
1897 mean: code_lines as f64 / 10.0,
1898 median: code_lines as f64 / 10.0,
1899 p90: code_lines as f64,
1900 p99: code_lines as f64,
1901 gini: 0.0,
1902 },
1903 histogram: vec![],
1904 top: TopOffenders {
1905 largest_lines: vec![],
1906 largest_tokens: vec![],
1907 largest_bytes: vec![],
1908 least_documented: vec![],
1909 most_dense: vec![],
1910 },
1911 tree: None,
1912 reading_time: ReadingTimeReport {
1913 minutes: 1.0,
1914 lines_per_minute: 200,
1915 basis_lines: code_lines,
1916 },
1917 context_window: None,
1918 cocomo: None,
1919 todo: None,
1920 integrity: IntegrityReport {
1921 algo: "blake3".into(),
1922 hash: "test".into(),
1923 entries: 10,
1924 },
1925 }
1926 }
1927}