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, required_string_field, string_field};
use crate::parser_support::{normalize_legacy_expires, normalize_lint_attribute_family};
use crate::types::LegacyClippyRule;
use crate::{default_baseline_created, default_baseline_expires};

pub(crate) fn parse_clippy_rules(table: &toml::Table) -> CargoAllowResult<Vec<LegacyClippyRule>> {
    let entries = table
        .get("allow")
        .or_else(|| table.get("entry"))
        .and_then(Value::as_array)
        .ok_or_else(|| CargoAllowError::new("clippy-exceptions missing allow entries"))?;
    entries
        .iter()
        .enumerate()
        .map(|(index, entry)| parse_clippy_rule(index, entry))
        .collect()
}

fn parse_clippy_rule(index: usize, entry: &Value) -> CargoAllowResult<LegacyClippyRule> {
    let table = entry.as_table().ok_or_else(|| {
        CargoAllowError::new(format!("clippy exception entry {index} is not a table"))
    })?;
    let id = string_field(table, "id").unwrap_or_else(|| format!("legacy-clippy-{index:04}"));
    let review_after = string_field(table, "review_after");
    let expires = normalize_legacy_expires(string_field(table, "expires"))
        .or_else(|| review_after.is_none().then(default_baseline_expires));
    Ok(LegacyClippyRule {
        path: required_string_field(table, "path", &id)?,
        lint: required_string_field(table, "lint", &id)?,
        family: string_field(table, "family")
            .or_else(|| string_field(table, "attribute"))
            .map(|family| normalize_lint_attribute_family(&family))
            .unwrap_or_else(|| "expect_attribute".to_string()),
        owner: string_field(table, "owner").unwrap_or_else(|| "unowned".to_string()),
        classification: string_field(table, "classification")
            .unwrap_or_else(|| "baseline_debt".to_string()),
        reason: string_field(table, "reason").unwrap_or_else(|| {
            "Generated from legacy Clippy exceptions policy; requires human review.".to_string()
        }),
        evidence: legacy_evidence(table),
        symbol: string_field(table, "symbol"),
        target_fingerprint: string_field(table, "target_fingerprint")
            .or_else(|| string_field(table, "policy_id").map(|id| format!("policy:{id}"))),
        created: string_field(table, "created").or_else(|| Some(default_baseline_created())),
        review_after,
        expires,
        id,
    })
}

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

    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_clippy_rules_accepts_allow_entries_and_preserves_fields() {
        let table = parse_table(
            r#"
[[allow]]
id = "clippy-unwrap-policy"
path = "src/lib.rs"
lint = "clippy::unwrap_used"
attribute = "expect"
owner = "lint"
classification = "reviewed_lint_exception"
reason = "Fixture keeps an explicit lint suppression linked to policy."
evidence = ["test:lint_policy_is_linked", "issue:#123"]
symbol = "parse_optional"
policy_id = "clippy-unwrap-policy"
created = "2026-05-09"
review_after = "2026-09-09"
expires = "permanent"

[[allow]]
path = "src/legacy.rs"
lint = "clippy::panic"
family = "allow-attribute"
covered_by = "test:legacy_panic_policy"
"#,
        );

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

        assert_eq!(rules.len(), 2);
        let reviewed = rules.remove(0);
        assert_eq!(reviewed.id, "clippy-unwrap-policy");
        assert_eq!(reviewed.path, "src/lib.rs");
        assert_eq!(reviewed.lint, "clippy::unwrap_used");
        assert_eq!(reviewed.family, "expect_attribute");
        assert_eq!(reviewed.owner, "lint");
        assert_eq!(reviewed.classification, "reviewed_lint_exception");
        assert_eq!(
            reviewed.reason,
            "Fixture keeps an explicit lint suppression linked to policy."
        );
        assert_eq!(
            reviewed.evidence,
            vec![
                "test:lint_policy_is_linked".to_string(),
                "issue:#123".to_string(),
            ]
        );
        assert_eq!(reviewed.symbol.as_deref(), Some("parse_optional"));
        assert_eq!(
            reviewed.target_fingerprint.as_deref(),
            Some("policy:clippy-unwrap-policy")
        );
        assert_eq!(reviewed.created.as_deref(), Some("2026-05-09"));
        assert_eq!(reviewed.review_after.as_deref(), Some("2026-09-09"));
        assert_eq!(reviewed.expires.as_deref(), Some("never"));

        let generated = rules.remove(0);
        assert_eq!(generated.id, "legacy-clippy-0001");
        assert_eq!(generated.path, "src/legacy.rs");
        assert_eq!(generated.lint, "clippy::panic");
        assert_eq!(generated.family, "allow_attribute");
        assert_eq!(generated.owner, "unowned");
        assert_eq!(generated.classification, "baseline_debt");
        assert!(generated.reason.contains("legacy Clippy exceptions policy"));
        assert_eq!(
            generated.evidence,
            vec!["test:legacy_panic_policy".to_string()]
        );
        assert_eq!(generated.symbol, None);
        assert_eq!(generated.target_fingerprint, None);
        assert!(
            generated
                .created
                .as_deref()
                .and_then(SimpleDate::parse)
                .is_some()
        );
        assert!(generated.review_after.is_none());
        assert!(
            generated
                .expires
                .as_deref()
                .and_then(SimpleDate::parse)
                .is_some()
        );
    }

    #[test]
    fn parse_clippy_rules_accepts_entry_root_and_target_fingerprint_precedence() {
        let table = parse_table(
            r#"
[[entry]]
id = "clippy-debug"
path = "src/debug.rs"
lint = "clippy::dbg_macro"
family = "expect-attribute"
target_fingerprint = "fingerprint:explicit"
policy_id = "ignored-policy-id"
created = "2026-05-09"
review_after = "2026-09-09"
"#,
        );

        let mut rules = parse_clippy_rules(&table).unwrap_or_else(|err| {
            std::panic::panic_any(format!("entry-root clippy rules parse: {err}"))
        });

        assert_eq!(rules.len(), 1);
        let rule = rules.remove(0);
        assert_eq!(rule.id, "clippy-debug");
        assert_eq!(rule.path, "src/debug.rs");
        assert_eq!(rule.lint, "clippy::dbg_macro");
        assert_eq!(rule.family, "expect_attribute");
        assert_eq!(
            rule.target_fingerprint.as_deref(),
            Some("fingerprint:explicit")
        );
        assert_eq!(rule.created.as_deref(), Some("2026-05-09"));
        assert_eq!(rule.review_after.as_deref(), Some("2026-09-09"));
        assert!(rule.expires.is_none());
    }

    #[test]
    fn parse_clippy_rules_reports_expected_errors() {
        let missing_entries = parse_table("policy = \"clippy-exceptions\"");
        let err = parse_clippy_rules(&missing_entries)
            .err()
            .unwrap_or_else(|| std::panic::panic_any("entries are required"));
        assert!(
            err.to_string()
                .contains("clippy-exceptions missing allow entries")
        );

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

        let missing_path = parse_table(
            r#"
[[allow]]
id = "clippy-missing-path"
lint = "clippy::unwrap_used"
"#,
        );
        let err = parse_clippy_rules(&missing_path)
            .err()
            .unwrap_or_else(|| std::panic::panic_any("path is required"));
        assert!(err.to_string().contains("clippy-missing-path missing path"));

        let missing_lint = parse_table(
            r#"
[[allow]]
id = "clippy-missing-lint"
path = "src/lib.rs"
"#,
        );
        let err = parse_clippy_rules(&missing_lint)
            .err()
            .unwrap_or_else(|| std::panic::panic_any("lint is required"));
        assert!(err.to_string().contains("clippy-missing-lint missing lint"));
    }
}