Skip to main content

tokmd_types/
inventory.rs

1//! Core inventory receipt DTOs.
2//!
3//! This module owns the serde-stable structures emitted by `tokmd lang`,
4//! `tokmd module`, `tokmd export`, and `tokmd run`. Public consumers should
5//! continue using the root-level re-exports from `tokmd_types`.
6
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11/// A small totals struct shared by summary outputs.
12///
13/// # Examples
14///
15/// ```
16/// use tokmd_types::Totals;
17///
18/// let totals = Totals {
19///     code: 1000,
20///     lines: 1500,
21///     files: 10,
22///     bytes: 40000,
23///     tokens: 10000,
24///     avg_lines: 150,
25/// };
26/// assert_eq!(totals.code, 1000);
27/// ```
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29pub struct Totals {
30    pub code: usize,
31    pub lines: usize,
32    pub files: usize,
33    pub bytes: usize,
34    pub tokens: usize,
35    pub avg_lines: usize,
36}
37
38/// A single language row in the lang summary.
39///
40/// # Examples
41///
42/// ```
43/// use tokmd_types::LangRow;
44///
45/// let row = LangRow {
46///     lang: "Rust".to_string(),
47///     code: 5000,
48///     lines: 6500,
49///     files: 42,
50///     bytes: 180_000,
51///     tokens: 45_000,
52///     avg_lines: 154,
53/// };
54/// assert_eq!(row.lang, "Rust");
55/// assert_eq!(row.files, 42);
56/// ```
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58pub struct LangRow {
59    pub lang: String,
60    pub code: usize,
61    pub lines: usize,
62    pub files: usize,
63    pub bytes: usize,
64    pub tokens: usize,
65    pub avg_lines: usize,
66}
67
68/// A report detailing language statistics.
69///
70/// # Examples
71///
72/// ```
73/// use tokmd_types::{LangReport, LangRow, Totals, ChildrenMode};
74///
75/// let report = LangReport {
76///     rows: vec![
77///         LangRow {
78///             lang: "Rust".to_string(),
79///             code: 5000,
80///             lines: 6500,
81///             files: 42,
82///             bytes: 180_000,
83///             tokens: 45_000,
84///             avg_lines: 154,
85///         }
86///     ],
87///     total: Totals {
88///         code: 5000,
89///         lines: 6500,
90///         files: 42,
91///         bytes: 180_000,
92///         tokens: 45_000,
93///         avg_lines: 154,
94///     },
95///     with_files: false,
96///     children: ChildrenMode::Collapse,
97///     top: 10,
98/// };
99/// assert_eq!(report.rows.len(), 1);
100/// assert_eq!(report.total.files, 42);
101/// ```
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct LangReport {
104    pub rows: Vec<LangRow>,
105    pub total: Totals,
106    pub with_files: bool,
107    pub children: ChildrenMode,
108    pub top: usize,
109}
110
111/// A single module row in the module breakdown.
112///
113/// # Examples
114///
115/// ```
116/// use tokmd_types::ModuleRow;
117///
118/// let row = ModuleRow {
119///     module: "crates/tokmd-types".to_string(),
120///     code: 800,
121///     lines: 1100,
122///     files: 3,
123///     bytes: 32_000,
124///     tokens: 8_000,
125///     avg_lines: 366,
126/// };
127/// assert_eq!(row.module, "crates/tokmd-types");
128/// assert_eq!(row.code, 800);
129/// ```
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131pub struct ModuleRow {
132    pub module: String,
133    pub code: usize,
134    pub lines: usize,
135    pub files: usize,
136    pub bytes: usize,
137    pub tokens: usize,
138    pub avg_lines: usize,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct ModuleReport {
143    pub rows: Vec<ModuleRow>,
144    pub total: Totals,
145    pub module_roots: Vec<String>,
146    pub module_depth: usize,
147    pub children: ChildIncludeMode,
148    pub top: usize,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum FileKind {
154    Parent,
155    Child,
156}
157
158/// A single file row in the export inventory.
159///
160/// # Examples
161///
162/// ```
163/// use tokmd_types::{FileRow, FileKind};
164///
165/// let row = FileRow {
166///     path: "src/main.rs".to_string(),
167///     module: "src".to_string(),
168///     lang: "Rust".to_string(),
169///     kind: FileKind::Parent,
170///     code: 120,
171///     comments: 30,
172///     blanks: 20,
173///     lines: 170,
174///     bytes: 4_800,
175///     tokens: 1_200,
176/// };
177/// assert_eq!(row.path, "src/main.rs");
178/// assert_eq!(row.kind, FileKind::Parent);
179/// assert_eq!(row.lines, row.code + row.comments + row.blanks);
180/// ```
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
182pub struct FileRow {
183    pub path: String,
184    pub module: String,
185    pub lang: String,
186    pub kind: FileKind,
187    pub code: usize,
188    pub comments: usize,
189    pub blanks: usize,
190    pub lines: usize,
191    pub bytes: usize,
192    pub tokens: usize,
193}
194
195/// Detailed export data containing individual file statistics.
196///
197/// # Examples
198///
199/// ```
200/// use tokmd_types::{ExportData, FileRow, FileKind, ChildIncludeMode};
201///
202/// let data = ExportData {
203///     rows: vec![
204///         FileRow {
205///             path: "src/main.rs".to_string(),
206///             module: "src".to_string(),
207///             lang: "Rust".to_string(),
208///             kind: FileKind::Parent,
209///             code: 120,
210///             comments: 30,
211///             blanks: 20,
212///             lines: 170,
213///             bytes: 4_800,
214///             tokens: 1_200,
215///         }
216///     ],
217///     module_roots: vec![],
218///     module_depth: 1,
219///     children: ChildIncludeMode::Separate,
220/// };
221/// assert_eq!(data.rows.len(), 1);
222/// ```
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct ExportData {
225    pub rows: Vec<FileRow>,
226    pub module_roots: Vec<String>,
227    pub module_depth: usize,
228    pub children: ChildIncludeMode,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct RunReceipt {
233    pub schema_version: u32,
234    pub generated_at_ms: u128,
235    pub lang_file: String,
236    pub module_file: String,
237    pub export_file: String,
238    // We could store the scan args here too
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(rename_all = "snake_case")]
243pub enum ScanStatus {
244    Complete,
245    Partial,
246}
247
248/// Classification of a commit's intent, derived from subject line.
249///
250/// Lives in `tokmd-types` (Tier 0) so that both `tokmd-git` (Tier 2) and
251/// `tokmd-analysis-types` (Tier 0) can reference it without creating
252/// upward dependency edges.
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum CommitIntentKind {
256    Feat,
257    Fix,
258    Refactor,
259    Docs,
260    Test,
261    Chore,
262    Ci,
263    Build,
264    Perf,
265    Style,
266    Revert,
267    Other,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize, Default)]
271pub struct ToolInfo {
272    pub name: String,
273    pub version: String,
274}
275
276impl ToolInfo {
277    pub fn current() -> Self {
278        Self {
279            name: "tokmd".to_string(),
280            version: env!("CARGO_PKG_VERSION").to_string(),
281        }
282    }
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct ScanArgs {
287    pub paths: Vec<String>,
288    pub excluded: Vec<String>,
289    /// True if `excluded` patterns were redacted (replaced with hashes).
290    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
291    pub excluded_redacted: bool,
292    pub config: ConfigMode,
293    pub hidden: bool,
294    pub no_ignore: bool,
295    pub no_ignore_parent: bool,
296    pub no_ignore_dot: bool,
297    pub no_ignore_vcs: bool,
298    pub treat_doc_strings_as_comments: bool,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct LangArgsMeta {
303    pub format: String,
304    pub top: usize,
305    pub with_files: bool,
306    pub children: ChildrenMode,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct LangReceipt {
311    pub schema_version: u32,
312    pub generated_at_ms: u128,
313    pub tool: ToolInfo,
314    pub mode: String, // "lang"
315    pub status: ScanStatus,
316    pub warnings: Vec<String>,
317    pub scan: ScanArgs,
318    pub args: LangArgsMeta,
319    #[serde(flatten)]
320    pub report: LangReport,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct ModuleArgsMeta {
325    pub format: String,
326    pub module_roots: Vec<String>,
327    pub module_depth: usize,
328    pub children: ChildIncludeMode,
329    pub top: usize,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct ModuleReceipt {
334    pub schema_version: u32,
335    pub generated_at_ms: u128,
336    pub tool: ToolInfo,
337    pub mode: String, // "module"
338    pub status: ScanStatus,
339    pub warnings: Vec<String>,
340    pub scan: ScanArgs,
341    pub args: ModuleArgsMeta,
342    #[serde(flatten)]
343    pub report: ModuleReport,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct ExportArgsMeta {
348    pub format: ExportFormat,
349    pub module_roots: Vec<String>,
350    pub module_depth: usize,
351    pub children: ChildIncludeMode,
352    pub min_code: usize,
353    pub max_rows: usize,
354    pub redact: RedactMode,
355    pub strip_prefix: Option<String>,
356    /// True if `strip_prefix` was redacted (replaced with a hash).
357    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
358    pub strip_prefix_redacted: bool,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct ExportReceipt {
363    pub schema_version: u32,
364    pub generated_at_ms: u128,
365    pub tool: ToolInfo,
366    pub mode: String, // "export"
367    pub status: ScanStatus,
368    pub warnings: Vec<String>,
369    pub scan: ScanArgs,
370    pub args: ExportArgsMeta,
371    #[serde(flatten)]
372    pub data: ExportData,
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct LangArgs {
377    pub paths: Vec<PathBuf>,
378    pub format: TableFormat,
379    pub top: usize,
380    pub files: bool,
381    pub children: ChildrenMode,
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct ModuleArgs {
386    pub paths: Vec<PathBuf>,
387    pub format: TableFormat,
388    pub top: usize,
389    pub module_roots: Vec<String>,
390    pub module_depth: usize,
391    pub children: ChildIncludeMode,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct ExportArgs {
396    pub paths: Vec<PathBuf>,
397    pub format: ExportFormat,
398    pub output: Option<PathBuf>,
399    pub module_roots: Vec<String>,
400    pub module_depth: usize,
401    pub children: ChildIncludeMode,
402    pub min_code: usize,
403    pub max_rows: usize,
404    pub redact: RedactMode,
405    pub meta: bool,
406    pub strip_prefix: Option<PathBuf>,
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "kebab-case")]
411pub enum TableFormat {
412    /// Markdown table (great for pasting into ChatGPT).
413    Md,
414    /// Tab-separated values (good for piping to other tools).
415    Tsv,
416    /// JSON (compact).
417    Json,
418}
419
420#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(rename_all = "kebab-case")]
422pub enum ExportFormat {
423    /// CSV with a header row.
424    Csv,
425    /// One JSON object per line.
426    Jsonl,
427    /// A single JSON array.
428    Json,
429    /// CycloneDX 1.6 JSON SBOM format.
430    Cyclonedx,
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
434#[serde(rename_all = "kebab-case")]
435pub enum ConfigMode {
436    /// Read scan config files (`tokei.toml` / `.tokeirc`) if present.
437    #[default]
438    Auto,
439    /// Ignore config files.
440    None,
441}
442
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
444#[serde(rename_all = "kebab-case")]
445pub enum ChildrenMode {
446    /// Merge embedded content into the parent language totals.
447    Collapse,
448    /// Show embedded languages as separate "(embedded)" rows.
449    Separate,
450}
451
452#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
453#[serde(rename_all = "kebab-case")]
454pub enum ChildIncludeMode {
455    /// Include embedded languages as separate contributions.
456    Separate,
457    /// Ignore embedded languages.
458    ParentsOnly,
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
462#[serde(rename_all = "kebab-case")]
463pub enum RedactMode {
464    /// Do not redact.
465    None,
466    /// Redact file paths.
467    Paths,
468    /// Redact file paths and module names.
469    All,
470}
471
472#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
473#[serde(rename_all = "kebab-case")]
474pub enum AnalysisFormat {
475    Md,
476    Json,
477    Jsonld,
478    Xml,
479    Svg,
480    Mermaid,
481    Obj,
482    Midi,
483    Tree,
484    Html,
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    fn sample_totals() -> Totals {
492        Totals {
493            code: 100,
494            lines: 200,
495            files: 10,
496            bytes: 5_000,
497            tokens: 250,
498            avg_lines: 20,
499        }
500    }
501
502    fn sample_lang_row() -> LangRow {
503        LangRow {
504            lang: "Rust".into(),
505            code: 100,
506            lines: 150,
507            files: 5,
508            bytes: 3_000,
509            tokens: 200,
510            avg_lines: 30,
511        }
512    }
513
514    fn sample_module_row() -> ModuleRow {
515        ModuleRow {
516            module: "src".into(),
517            code: 80,
518            lines: 120,
519            files: 4,
520            bytes: 2_500,
521            tokens: 160,
522            avg_lines: 30,
523        }
524    }
525
526    fn sample_file_row() -> FileRow {
527        FileRow {
528            path: "src/main.rs".into(),
529            module: "src".into(),
530            lang: "Rust".into(),
531            kind: FileKind::Parent,
532            code: 50,
533            comments: 10,
534            blanks: 5,
535            lines: 65,
536            bytes: 2_000,
537            tokens: 100,
538        }
539    }
540
541    fn sample_scan_args() -> ScanArgs {
542        ScanArgs {
543            paths: vec!["src".into(), "tests".into()],
544            excluded: vec!["target".into()],
545            excluded_redacted: false,
546            config: ConfigMode::Auto,
547            hidden: false,
548            no_ignore: false,
549            no_ignore_parent: false,
550            no_ignore_dot: false,
551            no_ignore_vcs: false,
552            treat_doc_strings_as_comments: false,
553        }
554    }
555
556    // ── Totals ───────────────────────────────────────────────────────
557    #[test]
558    fn totals_serde_roundtrip() {
559        let t = sample_totals();
560        let json = serde_json::to_string(&t).unwrap();
561        let back: Totals = serde_json::from_str(&json).unwrap();
562        assert_eq!(back, t);
563    }
564
565    #[test]
566    fn totals_field_names_stable() {
567        let value = serde_json::to_value(sample_totals()).unwrap();
568        for key in ["code", "lines", "files", "bytes", "tokens", "avg_lines"] {
569            assert!(value.get(key).is_some(), "missing key `{key}` in Totals");
570        }
571    }
572
573    // ── LangRow / LangReport ─────────────────────────────────────────
574    #[test]
575    fn lang_row_serde_roundtrip() {
576        let r = sample_lang_row();
577        let json = serde_json::to_string(&r).unwrap();
578        let back: LangRow = serde_json::from_str(&json).unwrap();
579        assert_eq!(back, r);
580    }
581
582    #[test]
583    fn lang_row_field_names_stable() {
584        let value = serde_json::to_value(sample_lang_row()).unwrap();
585        for key in [
586            "lang",
587            "code",
588            "lines",
589            "files",
590            "bytes",
591            "tokens",
592            "avg_lines",
593        ] {
594            assert!(value.get(key).is_some(), "missing key `{key}` in LangRow");
595        }
596    }
597
598    #[test]
599    fn lang_report_serde_roundtrip() {
600        let report = LangReport {
601            rows: vec![sample_lang_row()],
602            total: sample_totals(),
603            with_files: false,
604            children: ChildrenMode::Collapse,
605            top: 10,
606        };
607        let json = serde_json::to_string(&report).unwrap();
608        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
609        for key in ["rows", "total", "with_files", "children", "top"] {
610            assert!(
611                value.get(key).is_some(),
612                "missing key `{key}` in LangReport"
613            );
614        }
615        let back: LangReport = serde_json::from_str(&json).unwrap();
616        assert_eq!(back.rows.len(), 1);
617        assert_eq!(back.rows[0], report.rows[0]);
618        assert_eq!(back.total, report.total);
619        assert_eq!(back.top, 10);
620    }
621
622    // ── ModuleRow / ModuleReport ─────────────────────────────────────
623    #[test]
624    fn module_row_serde_roundtrip() {
625        let r = sample_module_row();
626        let json = serde_json::to_string(&r).unwrap();
627        let back: ModuleRow = serde_json::from_str(&json).unwrap();
628        assert_eq!(back, r);
629    }
630
631    #[test]
632    fn module_row_field_names_stable() {
633        let value = serde_json::to_value(sample_module_row()).unwrap();
634        for key in [
635            "module",
636            "code",
637            "lines",
638            "files",
639            "bytes",
640            "tokens",
641            "avg_lines",
642        ] {
643            assert!(value.get(key).is_some(), "missing key `{key}` in ModuleRow");
644        }
645    }
646
647    #[test]
648    fn module_report_serde_roundtrip() {
649        let report = ModuleReport {
650            rows: vec![sample_module_row()],
651            total: sample_totals(),
652            module_roots: vec!["crates".into()],
653            module_depth: 2,
654            children: ChildIncludeMode::ParentsOnly,
655            top: 5,
656        };
657        let json = serde_json::to_string(&report).unwrap();
658        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
659        for key in [
660            "rows",
661            "total",
662            "module_roots",
663            "module_depth",
664            "children",
665            "top",
666        ] {
667            assert!(
668                value.get(key).is_some(),
669                "missing key `{key}` in ModuleReport"
670            );
671        }
672        let back: ModuleReport = serde_json::from_str(&json).unwrap();
673        assert_eq!(back.rows.len(), 1);
674        assert_eq!(back.module_depth, 2);
675        assert_eq!(back.top, 5);
676    }
677
678    // ── FileRow / ExportData ─────────────────────────────────────────
679    #[test]
680    fn file_row_serde_roundtrip_parent() {
681        let r = sample_file_row();
682        let json = serde_json::to_string(&r).unwrap();
683        let back: FileRow = serde_json::from_str(&json).unwrap();
684        assert_eq!(back, r);
685    }
686
687    #[test]
688    fn file_row_serde_roundtrip_child() {
689        let r = FileRow {
690            kind: FileKind::Child,
691            ..sample_file_row()
692        };
693        let json = serde_json::to_string(&r).unwrap();
694        let back: FileRow = serde_json::from_str(&json).unwrap();
695        assert_eq!(back, r);
696        assert_eq!(back.kind, FileKind::Child);
697    }
698
699    #[test]
700    fn file_row_field_names_stable() {
701        let value = serde_json::to_value(sample_file_row()).unwrap();
702        for key in [
703            "path", "module", "lang", "kind", "code", "comments", "blanks", "lines", "bytes",
704            "tokens",
705        ] {
706            assert!(value.get(key).is_some(), "missing key `{key}` in FileRow");
707        }
708    }
709
710    #[test]
711    fn export_data_serde_roundtrip_preserves_row_order() {
712        let mut rows = Vec::new();
713        for path in ["a.rs", "b.rs", "c.rs", "d.rs"] {
714            let mut row = sample_file_row();
715            row.path = path.to_string();
716            rows.push(row);
717        }
718        let data = ExportData {
719            rows: rows.clone(),
720            module_roots: vec![],
721            module_depth: 1,
722            children: ChildIncludeMode::Separate,
723        };
724        let json = serde_json::to_string(&data).unwrap();
725        let back: ExportData = serde_json::from_str(&json).unwrap();
726        let paths: Vec<_> = back.rows.iter().map(|r| r.path.as_str()).collect();
727        assert_eq!(paths, vec!["a.rs", "b.rs", "c.rs", "d.rs"]);
728        assert_eq!(back.children, ChildIncludeMode::Separate);
729        assert_eq!(back.module_depth, 1);
730    }
731
732    // ── Enums ────────────────────────────────────────────────────────
733    #[test]
734    fn file_kind_uses_snake_case() {
735        assert_eq!(
736            serde_json::to_string(&FileKind::Parent).unwrap(),
737            "\"parent\""
738        );
739        assert_eq!(
740            serde_json::to_string(&FileKind::Child).unwrap(),
741            "\"child\""
742        );
743    }
744
745    #[test]
746    fn scan_status_uses_snake_case() {
747        assert_eq!(
748            serde_json::to_string(&ScanStatus::Complete).unwrap(),
749            "\"complete\""
750        );
751        assert_eq!(
752            serde_json::to_string(&ScanStatus::Partial).unwrap(),
753            "\"partial\""
754        );
755        for variant in [ScanStatus::Complete, ScanStatus::Partial] {
756            let json = serde_json::to_string(&variant).unwrap();
757            let back: ScanStatus = serde_json::from_str(&json).unwrap();
758            assert_eq!(back, variant);
759        }
760    }
761
762    #[test]
763    fn child_include_mode_uses_kebab_case_for_parents_only() {
764        assert_eq!(
765            serde_json::to_string(&ChildIncludeMode::ParentsOnly).unwrap(),
766            "\"parents-only\""
767        );
768        for variant in [ChildIncludeMode::Separate, ChildIncludeMode::ParentsOnly] {
769            let json = serde_json::to_string(&variant).unwrap();
770            let back: ChildIncludeMode = serde_json::from_str(&json).unwrap();
771            assert_eq!(back, variant);
772        }
773    }
774
775    #[test]
776    fn analysis_format_kebab_case_check() {
777        assert_eq!(
778            serde_json::to_string(&AnalysisFormat::Jsonld).unwrap(),
779            "\"jsonld\""
780        );
781        assert_eq!(
782            serde_json::to_string(&AnalysisFormat::Mermaid).unwrap(),
783            "\"mermaid\""
784        );
785    }
786
787    #[test]
788    fn commit_intent_kind_all_variants_roundtrip() {
789        for variant in [
790            CommitIntentKind::Feat,
791            CommitIntentKind::Fix,
792            CommitIntentKind::Refactor,
793            CommitIntentKind::Docs,
794            CommitIntentKind::Test,
795            CommitIntentKind::Chore,
796            CommitIntentKind::Ci,
797            CommitIntentKind::Build,
798            CommitIntentKind::Perf,
799            CommitIntentKind::Style,
800            CommitIntentKind::Revert,
801            CommitIntentKind::Other,
802        ] {
803            let json = serde_json::to_string(&variant).unwrap();
804            let back: CommitIntentKind = serde_json::from_str(&json).unwrap();
805            assert_eq!(back, variant);
806        }
807    }
808
809    #[test]
810    fn config_mode_default_is_auto() {
811        assert_eq!(ConfigMode::default(), ConfigMode::Auto);
812        assert_eq!(
813            serde_json::to_string(&ConfigMode::Auto).unwrap(),
814            "\"auto\""
815        );
816        assert_eq!(
817            serde_json::to_string(&ConfigMode::None).unwrap(),
818            "\"none\""
819        );
820    }
821
822    // ── ToolInfo ─────────────────────────────────────────────────────
823    #[test]
824    fn tool_info_default_serde() {
825        let tool = ToolInfo::default();
826        let json = serde_json::to_string(&tool).unwrap();
827        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
828        assert!(value.get("name").is_some());
829        assert!(value.get("version").is_some());
830        assert_eq!(value["name"], "");
831        assert_eq!(value["version"], "");
832    }
833
834    #[test]
835    fn tool_info_current_has_tokmd_name() {
836        let tool = ToolInfo::current();
837        assert_eq!(tool.name, "tokmd");
838        assert!(!tool.version.is_empty());
839        let json = serde_json::to_string(&tool).unwrap();
840        let back: ToolInfo = serde_json::from_str(&json).unwrap();
841        assert_eq!(back.name, tool.name);
842        assert_eq!(back.version, tool.version);
843    }
844
845    // ── ScanArgs ─────────────────────────────────────────────────────
846    #[test]
847    fn scan_args_roundtrip_preserves_paths_order() {
848        let args = sample_scan_args();
849        let json = serde_json::to_string(&args).unwrap();
850        let back: ScanArgs = serde_json::from_str(&json).unwrap();
851        assert_eq!(back.paths, vec!["src".to_string(), "tests".to_string()]);
852        assert_eq!(back.excluded, vec!["target".to_string()]);
853        assert_eq!(back.config, ConfigMode::Auto);
854    }
855
856    #[test]
857    fn scan_args_excluded_redacted_omitted_when_false() {
858        let args = sample_scan_args();
859        let value = serde_json::to_value(&args).unwrap();
860        assert!(value.get("excluded_redacted").is_none());
861    }
862
863    #[test]
864    fn scan_args_excluded_redacted_present_when_true() {
865        let args = ScanArgs {
866            excluded_redacted: true,
867            ..sample_scan_args()
868        };
869        let value = serde_json::to_value(&args).unwrap();
870        assert_eq!(value["excluded_redacted"], true);
871    }
872
873    // ── Receipts ─────────────────────────────────────────────────────
874    #[test]
875    fn lang_receipt_flattens_report_fields() {
876        let receipt = LangReceipt {
877            schema_version: crate::SCHEMA_VERSION,
878            generated_at_ms: 1_700_000_000_000,
879            tool: ToolInfo::current(),
880            mode: "lang".into(),
881            status: ScanStatus::Complete,
882            warnings: vec![],
883            scan: sample_scan_args(),
884            args: LangArgsMeta {
885                format: "md".into(),
886                top: 10,
887                with_files: true,
888                children: ChildrenMode::Separate,
889            },
890            report: LangReport {
891                rows: vec![sample_lang_row()],
892                total: sample_totals(),
893                with_files: true,
894                children: ChildrenMode::Separate,
895                top: 10,
896            },
897        };
898        let value = serde_json::to_value(&receipt).unwrap();
899        // Envelope fields
900        for key in [
901            "schema_version",
902            "generated_at_ms",
903            "tool",
904            "mode",
905            "status",
906            "warnings",
907            "scan",
908            "args",
909        ] {
910            assert!(value.get(key).is_some(), "missing envelope key `{key}`");
911        }
912        // Flattened report fields
913        for key in ["rows", "total", "with_files", "children", "top"] {
914            assert!(
915                value.get(key).is_some(),
916                "missing flattened report key `{key}`"
917            );
918        }
919        // Roundtrip
920        let json = serde_json::to_string(&receipt).unwrap();
921        let back: LangReceipt = serde_json::from_str(&json).unwrap();
922        assert_eq!(back.mode, "lang");
923        assert_eq!(back.report.rows.len(), 1);
924    }
925
926    #[test]
927    fn module_receipt_flattens_report_fields() {
928        let receipt = ModuleReceipt {
929            schema_version: crate::SCHEMA_VERSION,
930            generated_at_ms: 0,
931            tool: ToolInfo::default(),
932            mode: "module".into(),
933            status: ScanStatus::Partial,
934            warnings: vec!["something".into()],
935            scan: sample_scan_args(),
936            args: ModuleArgsMeta {
937                format: "json".into(),
938                module_roots: vec!["crates".into()],
939                module_depth: 3,
940                children: ChildIncludeMode::Separate,
941                top: 20,
942            },
943            report: ModuleReport {
944                rows: vec![sample_module_row()],
945                total: sample_totals(),
946                module_roots: vec!["crates".into()],
947                module_depth: 3,
948                children: ChildIncludeMode::Separate,
949                top: 20,
950            },
951        };
952        let json = serde_json::to_string(&receipt).unwrap();
953        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
954        assert_eq!(value["status"], "partial");
955        // Flattened ModuleReport fields
956        for key in [
957            "rows",
958            "total",
959            "module_roots",
960            "module_depth",
961            "children",
962            "top",
963        ] {
964            assert!(
965                value.get(key).is_some(),
966                "missing flattened key `{key}` in ModuleReceipt JSON"
967            );
968        }
969        let back: ModuleReceipt = serde_json::from_str(&json).unwrap();
970        assert_eq!(back.mode, "module");
971        assert_eq!(back.report.module_depth, 3);
972        assert_eq!(back.warnings, vec!["something".to_string()]);
973    }
974
975    #[test]
976    fn export_receipt_flattens_data_fields() {
977        let receipt = ExportReceipt {
978            schema_version: crate::SCHEMA_VERSION,
979            generated_at_ms: 0,
980            tool: ToolInfo::default(),
981            mode: "export".into(),
982            status: ScanStatus::Complete,
983            warnings: vec![],
984            scan: sample_scan_args(),
985            args: ExportArgsMeta {
986                format: ExportFormat::Json,
987                module_roots: vec![],
988                module_depth: 1,
989                children: ChildIncludeMode::Separate,
990                min_code: 0,
991                max_rows: 100,
992                redact: RedactMode::None,
993                strip_prefix: None,
994                strip_prefix_redacted: false,
995            },
996            data: ExportData {
997                rows: vec![sample_file_row()],
998                module_roots: vec![],
999                module_depth: 1,
1000                children: ChildIncludeMode::Separate,
1001            },
1002        };
1003        let json = serde_json::to_string(&receipt).unwrap();
1004        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1005        // Flattened ExportData fields
1006        for key in ["rows", "module_roots", "module_depth", "children"] {
1007            assert!(
1008                value.get(key).is_some(),
1009                "missing flattened key `{key}` in ExportReceipt JSON"
1010            );
1011        }
1012        let back: ExportReceipt = serde_json::from_str(&json).unwrap();
1013        assert_eq!(back.mode, "export");
1014        assert_eq!(back.data.rows.len(), 1);
1015    }
1016
1017    #[test]
1018    fn export_args_meta_strip_prefix_omitted_when_false() {
1019        let meta = ExportArgsMeta {
1020            format: ExportFormat::Csv,
1021            module_roots: vec![],
1022            module_depth: 0,
1023            children: ChildIncludeMode::Separate,
1024            min_code: 0,
1025            max_rows: 0,
1026            redact: RedactMode::None,
1027            strip_prefix: None,
1028            strip_prefix_redacted: false,
1029        };
1030        let value = serde_json::to_value(&meta).unwrap();
1031        assert!(value.get("strip_prefix_redacted").is_none());
1032    }
1033
1034    #[test]
1035    fn export_args_meta_strip_prefix_present_when_true() {
1036        let meta = ExportArgsMeta {
1037            format: ExportFormat::Csv,
1038            module_roots: vec![],
1039            module_depth: 0,
1040            children: ChildIncludeMode::Separate,
1041            min_code: 0,
1042            max_rows: 0,
1043            redact: RedactMode::None,
1044            strip_prefix: Some("abc".into()),
1045            strip_prefix_redacted: true,
1046        };
1047        let value = serde_json::to_value(&meta).unwrap();
1048        assert_eq!(value["strip_prefix_redacted"], true);
1049        assert_eq!(value["strip_prefix"], "abc");
1050    }
1051
1052    #[test]
1053    fn run_receipt_serde_roundtrip() {
1054        let receipt = RunReceipt {
1055            schema_version: crate::SCHEMA_VERSION,
1056            generated_at_ms: 1_700_000_000_000,
1057            lang_file: "lang.json".into(),
1058            module_file: "module.json".into(),
1059            export_file: "export.json".into(),
1060        };
1061        let json = serde_json::to_string(&receipt).unwrap();
1062        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
1063        for key in [
1064            "schema_version",
1065            "generated_at_ms",
1066            "lang_file",
1067            "module_file",
1068            "export_file",
1069        ] {
1070            assert!(
1071                value.get(key).is_some(),
1072                "missing key `{key}` in RunReceipt JSON"
1073            );
1074        }
1075        let back: RunReceipt = serde_json::from_str(&json).unwrap();
1076        assert_eq!(back.lang_file, "lang.json");
1077        assert_eq!(back.export_file, "export.json");
1078    }
1079}