use std::collections::BTreeMap;
use anyhow::Result;
use camino::Utf8PathBuf;
use cordance_core::source::SourceRecord;
pub fn run(target: &Utf8PathBuf, json: bool) -> Result<()> {
let sources = cordance_scan::scan_repo(target)?;
if json {
let out = serde_json::to_string_pretty(&sources)?;
println!("{out}");
return Ok(());
}
let report = render_markdown_report(target, &sources);
let out_path = target.join(".cordance").join("scan-report.md");
cordance_core::fs::safe_write_with_mkdir(out_path.as_std_path(), report.as_bytes())?;
println!("Wrote {out_path}");
Ok(())
}
fn render_markdown_report(target: &Utf8PathBuf, sources: &[SourceRecord]) -> String {
use std::fmt::Write as _;
let mut md = String::new();
let _ = writeln!(md, "# Scan report — {target}\n");
let _ = writeln!(md, "**Sources:** {}\n", sources.len());
md.push_str("## By class\n\n");
let mut by_class: BTreeMap<String, usize> = BTreeMap::new();
for s in sources {
let cls = format!("{:?}", s.class);
*by_class.entry(cls).or_insert(0) += 1;
}
if by_class.is_empty() {
md.push_str("- (no sources)\n");
} else {
for (cls, count) in &by_class {
let _ = writeln!(md, "- {cls}: {count}");
}
}
md.push_str("\n## Files\n\n");
if sources.is_empty() {
md.push_str("- (no sources)\n");
} else {
for s in sources {
if s.blocked {
let _ = writeln!(
md,
"- [blocked] {} ({})",
s.path,
s.blocked_reason.as_deref().unwrap_or("unknown")
);
} else {
let _ = writeln!(
md,
"- [{:?}] {} ({} bytes)",
s.class, s.path, s.size_bytes
);
}
}
}
md
}
#[cfg(test)]
mod tests {
use super::*;
use cordance_core::source::SourceClass;
fn rec(path: &str, class: SourceClass, blocked: bool, reason: Option<&str>) -> SourceRecord {
SourceRecord {
id: SourceRecord::stable_id(class, &Utf8PathBuf::from(path)),
path: Utf8PathBuf::from(path),
class,
sha256: "deadbeef".into(),
size_bytes: 42,
modified: None,
blocked,
blocked_reason: reason.map(str::to_string),
}
}
#[test]
fn run_writes_report_file_when_not_json() {
let dir = tempfile::tempdir().expect("tempdir");
let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
.expect("tempdir is utf8");
std::fs::write(target.join("README.md"), "# test\n").expect("write README");
run(&target, false).expect("scan succeeds");
let report_path = target.join(".cordance").join("scan-report.md");
assert!(
report_path.exists(),
"scan_cmd::run must write {report_path}"
);
let body = std::fs::read_to_string(&report_path).expect("read report");
assert!(
body.starts_with("# Scan report — "),
"report must start with the canonical title, got: {body}"
);
assert!(body.contains("## By class"));
assert!(body.contains("## Files"));
}
#[test]
fn run_does_not_write_file_in_json_mode() {
let dir = tempfile::tempdir().expect("tempdir");
let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf())
.expect("tempdir is utf8");
run(&target, true).expect("scan --json succeeds");
let report_path = target.join(".cordance").join("scan-report.md");
assert!(
!report_path.exists(),
"--json mode must not write the markdown report"
);
}
#[test]
fn render_markdown_report_handles_empty_sources() {
let target = Utf8PathBuf::from("/some/repo");
let body = render_markdown_report(&target, &[]);
assert!(body.contains("**Sources:** 0"));
assert!(body.contains("(no sources)"));
}
#[test]
fn render_markdown_report_groups_by_class_and_lists_files() {
let target = Utf8PathBuf::from("/repo");
let sources = vec![
rec("README.md", SourceClass::ProjectReadme, false, None),
rec("docs/adr/0001.md", SourceClass::ProjectAdr, false, None),
rec(
".env",
SourceClass::BlockedSurface,
true,
Some("secrets-prefix"),
),
];
let body = render_markdown_report(&target, &sources);
assert!(body.contains("ProjectReadme: 1"));
assert!(body.contains("ProjectAdr: 1"));
assert!(body.contains("BlockedSurface: 1"));
assert!(body.contains("[ProjectReadme] README.md (42 bytes)"));
assert!(body.contains("[ProjectAdr] docs/adr/0001.md (42 bytes)"));
assert!(body.contains("[blocked] .env (secrets-prefix)"));
}
#[test]
fn render_markdown_report_blocked_without_reason_uses_unknown() {
let target = Utf8PathBuf::from("/r");
let sources = vec![rec("x", SourceClass::BlockedSurface, true, None)];
let body = render_markdown_report(&target, &sources);
assert!(body.contains("[blocked] x (unknown)"));
}
}