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

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

fn parse_no_panic_allowlist_entry(
    index: usize,
    entry: &Value,
) -> CargoAllowResult<LegacyNoPanicAllowEntry> {
    let table = entry.as_table().ok_or_else(|| {
        CargoAllowError::new(format!("no-panic allow entry {index} is not a table"))
    })?;
    let id = string_field(table, "id").unwrap_or_else(|| format!("legacy-no-panic-{index:04}"));
    let selector = table.get("selector").and_then(Value::as_table);
    let last_seen_table = table.get("last_seen").and_then(Value::as_table);
    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));
    let last_seen = optional_last_seen(last_seen_table);
    Ok(LegacyNoPanicAllowEntry {
        index,
        id: id.clone(),
        path: required_string_field(table, "path", &id)?,
        family: required_string_field(table, "family", &id)?,
        selector_kind: selector
            .and_then(|selector| {
                string_field(selector, "kind").or_else(|| string_field(selector, "ast_kind"))
            })
            .ok_or_else(|| CargoAllowError::new(format!("{id} missing selector.kind")))?,
        selector_callee: selector.and_then(|selector| string_field(selector, "callee")),
        selector_container: selector.and_then(|selector| string_field(selector, "container")),
        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")
            .or_else(|| string_field(table, "explanation"))
            .unwrap_or_else(|| {
                "Generated from legacy no-panic allowlist; requires human review.".to_string()
            }),
        evidence: legacy_evidence(table),
        created: string_field(table, "created").or_else(|| Some(default_baseline_created())),
        review_after,
        expires,
        line_hint: selector
            .and_then(|selector| optional_u32_field(selector, "line_hint"))
            .or_else(|| last_seen.as_ref().map(|seen| seen.line)),
        last_seen,
    })
}

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

    #[test]
    fn parser_requires_allow_entries_array() {
        let table = parse_table(
            r#"
policy = "no-panic-allowlist"
"#,
        );

        let Err(err) = parse_no_panic_allowlist_entries(&table) else {
            std::panic::panic_any("expected missing allow entries error");
        };

        assert!(err.to_string().contains("missing allow entries"));
    }

    #[test]
    fn parser_defaults_missing_optional_no_panic_allow_fields() {
        let table = parse_table(
            r#"
policy = "no-panic-allowlist"

[[allow]]
path = "src/lib.rs"
family = "unwrap"

[allow.selector]
ast_kind = "method_call"
callee = "unwrap"
container = "load"
line_hint = 7
"#,
        );

        let entries = parse_entries(&table);

        let [entry] = entries.as_slice() else {
            std::panic::panic_any(format!("expected one entry, got {}", entries.len()));
        };
        assert_eq!(entry.index, 0);
        assert_eq!(entry.id, "legacy-no-panic-0000");
        assert_eq!(entry.path, "src/lib.rs");
        assert_eq!(entry.family, "unwrap");
        assert_eq!(entry.selector_kind, "method_call");
        assert_eq!(entry.selector_callee.as_deref(), Some("unwrap"));
        assert_eq!(entry.selector_container.as_deref(), Some("load"));
        assert_eq!(entry.owner, "unowned");
        assert_eq!(entry.classification, "baseline_debt");
        assert_eq!(
            entry.reason,
            "Generated from legacy no-panic allowlist; requires human review."
        );
        assert!(entry.evidence.is_empty());
        assert_eq!(
            entry.created.as_deref(),
            Some(default_baseline_created().as_str())
        );
        assert_eq!(entry.review_after, None);
        assert_eq!(
            entry.expires.as_deref(),
            Some(default_baseline_expires().as_str())
        );
        assert_eq!(entry.line_hint, Some(7));
        assert!(entry.last_seen.is_none());
    }

    #[test]
    fn parser_preserves_explicit_fields_last_seen_and_legacy_evidence() {
        let table = parse_table(
            r#"
policy = "no-panic-allowlist"

[[allow]]
id = "no-panic-reviewed"
path = "src/lib.rs"
family = "panic"
owner = "runtime"
classification = "accepted"
explanation = "Crash path is unreachable."
evidence = ["test:panic_path", "issue:#123"]
created = "2026-01-01"
review_after = "2026-10-01"
expires = "permanent"

[allow.selector]
kind = "macro_call"
callee = "panic"
container = "handler"

[allow.last_seen]
line = 12
column = 4

[[allow]]
path = "src/covered.rs"
family = "expect"
covered_by = "test:covered"

[allow.selector]
kind = "method_call"
"#,
        );

        let entries = parse_entries(&table);

        let [reviewed, covered] = entries.as_slice() else {
            std::panic::panic_any(format!("expected two entries, got {}", entries.len()));
        };
        assert_eq!(reviewed.index, 0);
        assert_eq!(reviewed.id, "no-panic-reviewed");
        assert_eq!(reviewed.path, "src/lib.rs");
        assert_eq!(reviewed.family, "panic");
        assert_eq!(reviewed.selector_kind, "macro_call");
        assert_eq!(reviewed.selector_callee.as_deref(), Some("panic"));
        assert_eq!(reviewed.selector_container.as_deref(), Some("handler"));
        assert_eq!(reviewed.owner, "runtime");
        assert_eq!(reviewed.classification, "accepted");
        assert_eq!(reviewed.reason, "Crash path is unreachable.");
        assert_eq!(
            reviewed.evidence,
            vec!["test:panic_path".to_string(), "issue:#123".to_string()]
        );
        assert_eq!(reviewed.created.as_deref(), Some("2026-01-01"));
        assert_eq!(reviewed.review_after.as_deref(), Some("2026-10-01"));
        assert_eq!(reviewed.expires.as_deref(), Some("never"));
        assert_eq!(reviewed.line_hint, Some(12));
        assert_eq!(
            reviewed
                .last_seen
                .as_ref()
                .map(|last_seen| (last_seen.line, last_seen.column)),
            Some((12, 4))
        );

        assert_eq!(covered.index, 1);
        assert_eq!(covered.id, "legacy-no-panic-0001");
        assert_eq!(covered.path, "src/covered.rs");
        assert_eq!(covered.family, "expect");
        assert_eq!(covered.selector_kind, "method_call");
        assert_eq!(covered.evidence, vec!["test:covered".to_string()]);
        assert_eq!(covered.line_hint, None);
    }

    #[test]
    fn parser_reports_contextual_no_panic_allow_errors() {
        let non_table = parse_table(
            r#"
allow = ["not-a-table"]
"#,
        );
        let Err(non_table_err) = parse_no_panic_allowlist_entries(&non_table) else {
            std::panic::panic_any("expected non-table allow entry error");
        };
        assert!(
            non_table_err
                .to_string()
                .contains("no-panic allow entry 0 is not a table")
        );

        let missing_path = parse_table(
            r#"
[[allow]]
id = "no-panic-missing-path"
family = "unwrap"

[allow.selector]
kind = "method_call"
"#,
        );
        let Err(missing_path_err) = parse_no_panic_allowlist_entries(&missing_path) else {
            std::panic::panic_any("expected missing path error");
        };
        assert!(
            missing_path_err
                .to_string()
                .contains("no-panic-missing-path missing path")
        );

        let missing_selector = parse_table(
            r#"
[[allow]]
id = "no-panic-missing-selector"
path = "src/lib.rs"
family = "unwrap"
"#,
        );
        let Err(missing_selector_err) = parse_no_panic_allowlist_entries(&missing_selector) else {
            std::panic::panic_any("expected missing selector error");
        };
        assert!(
            missing_selector_err
                .to_string()
                .contains("no-panic-missing-selector missing selector.kind")
        );
    }

    fn parse_entries(table: &toml::Table) -> Vec<LegacyNoPanicAllowEntry> {
        match parse_no_panic_allowlist_entries(table) {
            Ok(entries) => entries,
            Err(err) => std::panic::panic_any(format!("parse entries: {err}")),
        }
    }

    fn parse_table(input: &str) -> toml::Table {
        match toml::from_str::<toml::Table>(input) {
            Ok(table) => table,
            Err(err) => std::panic::panic_any(format!("parse TOML: {err}\n{input}")),
        }
    }
}