cordance-cli 0.1.1

Cordance CLI — installs the `cordance` binary. The umbrella package `cordance` re-exports this entry; either install command works.
Documentation
//! `cordance scan` — classify and report all sources in the repo.
//!
//! `BUILD_SPEC` §6.2: when invoked without `--json`, the human-readable
//! output is a markdown file at `.cordance/scan-report.md`, not a stdout
//! summary. Stdout is reserved for either JSON (with `--json`) or a single
//! confirmation line pointing at the report path. Round-3 codereview HIGH:
//! the previous implementation wrote a multi-line stdout summary, which
//! breaks the `cordance serve` invariant that stdout is JSON-RPC only and
//! prevents follow-up tooling from picking up the report deterministically.

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);

    // Round-5 redteam #4: a hostile target can pre-plant
    // `<target>/.cordance/scan-report.md` as a symlink to operator-owned
    // files. Route through `safe_write_with_mkdir` so the helper refuses to
    // follow the link. The helper also creates the parent directory.
    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(())
}

/// Build the markdown report body. Pure function so tests can exercise the
/// rendering logic without touching the filesystem.
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");
        // Drop a single source so the scanner has something to classify.
        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);

        // Class counts
        assert!(body.contains("ProjectReadme: 1"));
        assert!(body.contains("ProjectAdr: 1"));
        assert!(body.contains("BlockedSurface: 1"));

        // File lines: blocked entries surface the reason, plain ones the size.
        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)"));
    }
}