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