cargo-allow 0.1.11

Source-tree exception ledger and policy scanner for Rust repositories.
use std::fs;
use std::path::{Path, PathBuf};

use allow_policy_legacy::load_legacy_or_canonical;

use super::migrate_render::render_migrate_summary_json;
use super::migrate_types::MigrateContext;

#[test]
fn migrate_closeout_summary_panic_baseline_with_evidence_preserves_legacy_proof() {
    let root = stage_panic_fixture("panic-baseline.toml", "no-panic-baseline.toml");
    let policy_path = root.join("no-panic-baseline.toml");
    let cfg = load_legacy_or_canonical(&policy_path)
        .unwrap_or_else(|err| std::panic::panic_any(format!("load panic baseline: {err}")));
    let context = migrate_context(
        &root,
        policy_path.display().to_string(),
        vec!["no-panic-baseline.toml".to_string()],
        vec!["panic"],
    );

    let json = render_migrate_summary_json(&cfg, &context, Path::new("policy/allow.toml"), false);
    let closeout = closeout_value(&json);

    assert_eq!(closeout_preserved_u64(&closeout, "allow_entries"), 1);
    assert_eq!(
        closeout_preserved_u64(&closeout, "entries_with_evidence"),
        1
    );
    assert!(closeout_preserved_u64(&closeout, "evidence_entries") >= 2);
    assert_eq!(closeout_pointer_u64(&closeout, "/baseline_debt/entries"), 1);
    assert_eq!(closeout_preserved_u64(&closeout, "reviewed_entries"), 0);
    assert!(
        closeout_blockers(&closeout).contains(&"baseline_debt".to_string()),
        "panic baseline entries remain baseline debt even with legacy evidence"
    );
    assert_legacy_source(&closeout, "no-panic-baseline.toml", "panic", "blocked");
    assert!(
        json.contains("\"item_kind\": \"baseline_debt\""),
        "panic baseline with evidence should still route baseline-debt closeout"
    );
    remove_dir(&root);
}

#[test]
fn migrate_closeout_summary_panic_baseline_without_evidence_routes_baseline_debt() {
    let root = stage_panic_fixture("panic-baseline-no-evidence.toml", "no-panic-baseline.toml");
    let policy_path = root.join("no-panic-baseline.toml");
    let cfg = load_legacy_or_canonical(&policy_path)
        .unwrap_or_else(|err| std::panic::panic_any(format!("load panic baseline: {err}")));
    let context = migrate_context(
        &root,
        policy_path.display().to_string(),
        vec!["no-panic-baseline.toml".to_string()],
        vec!["panic"],
    );

    let json = render_migrate_summary_json(&cfg, &context, Path::new("policy/allow.toml"), false);
    let closeout = closeout_value(&json);

    assert_eq!(closeout_pointer_u64(&closeout, "/baseline_debt/entries"), 1);
    assert!(
        closeout_blockers(&closeout).contains(&"baseline_debt".to_string()),
        "missing legacy evidence should keep baseline debt visible"
    );
    assert!(
        closeout_blockers(&closeout).contains(&"weak_evidence_reference".to_string()),
        "generated baseline markers should route weak-evidence closeout"
    );
    assert_eq!(
        closeout_pointer_bool(&closeout, "/legacy_retirement/ready"),
        Some(false)
    );
    assert_legacy_source(&closeout, "no-panic-baseline.toml", "panic", "blocked");
    assert!(
        json.contains("\"item_kind\": \"baseline_debt\""),
        "panic baseline without evidence should route baseline-debt closeout queue"
    );
    assert!(
        json.contains("\"signal\": \"no_new_gate\""),
        "closeout should end with the no-new guard after routed queues"
    );
    remove_dir(&root);
}

#[test]
fn migrate_closeout_summary_lint_exception_minimal_routes_baseline_debt() {
    let root = stage_lane_fixture("lint-exception-minimal.toml", "clippy-exceptions.toml");
    let policy_path = root.join("clippy-exceptions.toml");
    let cfg = load_legacy_or_canonical(&policy_path)
        .unwrap_or_else(|err| std::panic::panic_any(format!("load lint exception: {err}")));
    let context = migrate_context(
        &root,
        policy_path.display().to_string(),
        vec!["clippy-exceptions.toml".to_string()],
        vec!["lint-exception"],
    );

    let json = render_migrate_summary_json(&cfg, &context, Path::new("policy/allow.toml"), false);
    let closeout = closeout_value(&json);

    assert_eq!(closeout_pointer_u64(&closeout, "/baseline_debt/entries"), 1);
    assert!(
        closeout_blockers(&closeout).contains(&"baseline_debt".to_string()),
        "minimal lint entries should remain baseline debt"
    );
    assert_legacy_source(
        &closeout,
        "clippy-exceptions.toml",
        "lint-exception",
        "blocked",
    );
    assert!(
        json.contains("\"label\": \"baseline debt entries\""),
        "lint-exception closeout should use generic baseline-debt queue label"
    );
    assert!(
        json.contains("\"signal\": \"no_new_gate\""),
        "closeout should end with the no-new guard after routed queues"
    );
    remove_dir(&root);
}

#[test]
fn migrate_closeout_summary_unsafe_without_evidence_routes_weak_evidence() {
    let root = stage_lane_fixture("unsafe-no-evidence.toml", "unsafe-allowlist.toml");
    let policy_path = root.join("unsafe-allowlist.toml");
    let cfg = load_legacy_or_canonical(&policy_path)
        .unwrap_or_else(|err| std::panic::panic_any(format!("load unsafe allowlist: {err}")));
    let context = migrate_context(
        &root,
        policy_path.display().to_string(),
        vec!["unsafe-allowlist.toml".to_string()],
        vec!["unsafe"],
    );

    let json = render_migrate_summary_json(&cfg, &context, Path::new("policy/allow.toml"), false);
    let closeout = closeout_value(&json);

    assert!(
        closeout_blockers(&closeout).contains(&"weak_evidence_reference".to_string()),
        "unsafe lane without legacy evidence should route weak-evidence closeout"
    );
    assert_legacy_source(&closeout, "unsafe-allowlist.toml", "unsafe", "blocked");
    assert!(
        json.contains("\"item_kind\": \"weak_evidence_reference\""),
        "unsafe without evidence should route weak-evidence closeout queue"
    );
    assert!(
        json.contains("\"signal\": \"no_new_gate\""),
        "closeout should end with the no-new guard after routed queues"
    );
    remove_dir(&root);
}

fn closeout_value(json: &str) -> serde_json::Value {
    let value: serde_json::Value = serde_json::from_str(json)
        .unwrap_or_else(|err| std::panic::panic_any(format!("parse migrate json: {err}")));
    value
        .get("closeout")
        .cloned()
        .unwrap_or_else(|| std::panic::panic_any("migrate summary missing closeout section"))
}

fn closeout_preserved_u64(closeout: &serde_json::Value, field: &str) -> u64 {
    closeout
        .pointer(&format!("/preserved/{field}"))
        .and_then(|value| value.as_u64())
        .unwrap_or_else(|| std::panic::panic_any(format!("closeout preserved.{field} missing")))
}

fn closeout_pointer_u64(closeout: &serde_json::Value, pointer: &str) -> u64 {
    closeout
        .pointer(pointer)
        .and_then(|value| value.as_u64())
        .unwrap_or_else(|| std::panic::panic_any(format!("closeout{pointer} missing")))
}

fn closeout_pointer_bool(closeout: &serde_json::Value, pointer: &str) -> Option<bool> {
    closeout.pointer(pointer).and_then(|value| value.as_bool())
}

fn closeout_blockers(closeout: &serde_json::Value) -> Vec<String> {
    closeout
        .pointer("/legacy_retirement/blockers")
        .and_then(|value| value.as_array())
        .map(|items| {
            items
                .iter()
                .filter_map(|item| item.as_str().map(str::to_string))
                .collect::<Vec<_>>()
        })
        .unwrap_or_default()
}

fn assert_legacy_source(
    closeout: &serde_json::Value,
    file_name: &str,
    compat_kind: &str,
    status: &str,
) {
    let sources = closeout
        .pointer("/legacy_retirement/sources")
        .and_then(|value| value.as_array())
        .unwrap_or_else(|| std::panic::panic_any("closeout legacy sources missing"));
    assert!(
        sources.iter().any(|source| {
            source.get("file_name").and_then(|value| value.as_str()) == Some(file_name)
                && source.get("compat_kind").and_then(|value| value.as_str()) == Some(compat_kind)
                && source.get("status").and_then(|value| value.as_str()) == Some(status)
        }),
        "closeout should name the migrated legacy source file"
    );
}

fn migrate_context(
    root: &Path,
    input_path: String,
    legacy_source_files: Vec<String>,
    legacy_compat_kinds: Vec<&'static str>,
) -> MigrateContext {
    MigrateContext {
        inventory_source: "filesystem_fallback".to_string(),
        source_tree_root: Some(root.display().to_string()),
        inventory_files: Some(1),
        input_kind: "from".to_string(),
        input_path,
        legacy_source_files,
        legacy_compat_kinds,
    }
}

fn stage_panic_fixture(fixture_file: &str, legacy_filename: &str) -> PathBuf {
    stage_lane_fixture(fixture_file, legacy_filename)
}

fn stage_lane_fixture(fixture_file: &str, legacy_filename: &str) -> PathBuf {
    let root = std::env::temp_dir().join(format!(
        "cargo-allow-migrate-closeout-{}-{}",
        fixture_file.replace('.', "-"),
        std::process::id()
    ));
    fs::create_dir_all(&root)
        .unwrap_or_else(|err| std::panic::panic_any(format!("create fixture dir: {err}")));
    let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../../tests/fixtures/migration")
        .join(fixture_file);
    let text = fs::read_to_string(&fixture)
        .unwrap_or_else(|err| std::panic::panic_any(format!("read fixture {fixture_file}: {err}")));
    fs::write(root.join(legacy_filename), text)
        .unwrap_or_else(|err| std::panic::panic_any(format!("write legacy policy: {err}")));
    root
}

fn remove_dir(path: &Path) {
    let _ = fs::remove_dir_all(path);
}