cargo-allow 0.1.9

Source-tree exception ledger and policy scanner for Rust repositories.
use super::MigrateContext;
use allow_core::{AllowConfig, FindingKind};
use std::path::{Path, PathBuf};

pub(super) fn render_migrate_summary(
    cfg: &AllowConfig,
    context: &MigrateContext,
    output: &Path,
    force: bool,
) -> String {
    let output = output.display().to_string();
    allow_report::render_migrate_human(migrate_report(cfg, context, &output, force))
}

pub(super) fn render_migrate_summary_json(
    cfg: &AllowConfig,
    context: &MigrateContext,
    output: &Path,
    force: bool,
) -> String {
    let output = output.display().to_string();
    allow_report::render_migrate_json(migrate_report(cfg, context, &output, force))
}

fn migrate_report<'a>(
    cfg: &'a AllowConfig,
    context: &'a MigrateContext,
    output: &'a str,
    force: bool,
) -> allow_report::MigrateReport<'a> {
    let notes = allow_policy_legacy::migration_notes();
    let mut report = allow_report::MigrateReport::from_config(
        allow_report::InventoryContext::policy_migration(
            &context.inventory_source,
            context.source_tree_root.as_deref(),
            context.inventory_files,
        ),
        cfg,
        &context.input_kind,
        &context.input_path,
        output,
        force,
        notes,
    );
    let evidence_root = evidence_diagnostic_root(context);
    let broken_evidence_links =
        allow_policy::broken_evidence_link_count(evidence_root.as_path(), cfg);
    report.broken_evidence_links = (broken_evidence_links > 0).then_some(broken_evidence_links);
    let unsafe_broken_evidence_links =
        unsafe_broken_evidence_link_count(evidence_root.as_path(), cfg);
    report.unsafe_broken_evidence_links =
        (unsafe_broken_evidence_links > 0).then_some(unsafe_broken_evidence_links);
    let weak_evidence_references =
        allow_policy::weak_evidence_reference_count(evidence_root.as_path(), cfg);
    report.weak_evidence_references =
        (weak_evidence_references > 0).then_some(weak_evidence_references);
    let unsafe_weak_evidence_references =
        unsafe_weak_evidence_reference_count(evidence_root.as_path(), cfg);
    report.unsafe_weak_evidence_references =
        (unsafe_weak_evidence_references > 0).then_some(unsafe_weak_evidence_references);
    report
}

fn evidence_diagnostic_root(context: &MigrateContext) -> PathBuf {
    context
        .source_tree_root
        .as_deref()
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."))
}

fn unsafe_weak_evidence_reference_count(root: &Path, cfg: &AllowConfig) -> usize {
    cfg.allow
        .iter()
        .filter(|entry| entry.kind == FindingKind::Unsafe)
        .flat_map(|entry| allow_policy::policy_reference_diagnostics(root, entry))
        .filter(|reference| reference.diagnostic.status.is_weak_reference())
        .count()
}

fn unsafe_broken_evidence_link_count(root: &Path, cfg: &AllowConfig) -> usize {
    cfg.allow
        .iter()
        .filter(|entry| entry.kind == FindingKind::Unsafe)
        .flat_map(|entry| allow_policy::policy_reference_diagnostics(root, entry))
        .filter(|reference| reference.diagnostic.status.is_broken_local_link())
        .count()
}

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

    #[test]
    fn migrate_report_call_presence_observer() {
        let root = unique_test_dir("migrate-report-evidence-counts");
        fs::create_dir_all(root.join("docs"))
            .unwrap_or_else(|err| std::panic::panic_any(format!("create docs dir: {err}")));
        fs::write(root.join("docs/present.md"), "retained evidence")
            .unwrap_or_else(|err| std::panic::panic_any(format!("write evidence: {err}")));
        let mut cfg = AllowConfig::empty();
        cfg.allow.push(allow_entry(
            "allow-unsafe",
            FindingKind::Unsafe,
            &[
                "doc:docs/missing-unsafe.md",
                "TODO: add unsafe-review evidence",
            ],
            &["doc:docs/missing-unsafe-link.md", "ticket:unsafe-123"],
        ));
        cfg.allow.push(allow_entry(
            "allow-panic",
            FindingKind::Panic,
            &["doc:docs/missing-panic.md", "ticket:panic-123"],
            &["doc:docs/present.md"],
        ));
        let context = migrate_context(Some(root.display().to_string()));

        let report = migrate_report(&cfg, &context, "policy/allow.toml", true);

        assert_eq!(report.allow_entries, 2);
        assert_eq!(report.unsafe_entries, 1);
        assert_eq!(report.input_kind, "from");
        assert_eq!(report.input_path, "policy/legacy.toml");
        assert_eq!(report.output_path, "policy/allow.toml");
        assert!(report.force);
        assert_eq!(report.broken_evidence_links, Some(3));
        assert_eq!(report.unsafe_broken_evidence_links, Some(2));
        assert_eq!(report.weak_evidence_references, Some(3));
        assert_eq!(report.unsafe_weak_evidence_references, Some(2));
        assert!(report.notes.contains("legacy"));
        remove_test_dir(&root);
    }

    #[test]
    fn evidence_diagnostic_root_call_presence_observer() {
        let explicit = migrate_context(Some("fixture-root".to_string()));
        let implicit = migrate_context(None);

        assert_eq!(
            evidence_diagnostic_root(&explicit),
            PathBuf::from("fixture-root")
        );
        assert_eq!(evidence_diagnostic_root(&implicit), PathBuf::from("."));
    }

    #[test]
    fn unsafe_weak_evidence_reference_count_call_presence_observer() {
        let root = unique_test_dir("migrate-render-unsafe-weak");
        let mut cfg = AllowConfig::empty();
        cfg.allow.push(allow_entry(
            "allow-unsafe",
            FindingKind::Unsafe,
            &["ticket:unsafe-123", "TODO: add unsafe evidence"],
            &["spreadsheet:manual"],
        ));
        cfg.allow.push(allow_entry(
            "allow-panic",
            FindingKind::Panic,
            &["ticket:panic-123"],
            &["doc:docs/missing-panic.md"],
        ));

        assert_eq!(unsafe_weak_evidence_reference_count(&root, &cfg), 3);
        remove_test_dir(&root);
    }

    #[test]
    fn unsafe_broken_evidence_link_count_call_presence_observer() {
        let root = unique_test_dir("migrate-render-unsafe-broken");
        fs::create_dir_all(root.join("docs"))
            .unwrap_or_else(|err| std::panic::panic_any(format!("create docs dir: {err}")));
        fs::write(root.join("docs/present.md"), "retained evidence")
            .unwrap_or_else(|err| std::panic::panic_any(format!("write evidence: {err}")));
        let mut cfg = AllowConfig::empty();
        cfg.allow.push(allow_entry(
            "allow-unsafe",
            FindingKind::Unsafe,
            &["doc:docs/missing-unsafe.md", "doc:docs/present.md"],
            &["doc:docs/missing-unsafe-link.md", "ticket:unsafe-123"],
        ));
        cfg.allow.push(allow_entry(
            "allow-panic",
            FindingKind::Panic,
            &["doc:docs/missing-panic.md"],
            &["doc:docs/present.md"],
        ));

        assert_eq!(unsafe_broken_evidence_link_count(&root, &cfg), 2);
        remove_test_dir(&root);
    }

    fn migrate_context(source_tree_root: Option<String>) -> MigrateContext {
        MigrateContext {
            inventory_source: "git_tracked".to_string(),
            source_tree_root,
            inventory_files: Some(7),
            input_kind: "from".to_string(),
            input_path: "policy/legacy.toml".to_string(),
        }
    }

    fn allow_entry(id: &str, kind: FindingKind, evidence: &[&str], links: &[&str]) -> AllowEntry {
        AllowEntry {
            id: id.to_string(),
            kind,
            family: Some("fixture".to_string()),
            path: Some(PathBuf::from("src/lib.rs")),
            glob: None,
            owner: "test".to_string(),
            classification: "reviewed".to_string(),
            reason: "fixture".to_string(),
            evidence: evidence.iter().map(|item| item.to_string()).collect(),
            links: links.iter().map(|item| item.to_string()).collect(),
            occurrence_limit: None,
            lifecycle: Lifecycle::empty(),
            selector: Selector::default(),
            last_seen: None,
        }
    }

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

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