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