Skip to main content

tokmd_format/
lib.rs

1//! # tokmd-format
2//!
3//! **Tier 2 (Formatting)**
4//!
5//! This crate handles the rendering and serialization of `tokmd` receipts.
6//! It supports Markdown, TSV, JSON, JSONL, CSV, and CycloneDX formats.
7//!
8//! ## What belongs here
9//! * Serialization logic (JSON/CSV/CycloneDX)
10//! * Markdown and TSV table rendering
11//! * Output file writing
12//! * Redaction integration (via tokmd-redact re-exports)
13//! * ScanArgs construction (single source of truth)
14//!
15//! ## What does NOT belong here
16//! * Business logic (calculating stats)
17//! * CLI argument parsing
18//! * Analysis-specific formatting (use tokmd-analysis-format)
19
20use std::borrow::Cow;
21use std::fmt::Write as FmtWrite;
22use std::fs::File;
23use std::io::{self, BufWriter, Write};
24use std::path::Path;
25use std::path::PathBuf;
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use anyhow::Result;
29use serde::Serialize;
30use time::OffsetDateTime;
31use time::format_description::well_known::Rfc3339;
32
33use tokmd_config::GlobalArgs;
34use tokmd_types::{
35    ExportArgs, ExportArgsMeta, ExportData, ExportFormat, ExportReceipt, FileKind, FileRow,
36    LangArgs, LangArgsMeta, LangReceipt, LangReport, ModuleArgs, ModuleArgsMeta, ModuleReceipt,
37    ModuleReport, RedactMode, ScanArgs, ScanStatus, TableFormat, ToolInfo,
38};
39
40fn now_ms() -> u128 {
41    SystemTime::now()
42        .duration_since(UNIX_EPOCH)
43        .unwrap_or_default()
44        .as_millis()
45}
46
47/// Normalize a path to forward slashes and strip leading `./` for cross-platform stability.
48///
49/// This is the canonical normalization function for scan inputs. Use this
50/// before storing paths in receipts to ensure consistent output across OS.
51pub fn normalize_scan_input(p: &Path) -> String {
52    let mut s = p.display().to_string().replace('\\', "/");
53    while s.starts_with("./") {
54        s = s.strip_prefix("./").unwrap().to_string();
55    }
56    if s.is_empty() { ".".to_string() } else { s }
57}
58
59/// Construct `ScanArgs` with optional redaction applied.
60///
61/// This is the single source of truth for building `ScanArgs` from CLI inputs.
62/// All commands that produce receipts should use this function to ensure
63/// consistent redaction and normalization behavior.
64///
65/// # Redaction Behavior
66///
67/// - `None` or `Some(RedactMode::None)`: Paths shown as-is (normalized only)
68/// - `Some(RedactMode::Paths)`: Hash file paths, preserve extension
69/// - `Some(RedactMode::All)`: Hash paths and excluded patterns
70pub fn scan_args(paths: &[PathBuf], global: &GlobalArgs, redact: Option<RedactMode>) -> ScanArgs {
71    let should_redact = redact == Some(RedactMode::Paths) || redact == Some(RedactMode::All);
72    let excluded_redacted = should_redact && !global.excluded.is_empty();
73
74    let mut args = ScanArgs {
75        paths: paths.iter().map(|p| normalize_scan_input(p)).collect(),
76        excluded: if should_redact {
77            global.excluded.iter().map(|p| short_hash(p)).collect()
78        } else {
79            global.excluded.clone()
80        },
81        excluded_redacted,
82        config: global.config,
83        hidden: global.hidden,
84        no_ignore: global.no_ignore,
85        no_ignore_parent: global.no_ignore || global.no_ignore_parent,
86        no_ignore_dot: global.no_ignore || global.no_ignore_dot,
87        no_ignore_vcs: global.no_ignore || global.no_ignore_vcs,
88        treat_doc_strings_as_comments: global.treat_doc_strings_as_comments,
89    };
90
91    if should_redact {
92        args.paths = args.paths.iter().map(|p| redact_path(p)).collect();
93    }
94    args
95}
96
97// -----------------------
98// Language summary output
99// -----------------------
100
101/// Write a language report to a writer.
102///
103/// This is the core implementation that can be tested with any `Write` sink.
104pub fn write_lang_report_to<W: Write>(
105    mut out: W,
106    report: &LangReport,
107    global: &GlobalArgs,
108    args: &LangArgs,
109) -> Result<()> {
110    match args.format {
111        TableFormat::Md => {
112            out.write_all(render_lang_md(report).as_bytes())?;
113        }
114        TableFormat::Tsv => {
115            out.write_all(render_lang_tsv(report).as_bytes())?;
116        }
117        TableFormat::Json => {
118            let receipt = LangReceipt {
119                schema_version: tokmd_types::SCHEMA_VERSION,
120                generated_at_ms: now_ms(),
121                tool: ToolInfo::current(),
122                mode: "lang".to_string(),
123                status: ScanStatus::Complete,
124                warnings: vec![],
125                scan: scan_args(&args.paths, global, None),
126                args: LangArgsMeta {
127                    format: "json".to_string(),
128                    top: report.top,
129                    with_files: report.with_files,
130                    children: report.children,
131                },
132                report: report.clone(),
133            };
134            writeln!(out, "{}", serde_json::to_string(&receipt)?)?;
135        }
136    }
137    Ok(())
138}
139
140/// Print a language report to stdout.
141///
142/// Thin wrapper around [`write_lang_report_to`] for stdout.
143pub fn print_lang_report(report: &LangReport, global: &GlobalArgs, args: &LangArgs) -> Result<()> {
144    let stdout = io::stdout();
145    let out = stdout.lock();
146    write_lang_report_to(out, report, global, args)
147}
148
149fn render_lang_md(report: &LangReport) -> String {
150    let mut s = String::new();
151
152    if report.with_files {
153        s.push_str("|Lang|Code|Lines|Files|Bytes|Tokens|Avg|\n");
154        s.push_str("|---|---:|---:|---:|---:|---:|---:|\n");
155        for r in &report.rows {
156            let _ = writeln!(
157                s,
158                "|{}|{}|{}|{}|{}|{}|{}|",
159                r.lang, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
160            );
161        }
162        let _ = writeln!(
163            s,
164            "|**Total**|{}|{}|{}|{}|{}|{}|",
165            report.total.code,
166            report.total.lines,
167            report.total.files,
168            report.total.bytes,
169            report.total.tokens,
170            report.total.avg_lines
171        );
172    } else {
173        s.push_str("|Lang|Code|Lines|Bytes|Tokens|\n");
174        s.push_str("|---|---:|---:|---:|---:|\n");
175        for r in &report.rows {
176            let _ = writeln!(
177                s,
178                "|{}|{}|{}|{}|{}|",
179                r.lang, r.code, r.lines, r.bytes, r.tokens
180            );
181        }
182        let _ = writeln!(
183            s,
184            "|**Total**|{}|{}|{}|{}|",
185            report.total.code, report.total.lines, report.total.bytes, report.total.tokens
186        );
187    }
188
189    s
190}
191
192fn render_lang_tsv(report: &LangReport) -> String {
193    let mut s = String::new();
194
195    if report.with_files {
196        s.push_str("Lang\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n");
197        for r in &report.rows {
198            let _ = writeln!(
199                s,
200                "{}\t{}\t{}\t{}\t{}\t{}\t{}",
201                r.lang, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
202            );
203        }
204        let _ = writeln!(
205            s,
206            "Total\t{}\t{}\t{}\t{}\t{}\t{}",
207            report.total.code,
208            report.total.lines,
209            report.total.files,
210            report.total.bytes,
211            report.total.tokens,
212            report.total.avg_lines
213        );
214    } else {
215        s.push_str("Lang\tCode\tLines\tBytes\tTokens\n");
216        for r in &report.rows {
217            let _ = writeln!(
218                s,
219                "{}\t{}\t{}\t{}\t{}",
220                r.lang, r.code, r.lines, r.bytes, r.tokens
221            );
222        }
223        let _ = writeln!(
224            s,
225            "Total\t{}\t{}\t{}\t{}",
226            report.total.code, report.total.lines, report.total.bytes, report.total.tokens
227        );
228    }
229
230    s
231}
232
233// ---------------------
234// Module summary output
235// ---------------------
236
237/// Write a module report to a writer.
238///
239/// This is the core implementation that can be tested with any `Write` sink.
240pub fn write_module_report_to<W: Write>(
241    mut out: W,
242    report: &ModuleReport,
243    global: &GlobalArgs,
244    args: &ModuleArgs,
245) -> Result<()> {
246    match args.format {
247        TableFormat::Md => {
248            out.write_all(render_module_md(report).as_bytes())?;
249        }
250        TableFormat::Tsv => {
251            out.write_all(render_module_tsv(report).as_bytes())?;
252        }
253        TableFormat::Json => {
254            let receipt = ModuleReceipt {
255                schema_version: tokmd_types::SCHEMA_VERSION,
256                generated_at_ms: now_ms(),
257                tool: ToolInfo::current(),
258                mode: "module".to_string(),
259                status: ScanStatus::Complete,
260                warnings: vec![],
261                scan: scan_args(&args.paths, global, None),
262                args: ModuleArgsMeta {
263                    format: "json".to_string(),
264                    top: report.top,
265                    module_roots: report.module_roots.clone(),
266                    module_depth: report.module_depth,
267                    children: report.children,
268                },
269                report: report.clone(),
270            };
271            writeln!(out, "{}", serde_json::to_string(&receipt)?)?;
272        }
273    }
274    Ok(())
275}
276
277/// Print a module report to stdout.
278///
279/// Thin wrapper around [`write_module_report_to`] for stdout.
280pub fn print_module_report(
281    report: &ModuleReport,
282    global: &GlobalArgs,
283    args: &ModuleArgs,
284) -> Result<()> {
285    let stdout = io::stdout();
286    let out = stdout.lock();
287    write_module_report_to(out, report, global, args)
288}
289
290fn render_module_md(report: &ModuleReport) -> String {
291    let mut s = String::new();
292    s.push_str("|Module|Code|Lines|Files|Bytes|Tokens|Avg|\n");
293    s.push_str("|---|---:|---:|---:|---:|---:|---:|\n");
294    for r in &report.rows {
295        let _ = writeln!(
296            s,
297            "|{}|{}|{}|{}|{}|{}|{}|",
298            r.module, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
299        );
300    }
301    let _ = writeln!(
302        s,
303        "|**Total**|{}|{}|{}|{}|{}|{}|",
304        report.total.code,
305        report.total.lines,
306        report.total.files,
307        report.total.bytes,
308        report.total.tokens,
309        report.total.avg_lines
310    );
311    s
312}
313
314fn render_module_tsv(report: &ModuleReport) -> String {
315    let mut s = String::new();
316    s.push_str("Module\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n");
317    for r in &report.rows {
318        let _ = writeln!(
319            s,
320            "{}\t{}\t{}\t{}\t{}\t{}\t{}",
321            r.module, r.code, r.lines, r.files, r.bytes, r.tokens, r.avg_lines
322        );
323    }
324    let _ = writeln!(
325        s,
326        "Total\t{}\t{}\t{}\t{}\t{}\t{}",
327        report.total.code,
328        report.total.lines,
329        report.total.files,
330        report.total.bytes,
331        report.total.tokens,
332        report.total.avg_lines
333    );
334    s
335}
336
337// -----------------
338// Export (datasets)
339// -----------------
340
341#[derive(Debug, Clone, Serialize)]
342struct ExportMeta {
343    #[serde(rename = "type")]
344    ty: &'static str,
345    schema_version: u32,
346    generated_at_ms: u128,
347    tool: ToolInfo,
348    mode: String,
349    status: ScanStatus,
350    warnings: Vec<String>,
351    scan: ScanArgs,
352    args: ExportArgsMeta,
353}
354
355#[derive(Debug, Clone, Serialize)]
356struct JsonlRow<'a> {
357    #[serde(rename = "type")]
358    ty: &'static str,
359    #[serde(flatten)]
360    row: &'a FileRow,
361}
362
363pub fn write_export(export: &ExportData, global: &GlobalArgs, args: &ExportArgs) -> Result<()> {
364    match &args.out {
365        Some(path) => {
366            let file = File::create(path)?;
367            let mut out = BufWriter::new(file);
368            write_export_to(&mut out, export, global, args)?;
369            out.flush()?;
370        }
371        None => {
372            let stdout = io::stdout();
373            let mut out = stdout.lock();
374            write_export_to(&mut out, export, global, args)?;
375            out.flush()?;
376        }
377    }
378    Ok(())
379}
380
381fn write_export_to<W: Write>(
382    out: &mut W,
383    export: &ExportData,
384    global: &GlobalArgs,
385    args: &ExportArgs,
386) -> Result<()> {
387    match args.format {
388        ExportFormat::Csv => write_export_csv(out, export, args),
389        ExportFormat::Jsonl => write_export_jsonl(out, export, global, args),
390        ExportFormat::Json => write_export_json(out, export, global, args),
391        ExportFormat::Cyclonedx => write_export_cyclonedx(out, export, args.redact),
392    }
393}
394
395fn write_export_csv<W: Write>(out: &mut W, export: &ExportData, args: &ExportArgs) -> Result<()> {
396    let mut wtr = csv::WriterBuilder::new().has_headers(true).from_writer(out);
397    wtr.write_record([
398        "path", "module", "lang", "kind", "code", "comments", "blanks", "lines", "bytes", "tokens",
399    ])?;
400
401    for r in redact_rows(&export.rows, args.redact) {
402        let code = r.code.to_string();
403        let comments = r.comments.to_string();
404        let blanks = r.blanks.to_string();
405        let lines = r.lines.to_string();
406        let bytes = r.bytes.to_string();
407        let tokens = r.tokens.to_string();
408        let kind = match r.kind {
409            FileKind::Parent => "parent",
410            FileKind::Child => "child",
411        };
412
413        wtr.write_record([
414            r.path.as_str(),
415            r.module.as_str(),
416            r.lang.as_str(),
417            kind,
418            &code,
419            &comments,
420            &blanks,
421            &lines,
422            &bytes,
423            &tokens,
424        ])?;
425    }
426
427    wtr.flush()?;
428    Ok(())
429}
430
431fn write_export_jsonl<W: Write>(
432    out: &mut W,
433    export: &ExportData,
434    global: &GlobalArgs,
435    args: &ExportArgs,
436) -> Result<()> {
437    if args.meta {
438        let should_redact = args.redact == RedactMode::Paths || args.redact == RedactMode::All;
439        let strip_prefix_redacted = should_redact && args.strip_prefix.is_some();
440
441        let meta = ExportMeta {
442            ty: "meta",
443            schema_version: tokmd_types::SCHEMA_VERSION,
444            generated_at_ms: now_ms(),
445            tool: ToolInfo::current(),
446            mode: "export".to_string(),
447            status: ScanStatus::Complete,
448            warnings: vec![],
449            scan: scan_args(&args.paths, global, Some(args.redact)),
450            args: ExportArgsMeta {
451                format: args.format,
452                module_roots: export.module_roots.clone(),
453                module_depth: export.module_depth,
454                children: export.children,
455                min_code: args.min_code,
456                max_rows: args.max_rows,
457                redact: args.redact,
458                strip_prefix: if should_redact {
459                    args.strip_prefix
460                        .as_ref()
461                        .map(|p| redact_path(&p.display().to_string().replace('\\', "/")))
462                } else {
463                    args.strip_prefix
464                        .as_ref()
465                        .map(|p| p.display().to_string().replace('\\', "/"))
466                },
467                strip_prefix_redacted,
468            },
469        };
470        writeln!(out, "{}", serde_json::to_string(&meta)?)?;
471    }
472
473    for row in redact_rows(&export.rows, args.redact) {
474        let wrapper = JsonlRow {
475            ty: "row",
476            row: &row,
477        };
478        writeln!(out, "{}", serde_json::to_string(&wrapper)?)?;
479    }
480    Ok(())
481}
482
483fn write_export_json<W: Write>(
484    out: &mut W,
485    export: &ExportData,
486    global: &GlobalArgs,
487    args: &ExportArgs,
488) -> Result<()> {
489    if args.meta {
490        let should_redact = args.redact == RedactMode::Paths || args.redact == RedactMode::All;
491        let strip_prefix_redacted = should_redact && args.strip_prefix.is_some();
492
493        let receipt = ExportReceipt {
494            schema_version: tokmd_types::SCHEMA_VERSION,
495            generated_at_ms: now_ms(),
496            tool: ToolInfo::current(),
497            mode: "export".to_string(),
498            status: ScanStatus::Complete,
499            warnings: vec![],
500            scan: scan_args(&args.paths, global, Some(args.redact)),
501            args: ExportArgsMeta {
502                format: args.format,
503                module_roots: export.module_roots.clone(),
504                module_depth: export.module_depth,
505                children: export.children,
506                min_code: args.min_code,
507                max_rows: args.max_rows,
508                redact: args.redact,
509                strip_prefix: if should_redact {
510                    args.strip_prefix
511                        .as_ref()
512                        .map(|p| redact_path(&p.display().to_string().replace('\\', "/")))
513                } else {
514                    args.strip_prefix
515                        .as_ref()
516                        .map(|p| p.display().to_string().replace('\\', "/"))
517                },
518                strip_prefix_redacted,
519            },
520            data: ExportData {
521                rows: redact_rows(&export.rows, args.redact)
522                    .map(|c| c.into_owned())
523                    .collect(),
524                module_roots: export.module_roots.clone(),
525                module_depth: export.module_depth,
526                children: export.children,
527            },
528        };
529        writeln!(out, "{}", serde_json::to_string(&receipt)?)?;
530    } else {
531        writeln!(
532            out,
533            "{}",
534            serde_json::to_string(&redact_rows(&export.rows, args.redact).collect::<Vec<_>>())?
535        )?;
536    }
537    Ok(())
538}
539
540fn redact_rows(rows: &[FileRow], mode: RedactMode) -> impl Iterator<Item = Cow<'_, FileRow>> {
541    rows.iter().map(move |r| {
542        if mode == RedactMode::None {
543            Cow::Borrowed(r)
544        } else {
545            let mut owned = r.clone();
546            if mode == RedactMode::Paths || mode == RedactMode::All {
547                owned.path = redact_path(&owned.path);
548            }
549            if mode == RedactMode::All {
550                owned.module = short_hash(&owned.module);
551            }
552            Cow::Owned(owned)
553        }
554    })
555}
556
557// Re-export redaction functions for backwards compatibility
558pub use tokmd_redact::{redact_path, short_hash};
559
560// -----------------
561// CycloneDX SBOM
562// -----------------
563
564#[derive(Debug, Clone, Serialize)]
565#[serde(rename_all = "camelCase")]
566struct CycloneDxBom {
567    bom_format: &'static str,
568    spec_version: &'static str,
569    serial_number: String,
570    version: u32,
571    metadata: CycloneDxMetadata,
572    components: Vec<CycloneDxComponent>,
573}
574
575#[derive(Debug, Clone, Serialize)]
576struct CycloneDxMetadata {
577    timestamp: String,
578    tools: Vec<CycloneDxTool>,
579}
580
581#[derive(Debug, Clone, Serialize)]
582struct CycloneDxTool {
583    vendor: &'static str,
584    name: &'static str,
585    version: String,
586}
587
588#[derive(Debug, Clone, Serialize)]
589struct CycloneDxComponent {
590    #[serde(rename = "type")]
591    ty: &'static str,
592    name: String,
593    #[serde(skip_serializing_if = "Option::is_none")]
594    group: Option<String>,
595    #[serde(skip_serializing_if = "Vec::is_empty")]
596    properties: Vec<CycloneDxProperty>,
597}
598
599#[derive(Debug, Clone, Serialize)]
600struct CycloneDxProperty {
601    name: String,
602    value: String,
603}
604
605fn write_export_cyclonedx<W: Write>(
606    out: &mut W,
607    export: &ExportData,
608    redact: RedactMode,
609) -> Result<()> {
610    let timestamp = OffsetDateTime::now_utc()
611        .format(&Rfc3339)
612        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
613
614    // Apply redaction to rows before generating components
615    let components: Vec<CycloneDxComponent> = redact_rows(&export.rows, redact)
616        .map(|row| {
617            let mut properties = vec![
618                CycloneDxProperty {
619                    name: "tokmd:lang".to_string(),
620                    value: row.lang.clone(),
621                },
622                CycloneDxProperty {
623                    name: "tokmd:code".to_string(),
624                    value: row.code.to_string(),
625                },
626                CycloneDxProperty {
627                    name: "tokmd:comments".to_string(),
628                    value: row.comments.to_string(),
629                },
630                CycloneDxProperty {
631                    name: "tokmd:blanks".to_string(),
632                    value: row.blanks.to_string(),
633                },
634                CycloneDxProperty {
635                    name: "tokmd:lines".to_string(),
636                    value: row.lines.to_string(),
637                },
638                CycloneDxProperty {
639                    name: "tokmd:bytes".to_string(),
640                    value: row.bytes.to_string(),
641                },
642                CycloneDxProperty {
643                    name: "tokmd:tokens".to_string(),
644                    value: row.tokens.to_string(),
645                },
646            ];
647
648            // Add kind if it's a child
649            if row.kind == FileKind::Child {
650                properties.push(CycloneDxProperty {
651                    name: "tokmd:kind".to_string(),
652                    value: "child".to_string(),
653                });
654            }
655
656            CycloneDxComponent {
657                ty: "file",
658                name: row.path.clone(),
659                group: if row.module.is_empty() {
660                    None
661                } else {
662                    Some(row.module.clone())
663                },
664                properties,
665            }
666        })
667        .collect();
668
669    let bom = CycloneDxBom {
670        bom_format: "CycloneDX",
671        spec_version: "1.6",
672        serial_number: format!("urn:uuid:{}", uuid::Uuid::new_v4()),
673        version: 1,
674        metadata: CycloneDxMetadata {
675            timestamp,
676            tools: vec![CycloneDxTool {
677                vendor: "tokmd",
678                name: "tokmd",
679                version: env!("CARGO_PKG_VERSION").to_string(),
680            }],
681        },
682        components,
683    };
684
685    writeln!(out, "{}", serde_json::to_string_pretty(&bom)?)?;
686    Ok(())
687}
688
689// -----------------
690// Run command helpers
691// -----------------
692
693/// Write a lang report as JSON to a file path.
694///
695/// This is a convenience function for the `run` command that accepts
696/// pre-constructed `ScanArgs` and `LangArgsMeta` rather than requiring
697/// the full CLI args structs.
698pub fn write_lang_json_to_file(
699    path: &Path,
700    report: &LangReport,
701    scan: &ScanArgs,
702    args_meta: &LangArgsMeta,
703) -> Result<()> {
704    let receipt = LangReceipt {
705        schema_version: tokmd_types::SCHEMA_VERSION,
706        generated_at_ms: now_ms(),
707        tool: ToolInfo::current(),
708        mode: "lang".to_string(),
709        status: ScanStatus::Complete,
710        warnings: vec![],
711        scan: scan.clone(),
712        args: args_meta.clone(),
713        report: report.clone(),
714    };
715    let file = File::create(path)?;
716    serde_json::to_writer(file, &receipt)?;
717    Ok(())
718}
719
720/// Write a module report as JSON to a file path.
721///
722/// This is a convenience function for the `run` command that accepts
723/// pre-constructed `ScanArgs` and `ModuleArgsMeta` rather than requiring
724/// the full CLI args structs.
725pub fn write_module_json_to_file(
726    path: &Path,
727    report: &ModuleReport,
728    scan: &ScanArgs,
729    args_meta: &ModuleArgsMeta,
730) -> Result<()> {
731    let receipt = ModuleReceipt {
732        schema_version: tokmd_types::SCHEMA_VERSION,
733        generated_at_ms: now_ms(),
734        tool: ToolInfo::current(),
735        mode: "module".to_string(),
736        status: ScanStatus::Complete,
737        warnings: vec![],
738        scan: scan.clone(),
739        args: args_meta.clone(),
740        report: report.clone(),
741    };
742    let file = File::create(path)?;
743    serde_json::to_writer(file, &receipt)?;
744    Ok(())
745}
746
747/// Write export data as JSONL to a file path.
748///
749/// This is a convenience function for the `run` command that accepts
750/// pre-constructed `ScanArgs` and `ExportArgsMeta` rather than requiring
751/// the full `GlobalArgs` and `ExportArgs` structs.
752pub fn write_export_jsonl_to_file(
753    path: &Path,
754    export: &ExportData,
755    scan: &ScanArgs,
756    args_meta: &ExportArgsMeta,
757) -> Result<()> {
758    let file = File::create(path)?;
759    let mut out = BufWriter::new(file);
760
761    let meta = ExportMeta {
762        ty: "meta",
763        schema_version: tokmd_types::SCHEMA_VERSION,
764        generated_at_ms: now_ms(),
765        tool: ToolInfo::current(),
766        mode: "export".to_string(),
767        status: ScanStatus::Complete,
768        warnings: vec![],
769        scan: scan.clone(),
770        args: args_meta.clone(),
771    };
772    writeln!(out, "{}", serde_json::to_string(&meta)?)?;
773
774    for row in redact_rows(&export.rows, args_meta.redact) {
775        let wrapper = JsonlRow {
776            ty: "row",
777            row: &row,
778        };
779        writeln!(out, "{}", serde_json::to_string(&wrapper)?)?;
780    }
781
782    out.flush()?;
783    Ok(())
784}
785
786// -----------------
787// Diff output
788// -----------------
789
790use tokmd_types::{DiffReceipt, DiffRow, DiffTotals, LangRow};
791
792/// Compute diff rows from two lang reports.
793pub fn compute_diff_rows(from_report: &LangReport, to_report: &LangReport) -> Vec<DiffRow> {
794    // Collect all languages from both reports
795    let mut all_langs: Vec<String> = from_report
796        .rows
797        .iter()
798        .chain(to_report.rows.iter())
799        .map(|r| r.lang.clone())
800        .collect();
801    all_langs.sort();
802    all_langs.dedup();
803
804    all_langs
805        .into_iter()
806        .filter_map(|lang_name| {
807            let old_row = from_report.rows.iter().find(|r| r.lang == lang_name);
808            let new_row = to_report.rows.iter().find(|r| r.lang == lang_name);
809
810            let old = old_row.cloned().unwrap_or_else(|| LangRow {
811                lang: lang_name.clone(),
812                code: 0,
813                lines: 0,
814                files: 0,
815                bytes: 0,
816                tokens: 0,
817                avg_lines: 0,
818            });
819            let new = new_row.cloned().unwrap_or_else(|| LangRow {
820                lang: lang_name.clone(),
821                code: 0,
822                lines: 0,
823                files: 0,
824                bytes: 0,
825                tokens: 0,
826                avg_lines: 0,
827            });
828
829            // Skip if no change
830            if old.code == new.code
831                && old.lines == new.lines
832                && old.files == new.files
833                && old.bytes == new.bytes
834                && old.tokens == new.tokens
835            {
836                return None;
837            }
838
839            Some(DiffRow {
840                lang: lang_name,
841                old_code: old.code,
842                new_code: new.code,
843                delta_code: new.code as i64 - old.code as i64,
844                old_lines: old.lines,
845                new_lines: new.lines,
846                delta_lines: new.lines as i64 - old.lines as i64,
847                old_files: old.files,
848                new_files: new.files,
849                delta_files: new.files as i64 - old.files as i64,
850                old_bytes: old.bytes,
851                new_bytes: new.bytes,
852                delta_bytes: new.bytes as i64 - old.bytes as i64,
853                old_tokens: old.tokens,
854                new_tokens: new.tokens,
855                delta_tokens: new.tokens as i64 - old.tokens as i64,
856            })
857        })
858        .collect()
859}
860
861/// Compute totals from diff rows.
862pub fn compute_diff_totals(rows: &[DiffRow]) -> DiffTotals {
863    let mut totals = DiffTotals {
864        old_code: 0,
865        new_code: 0,
866        delta_code: 0,
867        old_lines: 0,
868        new_lines: 0,
869        delta_lines: 0,
870        old_files: 0,
871        new_files: 0,
872        delta_files: 0,
873        old_bytes: 0,
874        new_bytes: 0,
875        delta_bytes: 0,
876        old_tokens: 0,
877        new_tokens: 0,
878        delta_tokens: 0,
879    };
880
881    for row in rows {
882        totals.old_code += row.old_code;
883        totals.new_code += row.new_code;
884        totals.delta_code += row.delta_code;
885        totals.old_lines += row.old_lines;
886        totals.new_lines += row.new_lines;
887        totals.delta_lines += row.delta_lines;
888        totals.old_files += row.old_files;
889        totals.new_files += row.new_files;
890        totals.delta_files += row.delta_files;
891        totals.old_bytes += row.old_bytes;
892        totals.new_bytes += row.new_bytes;
893        totals.delta_bytes += row.delta_bytes;
894        totals.old_tokens += row.old_tokens;
895        totals.new_tokens += row.new_tokens;
896        totals.delta_tokens += row.delta_tokens;
897    }
898
899    totals
900}
901
902fn format_delta(delta: i64) -> String {
903    if delta > 0 {
904        format!("+{}", delta)
905    } else {
906        delta.to_string()
907    }
908}
909
910/// Render diff as Markdown table.
911pub fn render_diff_md(
912    from_source: &str,
913    to_source: &str,
914    rows: &[DiffRow],
915    totals: &DiffTotals,
916) -> String {
917    let mut s = String::new();
918
919    let _ = writeln!(s, "## Diff: {} → {}", from_source, to_source);
920    s.push('\n');
921    s.push_str("|Language|Old LOC|New LOC|Delta|\n");
922    s.push_str("|---|---:|---:|---:|\n");
923
924    for row in rows {
925        let _ = writeln!(
926            s,
927            "|{}|{}|{}|{}|",
928            row.lang,
929            row.old_code,
930            row.new_code,
931            format_delta(row.delta_code)
932        );
933    }
934
935    let _ = writeln!(
936        s,
937        "|**Total**|{}|{}|{}|",
938        totals.old_code,
939        totals.new_code,
940        format_delta(totals.delta_code)
941    );
942
943    s
944}
945
946/// Create a DiffReceipt for JSON output.
947pub fn create_diff_receipt(
948    from_source: &str,
949    to_source: &str,
950    rows: Vec<DiffRow>,
951    totals: DiffTotals,
952) -> DiffReceipt {
953    DiffReceipt {
954        schema_version: tokmd_types::SCHEMA_VERSION,
955        generated_at_ms: now_ms(),
956        tool: ToolInfo::current(),
957        mode: "diff".to_string(),
958        from_source: from_source.to_string(),
959        to_source: to_source.to_string(),
960        diff_rows: rows,
961        totals,
962    }
963}
964
965// =============================================================================
966// Public test helpers - expose internal functions for integration tests
967// =============================================================================
968
969/// Write CSV export to a writer (exposed for testing).
970#[doc(hidden)]
971pub fn write_export_csv_to<W: Write>(
972    out: &mut W,
973    export: &ExportData,
974    args: &ExportArgs,
975) -> Result<()> {
976    write_export_csv(out, export, args)
977}
978
979/// Write JSONL export to a writer (exposed for testing).
980#[doc(hidden)]
981pub fn write_export_jsonl_to<W: Write>(
982    out: &mut W,
983    export: &ExportData,
984    global: &GlobalArgs,
985    args: &ExportArgs,
986) -> Result<()> {
987    write_export_jsonl(out, export, global, args)
988}
989
990/// Write JSON export to a writer (exposed for testing).
991#[doc(hidden)]
992pub fn write_export_json_to<W: Write>(
993    out: &mut W,
994    export: &ExportData,
995    global: &GlobalArgs,
996    args: &ExportArgs,
997) -> Result<()> {
998    write_export_json(out, export, global, args)
999}
1000
1001/// Write CycloneDX export to a writer (exposed for testing).
1002#[doc(hidden)]
1003pub fn write_export_cyclonedx_to<W: Write>(
1004    out: &mut W,
1005    export: &ExportData,
1006    redact: RedactMode,
1007) -> Result<()> {
1008    write_export_cyclonedx(out, export, redact)
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use super::*;
1014    use proptest::prelude::*;
1015    use tokmd_config::ChildrenMode;
1016    use tokmd_types::{LangRow, ModuleRow, Totals};
1017
1018    fn sample_lang_report(with_files: bool) -> LangReport {
1019        LangReport {
1020            rows: vec![
1021                LangRow {
1022                    lang: "Rust".to_string(),
1023                    code: 1000,
1024                    lines: 1200,
1025                    files: 10,
1026                    bytes: 50000,
1027                    tokens: 2500,
1028                    avg_lines: 120,
1029                },
1030                LangRow {
1031                    lang: "TOML".to_string(),
1032                    code: 50,
1033                    lines: 60,
1034                    files: 2,
1035                    bytes: 1000,
1036                    tokens: 125,
1037                    avg_lines: 30,
1038                },
1039            ],
1040            total: Totals {
1041                code: 1050,
1042                lines: 1260,
1043                files: 12,
1044                bytes: 51000,
1045                tokens: 2625,
1046                avg_lines: 105,
1047            },
1048            with_files,
1049            children: ChildrenMode::Collapse,
1050            top: 0,
1051        }
1052    }
1053
1054    fn sample_module_report() -> ModuleReport {
1055        ModuleReport {
1056            rows: vec![
1057                ModuleRow {
1058                    module: "crates/foo".to_string(),
1059                    code: 800,
1060                    lines: 950,
1061                    files: 8,
1062                    bytes: 40000,
1063                    tokens: 2000,
1064                    avg_lines: 119,
1065                },
1066                ModuleRow {
1067                    module: "crates/bar".to_string(),
1068                    code: 200,
1069                    lines: 250,
1070                    files: 2,
1071                    bytes: 10000,
1072                    tokens: 500,
1073                    avg_lines: 125,
1074                },
1075            ],
1076            total: Totals {
1077                code: 1000,
1078                lines: 1200,
1079                files: 10,
1080                bytes: 50000,
1081                tokens: 2500,
1082                avg_lines: 120,
1083            },
1084            module_roots: vec!["crates".to_string()],
1085            module_depth: 2,
1086            children: tokmd_config::ChildIncludeMode::Separate,
1087            top: 0,
1088        }
1089    }
1090
1091    fn sample_file_rows() -> Vec<FileRow> {
1092        vec![
1093            FileRow {
1094                path: "src/lib.rs".to_string(),
1095                module: "src".to_string(),
1096                lang: "Rust".to_string(),
1097                kind: FileKind::Parent,
1098                code: 100,
1099                comments: 20,
1100                blanks: 10,
1101                lines: 130,
1102                bytes: 1000,
1103                tokens: 250,
1104            },
1105            FileRow {
1106                path: "tests/test.rs".to_string(),
1107                module: "tests".to_string(),
1108                lang: "Rust".to_string(),
1109                kind: FileKind::Parent,
1110                code: 50,
1111                comments: 5,
1112                blanks: 5,
1113                lines: 60,
1114                bytes: 500,
1115                tokens: 125,
1116            },
1117        ]
1118    }
1119
1120    // ========================
1121    // Language Markdown Render Tests
1122    // ========================
1123
1124    #[test]
1125    fn render_lang_md_without_files() {
1126        let report = sample_lang_report(false);
1127        let output = render_lang_md(&report);
1128
1129        // Check header
1130        assert!(output.contains("|Lang|Code|Lines|Bytes|Tokens|"));
1131        // Check no Files/Avg columns
1132        assert!(!output.contains("|Files|"));
1133        assert!(!output.contains("|Avg|"));
1134        // Check row data
1135        assert!(output.contains("|Rust|1000|1200|50000|2500|"));
1136        assert!(output.contains("|TOML|50|60|1000|125|"));
1137        // Check total
1138        assert!(output.contains("|**Total**|1050|1260|51000|2625|"));
1139    }
1140
1141    #[test]
1142    fn render_lang_md_with_files() {
1143        let report = sample_lang_report(true);
1144        let output = render_lang_md(&report);
1145
1146        // Check header includes Files and Avg
1147        assert!(output.contains("|Lang|Code|Lines|Files|Bytes|Tokens|Avg|"));
1148        // Check row data includes file counts
1149        assert!(output.contains("|Rust|1000|1200|10|50000|2500|120|"));
1150        assert!(output.contains("|TOML|50|60|2|1000|125|30|"));
1151        // Check total
1152        assert!(output.contains("|**Total**|1050|1260|12|51000|2625|105|"));
1153    }
1154
1155    #[test]
1156    fn render_lang_md_table_structure() {
1157        let report = sample_lang_report(true);
1158        let output = render_lang_md(&report);
1159
1160        // Verify markdown table structure
1161        let lines: Vec<&str> = output.lines().collect();
1162        assert!(lines.len() >= 4); // header, separator, 2 data rows, total
1163
1164        // Check separator line
1165        assert!(lines[1].contains("|---|"));
1166        assert!(lines[1].contains(":")); // Right-aligned columns
1167    }
1168
1169    // ========================
1170    // Language TSV Render Tests
1171    // ========================
1172
1173    #[test]
1174    fn render_lang_tsv_without_files() {
1175        let report = sample_lang_report(false);
1176        let output = render_lang_tsv(&report);
1177
1178        // Check header
1179        assert!(output.starts_with("Lang\tCode\tLines\tBytes\tTokens\n"));
1180        // Check no Files/Avg columns
1181        assert!(!output.contains("\tFiles\t"));
1182        assert!(!output.contains("\tAvg"));
1183        // Check row data
1184        assert!(output.contains("Rust\t1000\t1200\t50000\t2500"));
1185        assert!(output.contains("TOML\t50\t60\t1000\t125"));
1186        // Check total
1187        assert!(output.contains("Total\t1050\t1260\t51000\t2625"));
1188    }
1189
1190    #[test]
1191    fn render_lang_tsv_with_files() {
1192        let report = sample_lang_report(true);
1193        let output = render_lang_tsv(&report);
1194
1195        // Check header includes Files and Avg
1196        assert!(output.starts_with("Lang\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n"));
1197        // Check row data includes file counts
1198        assert!(output.contains("Rust\t1000\t1200\t10\t50000\t2500\t120"));
1199        assert!(output.contains("TOML\t50\t60\t2\t1000\t125\t30"));
1200    }
1201
1202    #[test]
1203    fn render_lang_tsv_tab_separated() {
1204        let report = sample_lang_report(false);
1205        let output = render_lang_tsv(&report);
1206
1207        // Each data line should have exactly 4 tabs (5 columns)
1208        for line in output.lines().skip(1) {
1209            // Skip header
1210            if line.starts_with("Total") || line.starts_with("Rust") || line.starts_with("TOML") {
1211                assert_eq!(line.matches('\t').count(), 4);
1212            }
1213        }
1214    }
1215
1216    // ========================
1217    // Module Markdown Render Tests
1218    // ========================
1219
1220    #[test]
1221    fn render_module_md_structure() {
1222        let report = sample_module_report();
1223        let output = render_module_md(&report);
1224
1225        // Check header
1226        assert!(output.contains("|Module|Code|Lines|Files|Bytes|Tokens|Avg|"));
1227        // Check module data
1228        assert!(output.contains("|crates/foo|800|950|8|40000|2000|119|"));
1229        assert!(output.contains("|crates/bar|200|250|2|10000|500|125|"));
1230        // Check total
1231        assert!(output.contains("|**Total**|1000|1200|10|50000|2500|120|"));
1232    }
1233
1234    #[test]
1235    fn render_module_md_table_format() {
1236        let report = sample_module_report();
1237        let output = render_module_md(&report);
1238
1239        let lines: Vec<&str> = output.lines().collect();
1240        // Header, separator, 2 rows, total
1241        assert_eq!(lines.len(), 5);
1242        // Separator has right-alignment markers
1243        assert!(lines[1].contains("---:"));
1244    }
1245
1246    // ========================
1247    // Module TSV Render Tests
1248    // ========================
1249
1250    #[test]
1251    fn render_module_tsv_structure() {
1252        let report = sample_module_report();
1253        let output = render_module_tsv(&report);
1254
1255        // Check header
1256        assert!(output.starts_with("Module\tCode\tLines\tFiles\tBytes\tTokens\tAvg\n"));
1257        // Check data
1258        assert!(output.contains("crates/foo\t800\t950\t8\t40000\t2000\t119"));
1259        assert!(output.contains("crates/bar\t200\t250\t2\t10000\t500\t125"));
1260        // Check total
1261        assert!(output.contains("Total\t1000\t1200\t10\t50000\t2500\t120"));
1262    }
1263
1264    #[test]
1265    fn render_module_tsv_tab_count() {
1266        let report = sample_module_report();
1267        let output = render_module_tsv(&report);
1268
1269        // Each data line should have exactly 6 tabs (7 columns)
1270        for line in output.lines() {
1271            assert_eq!(line.matches('\t').count(), 6);
1272        }
1273    }
1274
1275    // ========================
1276    // Redaction Tests
1277    // ========================
1278
1279    #[test]
1280    fn redact_rows_none_mode() {
1281        let rows = sample_file_rows();
1282        let redacted: Vec<_> = redact_rows(&rows, RedactMode::None).collect();
1283
1284        // Should be identical
1285        assert_eq!(redacted.len(), rows.len());
1286        assert_eq!(redacted[0].path, "src/lib.rs");
1287        assert_eq!(redacted[0].module, "src");
1288    }
1289
1290    #[test]
1291    fn redact_rows_paths_mode() {
1292        let rows = sample_file_rows();
1293        let redacted: Vec<_> = redact_rows(&rows, RedactMode::Paths).collect();
1294
1295        // Paths should be redacted (16 char hash + extension)
1296        assert_ne!(redacted[0].path, "src/lib.rs");
1297        assert!(redacted[0].path.ends_with(".rs"));
1298        assert_eq!(redacted[0].path.len(), 16 + 3); // hash + ".rs"
1299
1300        // Module should NOT be redacted
1301        assert_eq!(redacted[0].module, "src");
1302    }
1303
1304    #[test]
1305    fn redact_rows_all_mode() {
1306        let rows = sample_file_rows();
1307        let redacted: Vec<_> = redact_rows(&rows, RedactMode::All).collect();
1308
1309        // Paths should be redacted
1310        assert_ne!(redacted[0].path, "src/lib.rs");
1311        assert!(redacted[0].path.ends_with(".rs"));
1312
1313        // Module should ALSO be redacted (16 char hash)
1314        assert_ne!(redacted[0].module, "src");
1315        assert_eq!(redacted[0].module.len(), 16);
1316    }
1317
1318    #[test]
1319    fn redact_rows_preserves_other_fields() {
1320        let rows = sample_file_rows();
1321        let redacted: Vec<_> = redact_rows(&rows, RedactMode::All).collect();
1322
1323        // All other fields should be preserved
1324        assert_eq!(redacted[0].lang, "Rust");
1325        assert_eq!(redacted[0].kind, FileKind::Parent);
1326        assert_eq!(redacted[0].code, 100);
1327        assert_eq!(redacted[0].comments, 20);
1328        assert_eq!(redacted[0].blanks, 10);
1329        assert_eq!(redacted[0].lines, 130);
1330        assert_eq!(redacted[0].bytes, 1000);
1331        assert_eq!(redacted[0].tokens, 250);
1332    }
1333
1334    // ========================
1335    // Path Normalization Tests
1336    // ========================
1337
1338    #[test]
1339    fn normalize_scan_input_forward_slash() {
1340        let p = Path::new("src/lib.rs");
1341        let normalized = normalize_scan_input(p);
1342        assert_eq!(normalized, "src/lib.rs");
1343    }
1344
1345    #[test]
1346    fn normalize_scan_input_backslash_to_forward() {
1347        let p = Path::new("src\\lib.rs");
1348        let normalized = normalize_scan_input(p);
1349        assert_eq!(normalized, "src/lib.rs");
1350    }
1351
1352    #[test]
1353    fn normalize_scan_input_strips_dot_slash() {
1354        let p = Path::new("./src/lib.rs");
1355        let normalized = normalize_scan_input(p);
1356        assert_eq!(normalized, "src/lib.rs");
1357    }
1358
1359    #[test]
1360    fn normalize_scan_input_current_dir() {
1361        let p = Path::new(".");
1362        let normalized = normalize_scan_input(p);
1363        assert_eq!(normalized, ".");
1364    }
1365
1366    // ========================
1367    // Property-Based Tests
1368    // ========================
1369
1370    proptest! {
1371        #[test]
1372        fn normalize_scan_input_no_backslash(s in "[a-zA-Z0-9_/\\\\.]+") {
1373            let p = Path::new(&s);
1374            let normalized = normalize_scan_input(p);
1375            prop_assert!(!normalized.contains('\\'), "Should not contain backslash: {}", normalized);
1376        }
1377
1378        #[test]
1379        fn normalize_scan_input_no_leading_dot_slash(s in "[a-zA-Z0-9_/\\\\.]+") {
1380            let p = Path::new(&s);
1381            let normalized = normalize_scan_input(p);
1382            prop_assert!(!normalized.starts_with("./"), "Should not start with ./: {}", normalized);
1383        }
1384
1385        #[test]
1386        fn redact_rows_preserves_count(
1387            code in 0usize..10000,
1388            comments in 0usize..1000,
1389            blanks in 0usize..500
1390        ) {
1391            let rows = vec![FileRow {
1392                path: "test/file.rs".to_string(),
1393                module: "test".to_string(),
1394                lang: "Rust".to_string(),
1395                kind: FileKind::Parent,
1396                code,
1397                comments,
1398                blanks,
1399                lines: code + comments + blanks,
1400                bytes: 1000,
1401                tokens: 250,
1402            }];
1403
1404            for mode in [RedactMode::None, RedactMode::Paths, RedactMode::All] {
1405                let redacted: Vec<_> = redact_rows(&rows, mode).collect();
1406                prop_assert_eq!(redacted.len(), 1);
1407                prop_assert_eq!(redacted[0].code, code);
1408                prop_assert_eq!(redacted[0].comments, comments);
1409                prop_assert_eq!(redacted[0].blanks, blanks);
1410            }
1411        }
1412
1413        #[test]
1414        fn redact_rows_paths_end_with_extension(ext in "[a-z]{1,4}") {
1415            let path = format!("some/path/file.{}", ext);
1416            let rows = vec![FileRow {
1417                path: path.clone(),
1418                module: "some".to_string(),
1419                lang: "Test".to_string(),
1420                kind: FileKind::Parent,
1421                code: 100,
1422                comments: 10,
1423                blanks: 5,
1424                lines: 115,
1425                bytes: 1000,
1426                tokens: 250,
1427            }];
1428
1429            let redacted: Vec<_> = redact_rows(&rows, RedactMode::Paths).collect();
1430            prop_assert!(redacted[0].path.ends_with(&format!(".{}", ext)),
1431                "Redacted path '{}' should end with .{}", redacted[0].path, ext);
1432        }
1433    }
1434
1435    // ========================
1436    // Snapshot Tests
1437    // ========================
1438
1439    #[test]
1440    fn snapshot_lang_md_with_files() {
1441        let report = sample_lang_report(true);
1442        let output = render_lang_md(&report);
1443        insta::assert_snapshot!(output);
1444    }
1445
1446    #[test]
1447    fn snapshot_lang_md_without_files() {
1448        let report = sample_lang_report(false);
1449        let output = render_lang_md(&report);
1450        insta::assert_snapshot!(output);
1451    }
1452
1453    #[test]
1454    fn snapshot_lang_tsv_with_files() {
1455        let report = sample_lang_report(true);
1456        let output = render_lang_tsv(&report);
1457        insta::assert_snapshot!(output);
1458    }
1459
1460    #[test]
1461    fn snapshot_module_md() {
1462        let report = sample_module_report();
1463        let output = render_module_md(&report);
1464        insta::assert_snapshot!(output);
1465    }
1466
1467    #[test]
1468    fn snapshot_module_tsv() {
1469        let report = sample_module_report();
1470        let output = render_module_tsv(&report);
1471        insta::assert_snapshot!(output);
1472    }
1473
1474    // ========================
1475    // Diff Render Tests
1476    // ========================
1477
1478    #[test]
1479    fn test_render_diff_md_smoke() {
1480        // Kills mutants: render_diff_md -> String::new() / "xyzzy".into()
1481        let from = LangReport {
1482            rows: vec![LangRow {
1483                lang: "Rust".to_string(),
1484                code: 10,
1485                lines: 10,
1486                files: 1,
1487                bytes: 100,
1488                tokens: 20,
1489                avg_lines: 10,
1490            }],
1491            total: Totals {
1492                code: 10,
1493                lines: 10,
1494                files: 1,
1495                bytes: 100,
1496                tokens: 20,
1497                avg_lines: 10,
1498            },
1499            with_files: false,
1500            children: ChildrenMode::Collapse,
1501            top: 0,
1502        };
1503
1504        let to = LangReport {
1505            rows: vec![LangRow {
1506                lang: "Rust".to_string(),
1507                code: 12,
1508                lines: 12,
1509                files: 1,
1510                bytes: 120,
1511                tokens: 24,
1512                avg_lines: 12,
1513            }],
1514            total: Totals {
1515                code: 12,
1516                lines: 12,
1517                files: 1,
1518                bytes: 120,
1519                tokens: 24,
1520                avg_lines: 12,
1521            },
1522            with_files: false,
1523            children: ChildrenMode::Collapse,
1524            top: 0,
1525        };
1526
1527        let rows = compute_diff_rows(&from, &to);
1528        assert_eq!(rows.len(), 1);
1529        assert_eq!(rows[0].lang, "Rust");
1530        assert_eq!(rows[0].delta_code, 2);
1531
1532        let totals = compute_diff_totals(&rows);
1533        assert_eq!(totals.delta_code, 2);
1534
1535        let md = render_diff_md("from", "to", &rows, &totals);
1536
1537        assert!(!md.trim().is_empty(), "diff markdown must not be empty");
1538        assert!(md.contains("from"));
1539        assert!(md.contains("to"));
1540        assert!(md.contains("Rust"));
1541    }
1542
1543    #[test]
1544    fn test_compute_diff_rows_language_added() {
1545        // Tests language being added (was 0, now has code)
1546        let from = LangReport {
1547            rows: vec![],
1548            total: Totals {
1549                code: 0,
1550                lines: 0,
1551                files: 0,
1552                bytes: 0,
1553                tokens: 0,
1554                avg_lines: 0,
1555            },
1556            with_files: false,
1557            children: ChildrenMode::Collapse,
1558            top: 0,
1559        };
1560
1561        let to = LangReport {
1562            rows: vec![LangRow {
1563                lang: "Python".to_string(),
1564                code: 100,
1565                lines: 120,
1566                files: 5,
1567                bytes: 5000,
1568                tokens: 250,
1569                avg_lines: 24,
1570            }],
1571            total: Totals {
1572                code: 100,
1573                lines: 120,
1574                files: 5,
1575                bytes: 5000,
1576                tokens: 250,
1577                avg_lines: 24,
1578            },
1579            with_files: false,
1580            children: ChildrenMode::Collapse,
1581            top: 0,
1582        };
1583
1584        let rows = compute_diff_rows(&from, &to);
1585        assert_eq!(rows.len(), 1);
1586        assert_eq!(rows[0].lang, "Python");
1587        assert_eq!(rows[0].old_code, 0);
1588        assert_eq!(rows[0].new_code, 100);
1589        assert_eq!(rows[0].delta_code, 100);
1590    }
1591
1592    #[test]
1593    fn test_compute_diff_rows_language_removed() {
1594        // Tests language being removed (had code, now 0)
1595        let from = LangReport {
1596            rows: vec![LangRow {
1597                lang: "Go".to_string(),
1598                code: 50,
1599                lines: 60,
1600                files: 2,
1601                bytes: 2000,
1602                tokens: 125,
1603                avg_lines: 30,
1604            }],
1605            total: Totals {
1606                code: 50,
1607                lines: 60,
1608                files: 2,
1609                bytes: 2000,
1610                tokens: 125,
1611                avg_lines: 30,
1612            },
1613            with_files: false,
1614            children: ChildrenMode::Collapse,
1615            top: 0,
1616        };
1617
1618        let to = LangReport {
1619            rows: vec![],
1620            total: Totals {
1621                code: 0,
1622                lines: 0,
1623                files: 0,
1624                bytes: 0,
1625                tokens: 0,
1626                avg_lines: 0,
1627            },
1628            with_files: false,
1629            children: ChildrenMode::Collapse,
1630            top: 0,
1631        };
1632
1633        let rows = compute_diff_rows(&from, &to);
1634        assert_eq!(rows.len(), 1);
1635        assert_eq!(rows[0].lang, "Go");
1636        assert_eq!(rows[0].old_code, 50);
1637        assert_eq!(rows[0].new_code, 0);
1638        assert_eq!(rows[0].delta_code, -50);
1639    }
1640
1641    #[test]
1642    fn test_compute_diff_rows_unchanged_excluded() {
1643        // Tests that unchanged languages are excluded from diff
1644        let report = LangReport {
1645            rows: vec![LangRow {
1646                lang: "Rust".to_string(),
1647                code: 100,
1648                lines: 100,
1649                files: 1,
1650                bytes: 1000,
1651                tokens: 250,
1652                avg_lines: 100,
1653            }],
1654            total: Totals {
1655                code: 100,
1656                lines: 100,
1657                files: 1,
1658                bytes: 1000,
1659                tokens: 250,
1660                avg_lines: 100,
1661            },
1662            with_files: false,
1663            children: ChildrenMode::Collapse,
1664            top: 0,
1665        };
1666
1667        let rows = compute_diff_rows(&report, &report);
1668        assert!(rows.is_empty(), "unchanged languages should be excluded");
1669    }
1670
1671    #[test]
1672    fn test_format_delta() {
1673        // Kills mutants in format_delta function
1674        assert_eq!(format_delta(5), "+5");
1675        assert_eq!(format_delta(0), "0");
1676        assert_eq!(format_delta(-3), "-3");
1677    }
1678
1679    // ========================
1680    // write_*_to Tests (mutation killers)
1681    // ========================
1682
1683    fn sample_global_args() -> GlobalArgs {
1684        GlobalArgs::default()
1685    }
1686
1687    fn sample_lang_args(format: TableFormat) -> LangArgs {
1688        LangArgs {
1689            paths: vec![PathBuf::from(".")],
1690            format,
1691            top: 0,
1692            files: false,
1693            children: ChildrenMode::Collapse,
1694        }
1695    }
1696
1697    fn sample_module_args(format: TableFormat) -> ModuleArgs {
1698        ModuleArgs {
1699            paths: vec![PathBuf::from(".")],
1700            format,
1701            top: 0,
1702            module_roots: vec!["crates".to_string()],
1703            module_depth: 2,
1704            children: tokmd_config::ChildIncludeMode::Separate,
1705        }
1706    }
1707
1708    #[test]
1709    fn write_lang_report_to_md_writes_content() {
1710        let report = sample_lang_report(true);
1711        let global = sample_global_args();
1712        let args = sample_lang_args(TableFormat::Md);
1713        let mut buf = Vec::new();
1714
1715        write_lang_report_to(&mut buf, &report, &global, &args).unwrap();
1716        let output = String::from_utf8(buf).unwrap();
1717
1718        assert!(!output.is_empty(), "output must not be empty");
1719        assert!(output.contains("|Lang|"), "must contain markdown header");
1720        assert!(output.contains("|Rust|"), "must contain Rust row");
1721        assert!(output.contains("|**Total**|"), "must contain total row");
1722    }
1723
1724    #[test]
1725    fn write_lang_report_to_tsv_writes_content() {
1726        let report = sample_lang_report(false);
1727        let global = sample_global_args();
1728        let args = sample_lang_args(TableFormat::Tsv);
1729        let mut buf = Vec::new();
1730
1731        write_lang_report_to(&mut buf, &report, &global, &args).unwrap();
1732        let output = String::from_utf8(buf).unwrap();
1733
1734        assert!(!output.is_empty(), "output must not be empty");
1735        assert!(output.contains("Lang\t"), "must contain TSV header");
1736        assert!(output.contains("Rust\t"), "must contain Rust row");
1737        assert!(output.contains("Total\t"), "must contain total row");
1738    }
1739
1740    #[test]
1741    fn write_lang_report_to_json_writes_receipt() {
1742        let report = sample_lang_report(true);
1743        let global = sample_global_args();
1744        let args = sample_lang_args(TableFormat::Json);
1745        let mut buf = Vec::new();
1746
1747        write_lang_report_to(&mut buf, &report, &global, &args).unwrap();
1748        let output = String::from_utf8(buf).unwrap();
1749
1750        assert!(!output.is_empty(), "output must not be empty");
1751        // Parse as JSON to verify valid receipt
1752        let receipt: LangReceipt = serde_json::from_str(&output).unwrap();
1753        assert_eq!(receipt.mode, "lang");
1754        assert_eq!(receipt.report.rows.len(), 2);
1755        assert_eq!(receipt.report.total.code, 1050);
1756    }
1757
1758    #[test]
1759    fn write_module_report_to_md_writes_content() {
1760        let report = sample_module_report();
1761        let global = sample_global_args();
1762        let args = sample_module_args(TableFormat::Md);
1763        let mut buf = Vec::new();
1764
1765        write_module_report_to(&mut buf, &report, &global, &args).unwrap();
1766        let output = String::from_utf8(buf).unwrap();
1767
1768        assert!(!output.is_empty(), "output must not be empty");
1769        assert!(output.contains("|Module|"), "must contain markdown header");
1770        assert!(output.contains("|crates/foo|"), "must contain module row");
1771        assert!(output.contains("|**Total**|"), "must contain total row");
1772    }
1773
1774    #[test]
1775    fn write_module_report_to_tsv_writes_content() {
1776        let report = sample_module_report();
1777        let global = sample_global_args();
1778        let args = sample_module_args(TableFormat::Tsv);
1779        let mut buf = Vec::new();
1780
1781        write_module_report_to(&mut buf, &report, &global, &args).unwrap();
1782        let output = String::from_utf8(buf).unwrap();
1783
1784        assert!(!output.is_empty(), "output must not be empty");
1785        assert!(output.contains("Module\t"), "must contain TSV header");
1786        assert!(output.contains("crates/foo\t"), "must contain module row");
1787        assert!(output.contains("Total\t"), "must contain total row");
1788    }
1789
1790    #[test]
1791    fn write_module_report_to_json_writes_receipt() {
1792        let report = sample_module_report();
1793        let global = sample_global_args();
1794        let args = sample_module_args(TableFormat::Json);
1795        let mut buf = Vec::new();
1796
1797        write_module_report_to(&mut buf, &report, &global, &args).unwrap();
1798        let output = String::from_utf8(buf).unwrap();
1799
1800        assert!(!output.is_empty(), "output must not be empty");
1801        // Parse as JSON to verify valid receipt
1802        let receipt: ModuleReceipt = serde_json::from_str(&output).unwrap();
1803        assert_eq!(receipt.mode, "module");
1804        assert_eq!(receipt.report.rows.len(), 2);
1805        assert_eq!(receipt.report.total.code, 1000);
1806    }
1807}