use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
use tokmd_format::{short_hash, write_export, write_lang_json_to_file, write_module_json_to_file};
use tokmd_settings::{ChildIncludeMode, ChildrenMode, ScanOptions};
use tokmd_types::{
ConfigMode, ExportArgs, ExportData, ExportFormat, FileKind, FileRow, LangArgsMeta, LangReport,
LangRow, ModuleArgsMeta, ModuleReport, ModuleRow, RedactMode, ScanArgs, Totals,
};
fn sample_export_data() -> ExportData {
ExportData {
rows: vec![FileRow {
path: "src/lib.rs".to_string(),
module: "src".to_string(),
lang: "Rust".to_string(),
kind: FileKind::Parent,
code: 100,
comments: 20,
blanks: 10,
lines: 130,
bytes: 5000,
tokens: 250,
}],
module_roots: vec!["src".to_string()],
module_depth: 2,
children: ChildIncludeMode::Separate,
}
}
fn export_args(format: ExportFormat, output: Option<PathBuf>) -> ExportArgs {
ExportArgs {
paths: vec![PathBuf::from(".")],
format,
output,
module_roots: vec![],
module_depth: 2,
children: ChildIncludeMode::Separate,
min_code: 0,
max_rows: 0,
redact: RedactMode::None,
meta: false,
strip_prefix: None,
}
}
fn scan_args_stub() -> ScanArgs {
ScanArgs {
paths: vec![".".to_string()],
excluded: vec![],
excluded_redacted: false,
config: ConfigMode::Auto,
hidden: false,
no_ignore: false,
no_ignore_parent: false,
no_ignore_dot: false,
no_ignore_vcs: false,
treat_doc_strings_as_comments: false,
}
}
fn sample_module_report() -> ModuleReport {
ModuleReport {
rows: vec![
ModuleRow {
module: "crates/foo".to_string(),
code: 100,
lines: 120,
files: 1,
bytes: 4000,
tokens: 200,
avg_lines: 120,
},
ModuleRow {
module: "crates/bar".to_string(),
code: 80,
lines: 100,
files: 1,
bytes: 3000,
tokens: 150,
avg_lines: 100,
},
],
total: Totals {
code: 180,
lines: 220,
files: 2,
bytes: 7000,
tokens: 350,
avg_lines: 110,
},
module_roots: vec!["crates/foo".to_string(), "crates/bar".to_string()],
module_depth: 2,
children: ChildIncludeMode::Separate,
top: 0,
}
}
fn sample_lang_report() -> LangReport {
LangReport {
rows: vec![LangRow {
lang: "Rust".to_string(),
code: 100,
lines: 130,
files: 1,
bytes: 5000,
tokens: 250,
avg_lines: 130,
}],
total: Totals {
code: 100,
lines: 130,
files: 1,
bytes: 5000,
tokens: 250,
avg_lines: 130,
},
with_files: true,
children: ChildrenMode::Collapse,
top: 0,
}
}
#[test]
fn write_export_dispatches_csv_format_to_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("out.csv");
let args = export_args(ExportFormat::Csv, Some(path.clone()));
write_export(&sample_export_data(), &ScanOptions::default(), &args)
.expect("write_export csv succeeds");
let out = fs::read_to_string(&path).expect("csv file readable");
assert!(
out.starts_with("path,"),
"csv must start with header: {out}"
);
assert!(out.contains("src/lib.rs"));
}
#[test]
fn write_export_dispatches_jsonl_format_to_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("out.jsonl");
let args = export_args(ExportFormat::Jsonl, Some(path.clone()));
write_export(&sample_export_data(), &ScanOptions::default(), &args)
.expect("write_export jsonl succeeds");
let out = fs::read_to_string(&path).expect("jsonl file readable");
let line_count = out.lines().filter(|l| !l.trim().is_empty()).count();
assert!(line_count >= 1, "jsonl must have at least one line: {out}");
for line in out.lines().filter(|l| !l.trim().is_empty()) {
serde_json::from_str::<serde_json::Value>(line)
.unwrap_or_else(|e| panic!("invalid jsonl line {line:?}: {e}"));
}
assert!(out.contains("src/lib.rs"));
}
#[test]
fn write_export_dispatches_json_format_to_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("out.json");
let args = export_args(ExportFormat::Json, Some(path.clone()));
write_export(&sample_export_data(), &ScanOptions::default(), &args)
.expect("write_export json succeeds");
let out = fs::read_to_string(&path).expect("json file readable");
let v: serde_json::Value = serde_json::from_str(&out).expect("output is valid json");
assert!(
v.is_object() || v.is_array(),
"json must parse as object or array"
);
assert!(out.contains("src/lib.rs"));
}
#[test]
fn write_export_dispatches_cyclonedx_format_to_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("out.cdx.json");
let args = export_args(ExportFormat::Cyclonedx, Some(path.clone()));
write_export(&sample_export_data(), &ScanOptions::default(), &args)
.expect("write_export cyclonedx succeeds");
let out = fs::read_to_string(&path).expect("cdx file readable");
let v: serde_json::Value = serde_json::from_str(&out).expect("output is valid json");
assert_eq!(
v["bomFormat"].as_str(),
Some("CycloneDX"),
"cyclonedx receipt must declare bomFormat"
);
}
#[test]
fn write_lang_json_to_file_emits_valid_receipt_json() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("lang.json");
let scan = scan_args_stub();
let args_meta = LangArgsMeta {
format: "json".to_string(),
top: 0,
with_files: true,
children: ChildrenMode::Collapse,
};
write_lang_json_to_file(&path, &sample_lang_report(), &scan, &args_meta)
.expect("lang json file write succeeds");
let raw = fs::read_to_string(&path).expect("lang json readable");
let v: serde_json::Value = serde_json::from_str(&raw).expect("valid json");
assert_eq!(v["mode"], "lang");
assert!(v["schema_version"].is_number());
assert!(v["rows"].is_array());
}
#[test]
fn write_module_json_to_file_with_redact_all_hashes_module_rows_and_roots() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("module.json");
let scan = scan_args_stub();
let args_meta = ModuleArgsMeta {
format: "json".to_string(),
module_roots: vec!["crates/foo".to_string(), "crates/bar".to_string()],
module_depth: 2,
children: ChildIncludeMode::Separate,
top: 0,
};
write_module_json_to_file(
&path,
&sample_module_report(),
&scan,
&args_meta,
RedactMode::All,
)
.expect("module json file write succeeds");
let raw = fs::read_to_string(&path).expect("module json readable");
let v: serde_json::Value = serde_json::from_str(&raw).expect("valid json");
let args_roots: Vec<String> = v["args"]["module_roots"]
.as_array()
.expect("args.module_roots is array")
.iter()
.map(|r| r.as_str().unwrap().to_string())
.collect();
assert!(
!args_roots.iter().any(|r| r.contains('/')),
"redacted module roots must not contain '/': {args_roots:?}"
);
assert_eq!(
args_roots,
vec![short_hash("crates/foo"), short_hash("crates/bar")]
);
let report_roots: Vec<String> = v["module_roots"]
.as_array()
.expect("module_roots is array")
.iter()
.map(|r| r.as_str().unwrap().to_string())
.collect();
assert_eq!(
report_roots,
vec![short_hash("crates/foo"), short_hash("crates/bar")]
);
let rows = v["rows"].as_array().expect("rows is array");
let row_modules: Vec<&str> = rows
.iter()
.map(|r| r["module"].as_str().expect("row.module is string"))
.collect();
assert_eq!(
row_modules,
vec![short_hash("crates/foo"), short_hash("crates/bar")]
);
}
#[test]
fn write_module_json_to_file_without_redaction_preserves_module_names() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("module.json");
let scan = scan_args_stub();
let args_meta = ModuleArgsMeta {
format: "json".to_string(),
module_roots: vec!["crates/foo".to_string(), "crates/bar".to_string()],
module_depth: 2,
children: ChildIncludeMode::Separate,
top: 0,
};
write_module_json_to_file(
&path,
&sample_module_report(),
&scan,
&args_meta,
RedactMode::None,
)
.expect("module json file write succeeds");
let raw = fs::read_to_string(&path).expect("module json readable");
let v: serde_json::Value = serde_json::from_str(&raw).expect("valid json");
let row_modules: Vec<&str> = v["rows"]
.as_array()
.expect("rows is array")
.iter()
.map(|r| r["module"].as_str().expect("row.module is string"))
.collect();
assert_eq!(row_modules, vec!["crates/foo", "crates/bar"]);
}
#[test]
fn write_module_json_to_file_with_redact_paths_does_not_hash_module_names() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("module.json");
let scan = scan_args_stub();
let args_meta = ModuleArgsMeta {
format: "json".to_string(),
module_roots: vec!["crates/foo".to_string()],
module_depth: 2,
children: ChildIncludeMode::Separate,
top: 0,
};
write_module_json_to_file(
&path,
&sample_module_report(),
&scan,
&args_meta,
RedactMode::Paths,
)
.expect("module json file write succeeds");
let raw = fs::read_to_string(&path).expect("module json readable");
let v: serde_json::Value = serde_json::from_str(&raw).expect("valid json");
let row_modules: Vec<&str> = v["rows"]
.as_array()
.expect("rows is array")
.iter()
.map(|r| r["module"].as_str().expect("row.module is string"))
.collect();
assert!(
row_modules.iter().any(|m| m.contains('/')),
"RedactMode::Paths must not hash module identifiers: {row_modules:?}"
);
}