allow-policy-legacy 0.1.9

Legacy policy adapters for cargo-allow migrations.
Documentation
use allow_core::{CargoAllowError, CargoAllowResult};
use toml::Value;

use crate::fields::{legacy_evidence, raw_string_field, string_field};
use crate::parser_support::normalize_legacy_expires;
use crate::types::LegacyNonRustRule;

pub(crate) fn parse_non_rust_rules(
    table: &toml::Table,
) -> CargoAllowResult<Vec<LegacyNonRustRule>> {
    let entries = table
        .get("allow")
        .and_then(Value::as_array)
        .ok_or_else(|| CargoAllowError::new("non-rust-allowlist missing allow entries"))?;
    entries
        .iter()
        .enumerate()
        .map(|(index, entry)| parse_non_rust_rule(index, entry))
        .collect()
}

fn parse_non_rust_rule(index: usize, entry: &Value) -> CargoAllowResult<LegacyNonRustRule> {
    let table = entry.as_table().ok_or_else(|| {
        CargoAllowError::new(format!("non-rust allow entry {index} is not a table"))
    })?;
    let id = string_field(table, "id").unwrap_or_else(|| format!("legacy-non-rust-{index:04}"));
    let (pattern, is_path) = match (string_field(table, "path"), string_field(table, "glob")) {
        (Some(path), None) => (path, true),
        (None, Some(glob)) => (glob, false),
        (Some(path), Some(_)) => (path, true),
        (None, None) => {
            return Err(CargoAllowError::new(format!("{id} missing path or glob")));
        }
    };
    let reason_field = string_field(table, "reason");
    let raw_broad_glob_reason = raw_string_field(table, "broad_glob_reason");
    let broad_glob_reason = raw_broad_glob_reason
        .as_deref()
        .map(str::trim)
        .filter(|reason| !reason.is_empty())
        .map(str::to_string);
    if !is_path && is_broad_legacy_glob(&pattern) {
        match raw_broad_glob_reason.as_deref() {
            None => {
                return Err(CargoAllowError::new(format!(
                    "{id} broad glob `{pattern}` requires broad_glob_reason"
                )));
            }
            Some(reason) if reason.trim().is_empty() => {
                return Err(CargoAllowError::new(format!(
                    "{id} broad glob `{pattern}` has empty broad_glob_reason"
                )));
            }
            Some(_) => {}
        }
    }
    let reason = match (reason_field, broad_glob_reason) {
        (Some(reason), Some(scope_reason)) if !scope_reason.trim().is_empty() => {
            format!("{reason} Scope note: {scope_reason}")
        }
        (Some(reason), _) => reason,
        (None, Some(scope_reason)) => scope_reason,
        (None, None) => String::new(),
    };
    Ok(LegacyNonRustRule {
        id: id.clone(),
        pattern,
        is_path,
        owner: string_field(table, "owner").unwrap_or_default(),
        classification: string_field(table, "category")
            .unwrap_or_else(|| "legacy_non_rust".to_string()),
        reason,
        evidence: legacy_evidence(table),
        created: string_field(table, "created"),
        review_after: string_field(table, "review_after"),
        expires: normalize_legacy_expires(string_field(table, "expires")),
    })
}

fn is_broad_legacy_glob(pattern: &str) -> bool {
    pattern.contains('*')
}

#[cfg(test)]
mod tests {
    use super::*;

    fn parse_table(input: &str) -> toml::Table {
        toml::from_str::<toml::Table>(input)
            .unwrap_or_else(|err| std::panic::panic_any(format!("test TOML parses: {err}")))
    }

    #[test]
    fn parse_non_rust_rules_preserves_path_and_glob_entries() {
        let table = parse_table(
            r#"
[[allow]]
id = "non-rust-readme"
path = "README.md"
glob = "*.md"
category = "documentation"
owner = "docs"
reason = "Front door docs."
evidence = ["doc:README.md"]
created = "2026-05-09"
review_after = "2026-09-09"
expires = "permanent"

[[allow]]
glob = "docs/**"
owner = "docs"
reason = "Docs tree."
broad_glob_reason = "Docs are maintained as source-tree governance."
covered_by = ["doc:docs/README.md"]
"#,
        );

        let mut rules = parse_non_rust_rules(&table).unwrap_or_else(|err| {
            std::panic::panic_any(format!("non-rust allow entries parse: {err}"))
        });

        assert_eq!(rules.len(), 2);
        let path_rule = rules.remove(0);
        assert_eq!(path_rule.id, "non-rust-readme");
        assert_eq!(path_rule.pattern, "README.md");
        assert!(path_rule.is_path);
        assert_eq!(path_rule.owner, "docs");
        assert_eq!(path_rule.classification, "documentation");
        assert_eq!(path_rule.reason, "Front door docs.");
        assert_eq!(path_rule.evidence, vec!["doc:README.md".to_string()]);
        assert_eq!(path_rule.created.as_deref(), Some("2026-05-09"));
        assert_eq!(path_rule.review_after.as_deref(), Some("2026-09-09"));
        assert_eq!(path_rule.expires.as_deref(), Some("never"));

        let glob_rule = rules.remove(0);
        assert_eq!(glob_rule.id, "legacy-non-rust-0001");
        assert_eq!(glob_rule.pattern, "docs/**");
        assert!(!glob_rule.is_path);
        assert_eq!(glob_rule.owner, "docs");
        assert_eq!(glob_rule.classification, "legacy_non_rust");
        assert_eq!(
            glob_rule.reason,
            "Docs tree. Scope note: Docs are maintained as source-tree governance."
        );
        assert_eq!(glob_rule.evidence, vec!["doc:docs/README.md".to_string()]);
        assert!(glob_rule.created.is_none());
        assert!(glob_rule.review_after.is_none());
        assert!(glob_rule.expires.is_none());
    }

    #[test]
    fn parse_non_rust_rule_composes_reason_boundaries() {
        let table = parse_table(
            r#"
[[allow]]
id = "reason-only"
path = "Cargo.toml"
reason = "Package metadata."

[[allow]]
id = "scope-only"
path = ".github/config.yml"
broad_glob_reason = "Configuration is repo metadata."

[[allow]]
id = "empty-reason"
path = "rustfmt.toml"
"#,
        );

        let mut rules = parse_non_rust_rules(&table)
            .unwrap_or_else(|err| std::panic::panic_any(format!("reason cases parse: {err}")));

        assert_eq!(rules.len(), 3);
        assert_eq!(rules.remove(0).reason, "Package metadata.");
        assert_eq!(rules.remove(0).reason, "Configuration is repo metadata.");
        assert_eq!(rules.remove(0).reason, "");
    }

    #[test]
    fn parse_non_rust_rule_reports_expected_errors() {
        let missing_entries = parse_table("policy = \"non-rust-allowlist\"");
        let err = parse_non_rust_rules(&missing_entries)
            .err()
            .unwrap_or_else(|| std::panic::panic_any("entries are required"));
        assert!(
            err.to_string()
                .contains("non-rust-allowlist missing allow entries")
        );

        let non_table = parse_table("allow = [\"not a table\"]");
        let err = parse_non_rust_rules(&non_table)
            .err()
            .unwrap_or_else(|| std::panic::panic_any("entry must be a table"));
        assert!(
            err.to_string()
                .contains("non-rust allow entry 0 is not a table")
        );

        let missing_path_or_glob = parse_table(
            r#"
[[allow]]
id = "non-rust-missing-target"
"#,
        );
        let err = parse_non_rust_rules(&missing_path_or_glob)
            .err()
            .unwrap_or_else(|| std::panic::panic_any("path or glob is required"));
        assert!(
            err.to_string()
                .contains("non-rust-missing-target missing path or glob")
        );

        let broad_glob_without_reason = parse_table(
            r#"
[[allow]]
id = "non-rust-docs"
glob = "docs/**"
"#,
        );
        let err = parse_non_rust_rules(&broad_glob_without_reason)
            .err()
            .unwrap_or_else(|| std::panic::panic_any("broad glob reason is required"));
        assert!(
            err.to_string()
                .contains("non-rust-docs broad glob `docs/**` requires broad_glob_reason")
        );

        let broad_glob_empty_reason = parse_table(
            r#"
[[allow]]
id = "non-rust-docs"
glob = "docs/**"
broad_glob_reason = "  "
"#,
        );
        let err = parse_non_rust_rules(&broad_glob_empty_reason)
            .err()
            .unwrap_or_else(|| std::panic::panic_any("broad glob reason cannot be empty"));
        assert!(
            err.to_string()
                .contains("non-rust-docs broad glob `docs/**` has empty broad_glob_reason")
        );
    }
}