Skip to main content

tokmd_analysis_format/
lib.rs

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