cargo-allow 0.1.3

Source-tree exception ledger and policy scanner for Rust repositories.
use super::test_support::{test_entry, test_finding};
use super::*;
use crate::{CargoAllowCli, CargoAllowCommand};
use allow_core::FindingKind;
use clap::Parser;
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};

fn argv(items: Vec<&str>) -> Vec<String> {
    items.into_iter().map(String::from).collect()
}

#[test]
fn clap_parses_explain_id_and_config() {
    let parsed = CargoAllowCli::try_parse_from(argv(vec![
        "cargo-allow",
        "explain",
        "allow-0001",
        "--config",
        "policy/custom.toml",
        "--include-untracked",
        "--format",
        "json",
        "--output",
        "target/explain.json",
    ]))
    .unwrap_or_else(|err| std::panic::panic_any(format!("CLI should parse: {err}")));

    assert!(matches!(
        parsed.command,
        Some(CargoAllowCommand::Explain(ExplainArgs {
            id,
            config,
            include_untracked: true,
            format: ExplainFormat::Json,
            output,
            ..
        })) if id == "allow-0001"
            && config.as_deref() == Some(Path::new("policy/custom.toml"))
            && output.as_deref() == Some(Path::new("target/explain.json"))
    ));
}

#[test]
fn explain_entry_text_reports_live_match_status() {
    let mut cfg = AllowConfig::empty();
    let entry = test_entry("allow-file", FindingKind::NonRustFile);
    cfg.allow.push(entry.clone());
    let mut finding = test_finding(
        FindingKind::NonRustFile,
        None,
        "tracked.file",
        "tracked_file",
    );
    finding.identity.crate_name = Some("fixture-package".to_string());
    let findings = vec![finding];

    let text = explain_entry_text(Path::new("."), &cfg, &entry, &findings);

    assert!(text.contains("current_status: matched"));
    assert!(text.contains("current_matches: 1"));
    assert!(text.contains("match_outcomes: matched=1"));
    assert!(text.contains("matched: tracked.file:1:1"));
    assert!(text.contains("source_package=fixture-package"));
    assert!(text.contains("Claim boundary: scanned source-tree/source syntax only"));
    assert!(text.contains("did not invoke Cargo metadata"));
    assert!(text.contains("external evidence tools"));
}

#[test]
fn explain_entry_text_reports_empty_evidence_next_actions() {
    let mut cfg = AllowConfig::empty();
    let entry = test_entry("allow-file", FindingKind::NonRustFile);
    cfg.allow.push(entry.clone());
    let finding = test_finding(
        FindingKind::NonRustFile,
        None,
        "tracked.file",
        "tracked_file",
    );

    let text = explain_entry_text(Path::new("."), &cfg, &entry, &[finding]);

    assert!(text.contains("current_status: matched"));
    assert!(text.contains("evidence: none"));
    assert!(text.contains("next:"));
    assert!(text.contains("action: add evidence that supports the exception reason"));
    assert!(text.contains("proof: cargo-allow worklist --missing-evidence --format json"));
    assert!(text.contains("proof: cargo-allow check --kind non-rust --mode no-new"));
}

#[test]
fn explain_entry_text_reports_baseline_debt_next_actions() {
    let mut cfg = AllowConfig::empty();
    let mut entry = test_entry("allow-baseline", FindingKind::Panic);
    entry.classification = "baseline_debt".to_string();
    entry.family = Some("unwrap".to_string());
    cfg.allow.push(entry.clone());
    let finding = test_finding(
        FindingKind::Panic,
        Some("unwrap"),
        "tracked.file",
        "tracked_file",
    );

    let text = explain_entry_text(Path::new("."), &cfg, &entry, &[finding]);

    assert!(text.contains("current_status: matched"));
    assert!(text.contains("baseline_debt and still needs human review"));
    assert!(text.contains("next:"));
    assert!(text.contains("action: replace generated baseline debt"));
    assert!(text.contains("proof: cargo-allow explain allow-baseline"));
    assert!(text.contains("proof: cargo-allow check --kind panic --mode no-new"));
}

#[test]
fn explain_entry_text_reports_evidence_reference_status() {
    let root = migrate_fixture_dir();
    fs::create_dir_all(root.join("docs"))
        .unwrap_or_else(|err| std::panic::panic_any(format!("create docs dir: {err}")));
    fs::write(root.join("docs/safety.md"), "review notes")
        .unwrap_or_else(|err| std::panic::panic_any(format!("write evidence: {err}")));
    let mut cfg = AllowConfig::empty();
    let mut entry = test_entry("allow-file", FindingKind::NonRustFile);
    entry.evidence = vec![
        "doc:docs/safety.md".to_string(),
        "spec:docs/missing.md".to_string(),
        "test:file_policy_fixture".to_string(),
    ];
    cfg.allow.push(entry.clone());

    let text = explain_entry_text(&root, &cfg, &entry, &[]);

    assert!(text.contains("evidence diagnostics:"));
    assert!(text.contains("doc:docs/safety.md"));
    assert!(text.contains("[ok] present: doc:docs/safety.md"));
    assert!(text.contains("spec:docs/missing.md"));
    assert!(text.contains("[missing] missing: spec:docs/missing.md"));
    assert!(text.contains("test:file_policy_fixture"));
    assert!(text.contains("[info] not-local: test:file_policy_fixture"));
    fs::remove_dir_all(root)
        .unwrap_or_else(|err| std::panic::panic_any(format!("remove fixture dir: {err}")));
}

#[test]
fn explain_entry_text_reports_local_evidence_outside_source_tree_inventory_as_missing() {
    let root = migrate_fixture_dir();
    fs::create_dir_all(root.join("docs"))
        .unwrap_or_else(|err| std::panic::panic_any(format!("create docs dir: {err}")));
    fs::write(root.join("docs/untracked.md"), "review notes")
        .unwrap_or_else(|err| std::panic::panic_any(format!("write evidence: {err}")));
    let mut cfg = AllowConfig::empty();
    let mut entry = test_entry("allow-file", FindingKind::NonRustFile);
    entry.evidence = vec!["doc:docs/untracked.md".to_string()];
    cfg.allow.push(entry.clone());
    let source_tree_files = BTreeSet::new();

    let finding = test_finding(
        FindingKind::NonRustFile,
        None,
        "tracked.file",
        "tracked_file",
    );

    let text = explain_entry_text_with_source_tree_files(
        &root,
        &cfg,
        &entry,
        &[finding],
        Some(&source_tree_files),
    );

    assert!(text.contains("[missing] missing: doc:docs/untracked.md"));
    assert!(text.contains("not in the default source-tree inventory"));
    assert!(text.contains("action: commit the referenced evidence file"));
    assert!(text.contains("action: or rerun with --include-untracked"));
    assert!(text.contains("proof: cargo-allow explain allow-file --include-untracked"));
    assert!(text.contains("proof: cargo-allow check --include-untracked --mode no-new"));
    fs::remove_dir_all(root)
        .unwrap_or_else(|err| std::panic::panic_any(format!("remove fixture dir: {err}")));
}

#[test]
fn explain_entry_text_reports_weak_evidence_next_actions() {
    let root = migrate_fixture_dir();
    let mut cfg = AllowConfig::empty();
    let mut entry = test_entry("allow-weak-evidence", FindingKind::NonRustFile);
    entry.evidence = vec![
        "spreadsheet:manual-review".to_string(),
        "TODO add reviewed evidence".to_string(),
    ];
    cfg.allow.push(entry.clone());
    let finding = test_finding(
        FindingKind::NonRustFile,
        None,
        "tracked.file",
        "tracked_file",
    );

    let text = explain_entry_text(&root, &cfg, &entry, &[finding]);

    assert!(text.contains("current_status: matched"));
    assert!(text.contains("[weak] weak: spreadsheet:manual-review"));
    assert!(text.contains("unrecognized evidence prefix"));
    assert!(text.contains("[weak] weak: TODO add reviewed evidence"));
    assert!(text.contains("unstructured evidence string"));
    assert!(text.contains("action: replace the weak evidence string"));
    assert!(
        text.contains("proof: cargo-allow worklist --allow-id allow-weak-evidence --format json")
    );
    fs::remove_dir_all(root)
        .unwrap_or_else(|err| std::panic::panic_any(format!("remove fixture dir: {err}")));
}

#[test]
fn explain_entry_text_specializes_high_risk_policy_weak_evidence_actions() {
    let root = migrate_fixture_dir();
    let mut cfg = AllowConfig::empty();
    let mut entry = test_entry("allow-process-weak", FindingKind::PolicyException);
    entry.family = Some("process_spawn".to_string());
    entry.path = Some(PathBuf::from(".github/workflows/ci.yml"));
    entry.selector.ast_kind = Some("process_spawn".to_string());
    entry.evidence = vec![
        "legacy-policy:proc-cargo-install-cargo-deny".to_string(),
        "binary:cargo".to_string(),
    ];
    cfg.allow.push(entry.clone());
    let finding = test_finding(
        FindingKind::PolicyException,
        Some("process_spawn"),
        ".github/workflows/ci.yml",
        "process_spawn",
    );

    let text = explain_entry_text(&root, &cfg, &entry, &[finding]);

    assert!(text.contains("current_status: matched"));
    assert!(text.contains("[weak] weak: binary:cargo"));
    assert!(text.contains(
        "action: replace weak evidence with typed evidence for policy_exception.process_spawn"
    ));
    assert!(text.contains("action: keep custom legacy facts only as supporting context"));
    fs::remove_dir_all(root)
        .unwrap_or_else(|err| std::panic::panic_any(format!("remove fixture dir: {err}")));
}

#[test]
fn explain_entry_text_reports_weak_link_next_actions() {
    let root = migrate_fixture_dir();
    let mut cfg = AllowConfig::empty();
    let mut entry = test_entry("allow-weak-link", FindingKind::NonRustFile);
    entry.links = vec!["spreadsheet:manual-review".to_string()];
    cfg.allow.push(entry.clone());
    let finding = test_finding(
        FindingKind::NonRustFile,
        None,
        "tracked.file",
        "tracked_file",
    );

    let text = explain_entry_text(&root, &cfg, &entry, &[finding]);

    assert!(text.contains("link diagnostics:"));
    assert!(text.contains("[weak] weak: spreadsheet:manual-review"));
    assert!(text.contains("message: unrecognized link prefix"));
    assert!(text.contains("action: replace the weak link string"));
    assert!(text.contains("proof: cargo-allow worklist --allow-id allow-weak-link --format json"));
    fs::remove_dir_all(root)
        .unwrap_or_else(|err| std::panic::panic_any(format!("remove fixture dir: {err}")));
}

#[test]
fn explain_entry_text_specializes_high_risk_policy_weak_link_actions() {
    let root = migrate_fixture_dir();
    let mut cfg = AllowConfig::empty();
    let mut entry = test_entry("allow-network-weak-link", FindingKind::PolicyException);
    entry.family = Some("network_destination".to_string());
    entry.path = Some(PathBuf::from("policy/network-allowlist.toml"));
    entry.selector.ast_kind = Some("network_destination".to_string());
    entry.links = vec!["spreadsheet:manual-review".to_string()];
    cfg.allow.push(entry.clone());
    let finding = test_finding(
        FindingKind::PolicyException,
        Some("network_destination"),
        "policy/network-allowlist.toml",
        "network_destination",
    );

    let text = explain_entry_text(&root, &cfg, &entry, &[finding]);

    assert!(text.contains("current_status: matched"));
    assert!(text.contains("[weak] weak: spreadsheet:manual-review"));
    assert!(text.contains(
        "action: replace weak traceability with typed traceability for policy_exception.network_destination"
    ));
    assert!(text.contains("action: keep custom legacy notes only as supporting context"));
    fs::remove_dir_all(root)
        .unwrap_or_else(|err| std::panic::panic_any(format!("remove fixture dir: {err}")));
}

#[test]
fn explain_entry_text_reports_stale_entry() {
    let mut cfg = AllowConfig::empty();
    let entry = test_entry("allow-file", FindingKind::NonRustFile);
    cfg.allow.push(entry.clone());

    let text = explain_entry_text(Path::new("."), &cfg, &entry, &[]);

    assert!(text.contains("current_status: stale"));
    assert!(text.contains("current_matches: 0"));
    assert!(text.contains("match_outcomes: stale=1"));
    assert!(text.contains("allow-file is stale"));
    assert!(text.contains("next:"));
    assert!(text.contains("action: remove the stale allow entry"));
    assert!(text.contains("proof: cargo-allow explain allow-file"));
}

#[test]
fn explain_entry_text_reports_occurrence_limit_exceeded() {
    let mut cfg = AllowConfig::empty();
    let mut entry = test_entry("allow-file", FindingKind::NonRustFile);
    entry.occurrence_limit = Some(1);
    cfg.allow.push(entry.clone());
    let finding = test_finding(
        FindingKind::NonRustFile,
        None,
        "tracked.file",
        "tracked_file",
    );
    let findings = vec![finding.clone(), finding];

    let text = explain_entry_text(Path::new("."), &cfg, &entry, &findings);

    assert!(text.contains("occurrence_limit: 1"));
    assert!(text.contains("current_status: new"));
    assert!(text.contains("current_matches: 2"));
    assert!(text.contains("match_outcomes: matched=1, new=1"));
    assert!(text.contains("occurrence_limit exceeded"));
}

static NEXT_EXPLAIN_FIXTURE: AtomicUsize = AtomicUsize::new(0);

fn migrate_fixture_dir() -> PathBuf {
    let id = NEXT_EXPLAIN_FIXTURE.fetch_add(1, Ordering::Relaxed);
    let stamp = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|duration| duration.as_nanos())
        .unwrap_or(0);
    let dir = std::env::temp_dir().join(format!(
        "cargo-allow-cli-explain-{}-{stamp}-{id}",
        std::process::id()
    ));
    fs::create_dir_all(&dir)
        .unwrap_or_else(|err| std::panic::panic_any(format!("fixture dir: {err}")));
    dir
}