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