cargo-allow 0.1.9

Source-tree exception ledger and policy scanner for Rust repositories.
use allow_core::{CargoAllowError, CargoAllowResult, FindingKind, normalize_path};
use allow_inventory::{InventoryOptions, inventory, resolve_source_tree_root};
use std::env;
use std::path::{Path, PathBuf};

use super::migrate_types::{MigrateContext, MigrationLoad};
use crate::root_relative_path;

pub(super) fn load_single_file_migration_config(
    explicit_root: Option<&Path>,
    from: &Path,
) -> CargoAllowResult<MigrationLoad> {
    let root = explicit_root
        .map(|root| resolve_source_tree_root(Some(root), root))
        .transpose()?;
    Ok(MigrationLoad {
        cfg: allow_policy_legacy::load_legacy_or_canonical(from)?,
        context: MigrateContext {
            inventory_source: "unknown".to_string(),
            source_tree_root: root.as_deref().map(allow_report::source_tree_path_text),
            inventory_files: None,
            input_kind: "from".to_string(),
            input_path: normalize_path(from),
        },
    })
}

pub(super) fn load_repo_policy_migration_config(
    explicit_root: Option<&Path>,
    repo_policy: &Path,
) -> CargoAllowResult<MigrationLoad> {
    let root = repo_policy_source_tree_root(explicit_root, repo_policy)?;
    let repo_policy = root_relative_path(&root, repo_policy);
    let inventory = inventory(&root, &InventoryOptions::default())?;
    let inventory_source = inventory.source;
    let files_scanned = inventory.files.len();
    let findings = allow_files::scan_files(&inventory.files)
        .into_iter()
        .filter(|finding| finding.kind == FindingKind::NonRustFile)
        .collect::<Vec<_>>();
    let cfg = allow_policy_legacy::load_legacy_policy_dir_with_non_rust_findings(
        &repo_policy,
        &findings,
    )?;
    Ok(MigrationLoad {
        cfg,
        context: MigrateContext {
            inventory_source: inventory_source.as_str().to_string(),
            source_tree_root: Some(allow_report::source_tree_path_text(&root)),
            inventory_files: Some(files_scanned),
            input_kind: "repo_policy".to_string(),
            input_path: normalize_path(&repo_policy),
        },
    })
}

fn repo_policy_source_tree_root(
    explicit_root: Option<&Path>,
    repo_policy: &Path,
) -> CargoAllowResult<PathBuf> {
    if let Some(root) = explicit_root {
        return resolve_source_tree_root(Some(root), root);
    }
    let cwd =
        env::current_dir().map_err(|e| CargoAllowError::new(format!("failed to read cwd: {e}")))?;
    let full_policy_path = if repo_policy.is_absolute() {
        repo_policy.to_path_buf()
    } else {
        cwd.join(repo_policy)
    };
    if full_policy_path.file_name().and_then(|name| name.to_str()) == Some("policy") {
        return full_policy_path
            .parent()
            .filter(|parent| !parent.as_os_str().is_empty())
            .map(PathBuf::from)
            .ok_or_else(|| {
                CargoAllowError::new(format!(
                    "failed to infer repository root from {}",
                    repo_policy.display()
                ))
            });
    }
    resolve_source_tree_root(None, cwd)
}

#[cfg(test)]
mod tests {
    use super::*;
    use allow_core::{AllowConfig, AllowEntry, Lifecycle, Selector};
    use allow_policy::render_policy;
    use std::fs;
    use std::time::{SystemTime, UNIX_EPOCH};

    #[test]
    fn load_single_file_migration_config_call_presence_observer() {
        let root = unique_test_dir("migrate-load-single");
        let from = root.join("legacy.allow.toml");
        fs::write(&from, render_policy(&canonical_policy_config()))
            .unwrap_or_else(|err| std::panic::panic_any(format!("write canonical policy: {err}")));

        let migration = load_single_file_migration_config(Some(&root), &from)
            .unwrap_or_else(|err| std::panic::panic_any(format!("load single migrate: {err}")));

        assert_eq!(migration.cfg.allow.len(), 1);
        assert_eq!(
            migration.cfg.allow.first().map(|entry| entry.id.as_str()),
            Some("allow-migrated-doc")
        );
        assert_eq!(migration.context.inventory_source, "unknown");
        assert_eq!(
            migration.context.source_tree_root.as_deref(),
            Some(allow_report::source_tree_path_text(&root).as_str())
        );
        assert_eq!(migration.context.inventory_files, None);
        assert_eq!(migration.context.input_kind, "from");
        assert_eq!(migration.context.input_path, normalize_path(&from));
        remove_test_dir(&root);
    }

    #[test]
    fn load_repo_policy_migration_config_call_presence_observer() {
        let root = unique_test_dir("migrate-load-repo-policy");
        let canonical_root = root
            .canonicalize()
            .unwrap_or_else(|err| std::panic::panic_any(format!("canonicalize root: {err}")));
        let policy_dir = root.join("policy");
        fs::create_dir_all(&policy_dir)
            .unwrap_or_else(|err| std::panic::panic_any(format!("create policy dir: {err}")));
        fs::write(
            policy_dir.join("process-allowlist.toml"),
            process_policy_fixture_text(),
        )
        .unwrap_or_else(|err| std::panic::panic_any(format!("write process policy: {err}")));

        let migration = load_repo_policy_migration_config(Some(&root), Path::new("policy"))
            .unwrap_or_else(|err| std::panic::panic_any(format!("load repo policy: {err}")));

        assert_eq!(migration.cfg.policy, "cargo-allow");
        assert_eq!(migration.cfg.allow.len(), 1);
        let entry = migration
            .cfg
            .allow
            .first()
            .unwrap_or_else(|| std::panic::panic_any("expected migrated process entry"));
        assert_eq!(entry.id, "proc-cargo-install-cargo-deny");
        assert_eq!(entry.kind, FindingKind::PolicyException);
        assert_eq!(migration.context.inventory_source, "filesystem_fallback");
        assert_eq!(
            migration.context.source_tree_root.as_deref(),
            Some(allow_report::source_tree_path_text(&canonical_root).as_str())
        );
        assert!(
            migration
                .context
                .inventory_files
                .is_some_and(|count| count >= 1),
            "repo-policy migration should report inventory file count"
        );
        assert_eq!(migration.context.input_kind, "repo_policy");
        assert_eq!(
            migration.context.input_path,
            normalize_path(canonical_root.join("policy"))
        );
        remove_test_dir(&root);
    }

    #[test]
    fn repo_policy_source_tree_root_call_presence_observer() {
        let explicit = unique_test_dir("migrate-load-explicit-root");
        let policy_parent = unique_test_dir("migrate-load-policy-parent");
        let policy_dir = policy_parent.join("policy");
        fs::create_dir_all(&policy_dir)
            .unwrap_or_else(|err| std::panic::panic_any(format!("create policy dir: {err}")));

        let explicit_result = repo_policy_source_tree_root(Some(&explicit), Path::new("ignored"))
            .unwrap_or_else(|err| std::panic::panic_any(format!("explicit root: {err}")));
        assert_eq!(
            explicit_result,
            explicit.canonicalize().unwrap_or_else(|err| {
                std::panic::panic_any(format!("canonicalize explicit root: {err}"))
            })
        );

        let inferred = repo_policy_source_tree_root(None, &policy_dir)
            .unwrap_or_else(|err| std::panic::panic_any(format!("infer policy parent: {err}")));
        assert_eq!(inferred, policy_parent);

        let fallback = repo_policy_source_tree_root(None, Path::new("not-policy-dir"))
            .unwrap_or_else(|err| std::panic::panic_any(format!("fallback source root: {err}")));
        assert!(fallback.is_absolute());
        assert!(fallback.exists());
        remove_test_dir(&explicit);
        remove_test_dir(&policy_parent);
    }

    fn canonical_policy_config() -> AllowConfig {
        let mut cfg = AllowConfig::empty();
        cfg.allow.push(AllowEntry {
            id: "allow-migrated-doc".to_string(),
            kind: FindingKind::NonRustFile,
            family: Some("documentation".to_string()),
            path: Some(PathBuf::from("README.md")),
            glob: None,
            owner: "docs".to_string(),
            classification: "reviewed_documentation".to_string(),
            reason: "Retained documentation file carried forward from legacy migration."
                .to_string(),
            evidence: Vec::new(),
            links: Vec::new(),
            occurrence_limit: None,
            lifecycle: Lifecycle {
                created: Some("2026-06-02".to_string()),
                review_after: Some("2026-11-01".to_string()),
                expires: None,
            },
            selector: Selector {
                ast_kind: Some("tracked_file".to_string()),
                symbol: Some("README.md".to_string()),
                target_fingerprint: Some("md".to_string()),
                line_hint: Some(1),
                ..Selector::default()
            },
            last_seen: None,
        });
        cfg
    }

    fn process_policy_fixture_text() -> &'static str {
        r#"schema_version = 1
policy = "process-allowlist"
owner = "EffortlessMetrics"
status = "advisory"

[[allow]]
id = "proc-cargo-install-cargo-deny"
binary = "cargo"
argv_shape = ["install", "cargo-deny", "--locked"]
network_reach = true
called_by = [".github/workflows/ci.yml"]
owner = "release/ci"
reason = "Installs cargo-deny in the deny job."
created = "2026-05-09"
review_after = "2026-09-09"
"#
    }

    fn unique_test_dir(slug: &str) -> PathBuf {
        let stamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|duration| duration.as_nanos())
            .unwrap_or(0);
        let dir =
            std::env::temp_dir().join(format!("cargo-allow-{slug}-{}-{stamp}", std::process::id()));
        fs::create_dir_all(&dir)
            .unwrap_or_else(|err| std::panic::panic_any(format!("create fixture dir: {err}")));
        dir
    }

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