1use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11#[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#[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#[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#[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#[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#[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 }
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#[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 #[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, 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, 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 #[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, 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 Md,
414 Tsv,
416 Json,
418}
419
420#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(rename_all = "kebab-case")]
422pub enum ExportFormat {
423 Csv,
425 Jsonl,
427 Json,
429 Cyclonedx,
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
434#[serde(rename_all = "kebab-case")]
435pub enum ConfigMode {
436 #[default]
438 Auto,
439 None,
441}
442
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
444#[serde(rename_all = "kebab-case")]
445pub enum ChildrenMode {
446 Collapse,
448 Separate,
450}
451
452#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
453#[serde(rename_all = "kebab-case")]
454pub enum ChildIncludeMode {
455 Separate,
457 ParentsOnly,
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
462#[serde(rename_all = "kebab-case")]
463pub enum RedactMode {
464 None,
466 Paths,
468 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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}