cargo-allow 0.1.11

Source-tree exception ledger and policy scanner for Rust repositories.
use allow_core::{CargoAllowResult, FindingKind, MatchStatus};
use allow_match::{CheckMode, evaluate};
use allow_policy::render_policy;
use allow_report::MutationReceipt;

use crate::{
    EvidenceValidationMode, MutationLock, SourceTreeReportContext, emit_stderr_text,
    load_world_with_evidence_mode, write_file_no_overwrite,
};

#[path = "propose_args.rs"]
mod propose_args;
#[path = "propose_baseline.rs"]
mod propose_baseline;
#[path = "propose_render.rs"]
mod propose_render;
#[path = "propose_types.rs"]
mod propose_types;
pub(crate) use propose_args::ProposeArgs;
use propose_args::ProposeSummaryFormat;
use propose_baseline::{default_baseline_expiry, entry_from_finding};
use propose_render::{render_propose_summary, render_propose_summary_json};
pub(super) use propose_types::ProposeContext;

#[cfg(test)]
use allow_core::{Finding, SimpleDate};
#[cfg(test)]
use propose_baseline::BASELINE_DEBT_DEFAULT_DAYS;

pub(crate) fn cmd_propose(args: &ProposeArgs) -> CargoAllowResult<()> {
    let _mutation_lock = args
        .write
        .as_deref()
        .map(MutationLock::acquire)
        .transpose()?;
    let (root, cfg, findings, inventory_facts, _federation) = load_world_with_evidence_mode(
        args.root.root.as_deref(),
        args.config.as_deref(),
        false,
        args.kind.as_deref(),
        args.include_untracked,
        EvidenceValidationMode::ReportOnly,
    )?;
    let outcomes = evaluate(&cfg, &findings, CheckMode::Audit);
    let mut proposed = cfg.clone();
    let start = proposed.allow.len() + 1;
    let mut proposed_entries = 0;
    let mut unsafe_proposed_entries = 0;
    let expires = args.expires.clone().unwrap_or_else(default_baseline_expiry);
    for (n, outcome) in outcomes
        .iter()
        .filter(|o| o.status == MatchStatus::New)
        .enumerate()
    {
        // --max limits the number of proposed entries to avoid overwhelming
        // first-hour adopters on noisy codebases (#1815). --max 0 means
        // unlimited.
        if args.max > 0 && n >= args.max {
            break;
        }
        if let Some(finding) = outcome.finding_index.and_then(|idx| findings.get(idx)) {
            if finding.kind == FindingKind::Unsafe {
                unsafe_proposed_entries += 1;
            }
            proposed
                .allow
                .push(entry_from_finding(finding, start + n, &expires));
            proposed_entries += 1;
        }
    }
    let rendered = render_policy(&proposed);
    let original_allow_count = cfg.allow.len();
    let changed_allow_ids = proposed
        .allow
        .iter()
        .skip(original_allow_count)
        .map(|entry| entry.id.as_str())
        .collect::<Vec<_>>();
    let after_fingerprints = proposed
        .allow
        .iter()
        .skip(original_allow_count)
        .map(|entry| Some(allow_core::allow_entry_content_fingerprint(entry)))
        .collect::<Vec<_>>();
    let repo_root = root.display().to_string();
    let config_source = crate::policy_config::config_path(&root, args.config.as_deref())
        .map(|path| path.display().to_string());
    let mutation_receipt = MutationReceipt {
        operation: "propose",
        tool_version: env!("CARGO_PKG_VERSION"),
        repo_root: Some(repo_root.as_str()),
        config_source: config_source.as_deref(),
        ledger_ids: Vec::new(),
        before_fingerprints: vec![None; changed_allow_ids.len()],
        changed_allow_ids,
        after_fingerprints,
        result: if args.write.is_some() {
            "written"
        } else {
            "stdout"
        },
        next_commands: vec![
            "cargo-allow worklist --baseline-debt --format json".to_string(),
            "cargo-allow check --mode no-new".to_string(),
        ],
    };
    if let Some(path) = &args.write {
        write_file_no_overwrite(path, &rendered, args.force)?;
    } else {
        println!("{rendered}");
    }
    let source_context = SourceTreeReportContext::new(&root, inventory_facts);
    let context = ProposeContext {
        inventory: source_context.inventory(),
        kind_filter: args.kind.as_deref(),
        mutation_receipt,
    };
    let summary = match args.summary_format {
        ProposeSummaryFormat::Human => render_propose_summary(
            findings.len(),
            proposed_entries,
            unsafe_proposed_entries,
            expires.as_str(),
            args.write.as_deref(),
            context,
        ),
        ProposeSummaryFormat::Json => render_propose_summary_json(
            findings.len(),
            proposed_entries,
            unsafe_proposed_entries,
            expires.as_str(),
            args.write.as_deref(),
            args.force,
            context,
        ),
    };
    emit_stderr_text(args.summary_output.as_deref(), &summary)?;
    Ok(())
}

#[cfg(test)]
pub(crate) fn sample_propose_json_for_contract_test() -> String {
    use std::path::Path;

    render_propose_summary_json(
        12,
        3,
        1,
        "2026-08-01",
        Some(Path::new("policy/allow.proposed.toml")),
        true,
        ProposeContext {
            inventory: allow_report::InventoryContext::source_syntax(
                "git_tracked",
                Some("H:/Code/Rust/cargo-allow"),
                Some(51),
            ),
            kind_filter: Some("panic"),
            mutation_receipt: MutationReceipt {
                operation: "propose",
                tool_version: env!("CARGO_PKG_VERSION"),
                repo_root: Some("H:/Code/Rust/cargo-allow"),
                config_source: Some("policy/allow.toml"),
                ledger_ids: Vec::new(),
                changed_allow_ids: vec!["allow-0001", "allow-0002", "allow-0003"],
                before_fingerprints: vec![None, None, None],
                after_fingerprints: vec![
                    Some("sha256:v1:0000000000000000000000000000000000000000000000000000000000000001".to_string()),
                    Some("sha256:v1:0000000000000000000000000000000000000000000000000000000000000002".to_string()),
                    Some("sha256:v1:0000000000000000000000000000000000000000000000000000000000000003".to_string()),
                ],
                result: "written",
                next_commands: vec![
                    "cargo-allow worklist --baseline-debt --format json".to_string(),
                    "cargo-allow check --mode no-new".to_string(),
                ],
            },
        },
    )
}

#[cfg(test)]
#[path = "propose_tests.rs"]
mod tests;