cargo-allow 0.1.3

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 crate::{
    EvidenceValidationMode, 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 (root, cfg, findings, inventory_facts) = 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()
    {
        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);
    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(),
    };
    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"),
        },
    )
}

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