cargo-allow 0.1.9

Source-tree exception ledger and policy scanner for Rust repositories.
use super::PruneCandidate;
use allow_core::{AllowConfig, MatchOutcome, MatchStatus};
use std::collections::BTreeSet;

pub(super) fn prune_stale_candidates(
    cfg: &AllowConfig,
    outcomes: &[MatchOutcome],
) -> Vec<PruneCandidate> {
    outcomes
        .iter()
        .filter(|outcome| outcome.status == MatchStatus::Stale)
        .filter_map(|outcome| {
            let id = outcome.allow_id.as_deref()?;
            let entry = cfg.allow.iter().find(|entry| entry.id == id)?;
            Some(PruneCandidate {
                id: entry.id.clone(),
                kind: entry.kind,
                family: entry.family.clone(),
                owner: entry.owner.clone(),
                classification: entry.classification.clone(),
                scope: entry.path_or_glob(),
                reason: entry.reason.clone(),
            })
        })
        .collect()
}

pub(super) fn config_without_prune_candidates(
    cfg: &AllowConfig,
    candidates: &[PruneCandidate],
) -> AllowConfig {
    let mut pruned = cfg.clone();
    pruned
        .allow
        .retain(|entry| !candidates.iter().any(|candidate| candidate.id == entry.id));
    pruned
}

pub(super) fn removed_toml_blocks(
    rendered_policy: &str,
    candidates: &[PruneCandidate],
) -> Vec<String> {
    let ids = candidates
        .iter()
        .map(|candidate| candidate.id.as_str())
        .collect::<BTreeSet<_>>();
    allow_blocks(rendered_policy)
        .into_iter()
        .filter(|block| ids.iter().any(|id| block_contains_allow_id(block, id)))
        .map(str::to_string)
        .collect()
}

fn allow_blocks(rendered_policy: &str) -> Vec<&str> {
    let mut blocks = Vec::new();
    let mut start = None;
    let mut offset = 0;
    for line in rendered_policy.split_inclusive('\n') {
        let line_text = line.trim_end_matches('\n').trim_end_matches('\r');
        if line_text == "[[allow]]" {
            if let Some(previous) = start.replace(offset) {
                if let Some(block) = rendered_policy.get(previous..offset) {
                    blocks.push(block.trim_end());
                }
            }
        }
        offset += line.len();
    }
    if let Some(previous) = start {
        if let Some(block) = rendered_policy.get(previous..) {
            blocks.push(block.trim_end());
        }
    }
    blocks
}

fn block_contains_allow_id(block: &str, id: &str) -> bool {
    block
        .lines()
        .any(|line| line.trim() == format!("id = \"{}\"", escape_toml_basic(id)))
}

fn escape_toml_basic(value: &str) -> String {
    let mut out = String::new();
    for ch in value.chars() {
        match ch {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            _ => out.push(ch),
        }
    }
    out
}

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

    #[test]
    fn allow_blocks_call_presence_observer() {
        let rendered = r#"schema_version = "0.1"

[[allow]]
id = "allow-one"
reason = "first"

[[allow]]
id = "allow-two"
reason = "second"
"#;

        let blocks = allow_blocks(rendered);

        assert_eq!(blocks.len(), 2);
        assert_eq!(
            blocks.first().copied(),
            Some("[[allow]]\nid = \"allow-one\"\nreason = \"first\"")
        );
        assert_eq!(
            blocks.get(1).copied(),
            Some("[[allow]]\nid = \"allow-two\"\nreason = \"second\"")
        );
    }

    #[test]
    fn block_contains_allow_id_call_presence_observer() {
        let block = "[[allow]]\nid = \"allow-\\\"quoted\\\"\"\nreason = \"fixture\"";

        assert!(block_contains_allow_id(block, "allow-\"quoted\""));
        assert!(!block_contains_allow_id(block, "allow-quoted"));
        assert!(!block_contains_allow_id(
            "[[allow]]\n# id = \"allow-\\\"quoted\\\"\"",
            "allow-\"quoted\""
        ));
    }

    #[test]
    fn escape_toml_basic_call_presence_observer() {
        assert_eq!(
            escape_toml_basic("slash\\quote\"newline\nreturn\rtab\tplain"),
            "slash\\\\quote\\\"newline\\nreturn\\rtab\\tplain"
        );
    }

    #[test]
    fn escape_toml_basic_match_arm_observer() {
        let cases = [
            ('\\', "\\\\"),
            ('"', "\\\""),
            ('\n', "\\n"),
            ('\r', "\\r"),
            ('\t', "\\t"),
            ('x', "x"),
        ];

        for (input, expected) in cases {
            assert_eq!(escape_toml_basic(&input.to_string()), expected);
        }
    }

    #[test]
    fn removed_toml_blocks_call_presence_observer() {
        let rendered = r#"schema_version = "0.1"

[[allow]]
id = "allow-keep"
path = "docs/keep.md"

[[allow]]
id = "allow-remove"
path = "docs/remove.md"

[[allow]]
id = "allow-remove-suffix"
path = "docs/suffix.md"
"#;
        let candidates = vec![prune_candidate("allow-remove")];

        let blocks = removed_toml_blocks(rendered, &candidates);

        assert_eq!(blocks.len(), 1);
        let block = blocks
            .first()
            .unwrap_or_else(|| std::panic::panic_any("expected removed block"));
        assert!(block.contains("id = \"allow-remove\""));
        assert!(!block.contains("allow-keep"));
        assert!(!block.contains("allow-remove-suffix"));
    }

    fn prune_candidate(id: &str) -> PruneCandidate {
        PruneCandidate {
            id: id.to_string(),
            kind: FindingKind::NonRustFile,
            family: Some("documentation".to_string()),
            owner: "docs".to_string(),
            classification: "reviewed".to_string(),
            scope: "docs/remove.md".to_string(),
            reason: "fixture".to_string(),
        }
    }
}