Skip to main content

tokmd_format/analysis/
mod.rs

1//! Analysis receipt rendering.
2//!
3//! This module owns analysis-specific formatting under the durable
4//! `tokmd-format` capability crate. It supports Markdown, JSON, JSON-LD, XML,
5//! SVG, Mermaid, HTML, and optional fun outputs.
6//!
7//! ## Effort rendering
8//!
9//! Effort sections are rendered in two tiers:
10//!
11//! 1. `receipt.effort` the preferred path for the newer effort-estimation
12//!    receipt surface. This can render size basis, confidence, drivers,
13//!    assumptions, and optional delta data.
14//! 2. `derived.cocomo` a legacy fallback used when the richer `effort`
15//!    section is absent but classic derived COCOMO data is present.
16//!
17//! The formatter intentionally renders whatever the receipt contains without
18//! inferring missing estimate data. If the upstream effort builder is still
19//! scaffold-only, the formatter preserves that truth rather than making the
20//! estimate look more complete than it is.
21//!
22//! ## What belongs here
23//! * Analysis receipt rendering to various formats
24//! * Format-specific transformations
25//! * Fun output integration (OBJ, MIDI when enabled)
26//!
27//! ## What does NOT belong here
28//! * Analysis computation (use tokmd-analysis)
29//! * CLI argument parsing
30//! * Analysis computation (use tokmd-analysis)
31
32use 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// --- fun enabled impls ---
160#[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(&notes, 120)
205}
206
207// --- fun disabled impls (errors) ---
208#[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
222// --- stable API names used by the rest of the code ---
223fn 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 render_xml
475    #[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 render_xml without derived
487    #[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 render_jsonld
495    #[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 render_jsonld without inputs
507    #[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 render_svg
516    #[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")); // has context_window
524        assert!(result.contains("2.5%")); // pct value
525    }
526
527    // Test render_svg without context_window
528    #[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")); // total tokens
537    }
538
539    // Test render_svg without derived
540    #[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<")); // default 0 value
546    }
547
548    // Test render_svg arithmetic (width - label_width = value_width)
549    #[test]
550    fn test_render_svg_dimensions() {
551        let receipt = minimal_receipt();
552        let result = render_svg(&receipt);
553        // width=240, label_width=80, value_width should be 160
554        assert!(result.contains("width=\"160\"")); // value_width = 240 - 80
555    }
556
557    // Test render_mermaid
558    #[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 render_mermaid no imports
575    #[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 render_tree
583    #[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 render_tree without derived
592    #[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 render_tree with no tree in derived
600    #[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    // Test render_obj (non-fun feature) returns error
611    #[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    // Test render_midi (non-fun feature) returns error
621    #[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    // Test render_obj with fun feature - verify coordinate calculations
631    // This test uses precise vertex extraction to catch arithmetic mutants:
632    // - idx % 5 vs idx / 5 (grid position)
633    // - * 2.0 multiplier
634    // - lines / 10.0 for height
635    // - .max(0.5) clamping
636    #[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        // Build test data with specific indices and line counts to verify:
642        // x = (idx % 5) * 2.0
643        // y = (idx / 5) * 2.0
644        // h = (lines / 10.0).max(0.5)
645        //
646        // idx=0: x=0*2=0, y=0*2=0
647        // idx=4: x=4*2=8, y=0*2=0 (tests % 5 at boundary)
648        // idx=5: x=0*2=0, y=1*2=2 (tests % 5 wrap and / 5 increment)
649        // idx=6: x=1*2=2, y=1*2=2
650        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, // h = 100/10 = 10.0
659                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, // h = 3/10 = 0.3 -> clamped to 0.5 by .max(0.5)
673                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, // h = 200/10 = 20.0
687                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, // h = 75/10 = 7.5
701                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, // h = 150/10 = 15.0
715                bytes: 1500,
716                tokens: 300,
717                doc_pct: None,
718                bytes_per_line: None,
719                depth: 1,
720            },
721            // idx=5: x = (5%5)*2 = 0, y = (5/5)*2 = 2
722            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, // h = 80/10 = 8.0
730                bytes: 800,
731                tokens: 160,
732                doc_pct: None,
733                bytes_per_line: None,
734                depth: 2,
735            },
736            // idx=6: x = (6%5)*2 = 2, y = (6/5)*2 = 2
737            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, // h = 60/10 = 6.0
745                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        // Parse the OBJ output into objects with their vertices
756        // Each object starts with "o <name>" followed by 8 vertices
757        #[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        // Verify we have 7 objects
781        assert_eq!(objects.len(), 7, "expected 7 buildings");
782
783        // Helper to get first vertex (base corner) of each object
784        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] // 5th vertex is top of first corner
789        }
790
791        // idx=0: x=0, y=0, h=10
792        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        // idx=1: x=2, y=0, h=0.5 (clamped from 0.3)
804        // Tests: * 2.0 multiplier, .max(0.5) clamping
805        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        // idx=2: x=4, y=0, h=20
817        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        // idx=3: x=6, y=0, h=7.5
829        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        // idx=4: x=8, y=0, h=15
841        // Tests: % 5 at boundary (4 % 5 = 4, not 0)
842        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        // idx=5: x=0, y=2, h=8
854        // Tests: % 5 wrapping (5 % 5 = 0), / 5 incrementing (5 / 5 = 1)
855        // Catches mutations: % -> / would give x=2, / -> % would give y=0
856        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        // idx=6: x=2, y=2, h=6
868        // Tests: both % and / together at idx=6
869        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        // Verify face definitions exist (basic structural check)
881        assert!(result.contains("f 1 2 3 4"), "missing face definition");
882    }
883
884    // Test render_midi with fun feature - verify note calculations using midly parser
885    // This test verifies arithmetic correctness for:
886    // - key = 60 + (depth % 12)
887    // - velocity = min(40 + min(lines, 127) / 2, 120)
888    // - start = idx * 240
889    #[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        // Create rows with specific depths and lines to verify math
897        // Each row maps to a note:
898        //   key = 60 + (depth % 12)
899        //   velocity = (40 + (lines.min(127) / 2)).min(120)
900        //   start = idx * 240
901        derived.top.largest_lines = vec![
902            // idx=0: key=60+(5%12)=65, vel=40+(60/2)=70, start=0*240=0
903            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            // idx=1: key=60+(15%12)=63, vel=40+(127/2)=103, start=1*240=240
918            // Tests: % 12 wrapping (15 % 12 = 3), lines clamped at 127
919            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, // clamped to 127 for velocity calc
927                bytes: 1000,
928                tokens: 200,
929                doc_pct: None,
930                bytes_per_line: None,
931                depth: 15,
932            },
933            // idx=2: key=60+(0%12)=60, vel=40+(20/2)=50, start=2*240=480
934            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            // idx=3: key=60+(12%12)=60, vel=40+(min(160,127)/2)=40+(127/2)=40+63=103, start=3*240=720
949            // Tests: % 12 at boundary (12 % 12 = 0)
950            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        // Parse with midly
970        let smf = Smf::parse(&result).expect("should parse as valid MIDI");
971
972        // Collect NoteOn events with their absolute times
973        let mut notes: Vec<(u32, u8, u8)> = Vec::new(); // (time, key, velocity)
974        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        // Should have 4 NoteOn events
988        assert_eq!(notes.len(), 4, "expected 4 NoteOn events, got {:?}", notes);
989
990        // Verify each note precisely
991        // Note 0: time=0, key=65, velocity=70
992        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        // Note 1: time=240, key=63, velocity=103
1000        // key=60+(15%12)=60+3=63, vel=40+(127/2)=40+63=103
1001        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        // Note 2: time=480, key=60, velocity=50
1009        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        // Note 3: time=720, key=60, velocity=103
1017        // key=60+(12%12)=60+0=60, vel=40+(min(160,127)/2)=40+63=103
1018        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        // Verify NoteOff timing too (duration=180)
1026        let mut note_offs: Vec<(u32, u8)> = Vec::new(); // (time, key)
1027        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        // NoteOff times should be start + 180
1040        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    // Test render_midi with empty derived - should still produce valid MIDI
1063    #[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        // Should produce a valid MIDI (not empty, parseable)
1072        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        // Parse and verify structure
1079        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    // Test render_obj with no derived data
1084    #[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        // Should return fallback string when no derived data
1091        assert_eq!(result, "# tokmd code city\n");
1092    }
1093
1094    // Test render_md basic structure
1095    #[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 render_md with inputs
1104    #[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 render_md empty inputs - should NOT have inputs section
1115    #[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 render_md with archetype
1124    #[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 render_md with archetype empty evidence
1138    #[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 render_md with topics
1152    #[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 render_md with topics empty module terms
1182    #[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        // Empty module should be skipped
1194        assert!(!result.contains("empty_module"));
1195    }
1196
1197    // Test render_md with entropy
1198    #[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 render_md with entropy no suspects
1216    #[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 render_md with license
1226    #[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 render_md with license empty findings
1245    #[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|")); // No table header
1256    }
1257
1258    // Test render_md with corporate fingerprint
1259    #[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 render_md with corporate fingerprint no domains
1275    #[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 render_md with predictive churn
1285    #[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 render_md with predictive churn empty
1306    #[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 render_md with assets
1425    #[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 render_md with assets empty categories
1452    #[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|")); // No table
1465    }
1466
1467    // Test render_md with deps
1468    #[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 render_md with deps empty lockfiles
1486    #[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 render_md with git
1499    #[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 render_md with git empty sections
1564    #[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 render_md with imports
1591    #[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 render_md with imports empty
1609    #[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 render_md with dup
1622    #[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|")); // 2 files
1658    }
1659
1660    // Test render_md with dup empty
1661    #[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 render_md with fun eco_label
1677    #[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 render_md with fun no eco_label
1695    #[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        // No eco label section should appear
1701        assert!(!result.contains("## Eco label"));
1702    }
1703
1704    // Test render_md with derived
1705    #[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 render function dispatch
1732    #[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 render_html
1803    #[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    /// Markdown rendering.
1812    #[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}