cargo-allow 0.1.1

Source-tree exception ledger and policy scanner for Rust repositories.
use super::test_support::{row_status, test_entry, test_finding, test_outcome};
use super::*;
use crate::{CargoAllowCli, CargoAllowCommand};
use clap::Parser;
use serde_json::Value;
use std::path::Path;

fn argv(items: Vec<&str>) -> Vec<String> {
    items.into_iter().map(String::from).collect()
}

#[test]
fn clap_parses_list_json_filters() {
    let parsed = CargoAllowCli::try_parse_from(argv(vec![
        "cargo-allow",
        "list",
        "--kind",
        "unsafe",
        "--family",
        "unsafe_fn",
        "--owner",
        "runtime",
        "--classification",
        "baseline_debt",
        "--path",
        "crates/allow-core",
        "--source-package",
        "allow-core",
        "--allow-id",
        "allow-runtime",
        "--status",
        "baseline_debt",
        "--expired",
        "--review-due",
        "--stale",
        "--baseline-debt",
        "--broad-scope",
        "--missing-evidence",
        "--format",
        "json",
        "--output",
        "target/list.json",
    ]))
    .unwrap_or_else(|err| std::panic::panic_any(format!("CLI should parse list args: {err}")));

    assert!(matches!(
        parsed.command,
        Some(CargoAllowCommand::List(ListArgs {
            kind: Some(kind),
            family: Some(family),
            owner: Some(owner),
            classification: Some(classification),
            path: Some(path_filter),
            source_package: Some(source_package),
            allow_id: Some(allow_id),
            status: Some(status),
            expired: true,
            review_due: true,
            stale: true,
            baseline_debt: true,
            broad_scope: true,
            missing_evidence: true,
            format: ListFormat::Json,
            output: Some(path),
            ..
        })) if kind == "unsafe"
            && family == "unsafe_fn"
            && owner == "runtime"
            && classification == "baseline_debt"
            && path_filter == "crates/allow-core"
            && source_package == "allow-core"
            && allow_id == "allow-runtime"
            && status == "baseline_debt"
            && path == Path::new("target/list.json")
    ));
}

#[test]
fn list_rows_report_lifecycle_stale_and_baseline_status() {
    let mut cfg = AllowConfig::empty();
    let mut expired = test_entry("allow-expired", FindingKind::Panic);
    expired.lifecycle.expires = Some("2000-01-01".to_string());
    let mut review_due = test_entry("allow-review", FindingKind::Panic);
    review_due.lifecycle.review_after = Some("2000-01-01".to_string());
    let mut baseline = test_entry("allow-baseline", FindingKind::Panic);
    baseline.classification = "baseline_debt".to_string();
    let stale = test_entry("allow-stale", FindingKind::Panic);
    cfg.allow = vec![expired, review_due, baseline, stale];
    let outcomes = vec![
        test_outcome(
            MatchStatus::Matched,
            Some("allow-expired"),
            Some(0),
            "matched",
        ),
        test_outcome(
            MatchStatus::Matched,
            Some("allow-review"),
            Some(1),
            "matched",
        ),
        test_outcome(
            MatchStatus::Matched,
            Some("allow-baseline"),
            Some(2),
            "matched",
        ),
        test_outcome(MatchStatus::Stale, Some("allow-stale"), None, "stale"),
    ];
    let expired_finding = test_finding(
        FindingKind::NonRustFile,
        None,
        "tracked-expired.file",
        "tracked_file",
    );
    let mut review_finding = test_finding(
        FindingKind::NonRustFile,
        None,
        "tracked-review.file",
        "tracked_file",
    );
    review_finding.identity.crate_name = Some("review-package".to_string());
    let stale_finding = test_finding(
        FindingKind::NonRustFile,
        None,
        "tracked-stale.file",
        "tracked_file",
    );
    let findings = vec![expired_finding, review_finding, stale_finding];

    let rows = list_rows(&cfg, &findings, &outcomes);

    assert_eq!(row_status(&rows, "allow-expired"), MatchStatus::Expired);
    assert_eq!(row_status(&rows, "allow-review"), MatchStatus::ReviewDue);
    assert_eq!(
        rows.iter()
            .find(|row| row.id == "allow-review")
            .and_then(|row| row.source_package.as_deref()),
        Some("review-package")
    );
    assert_eq!(
        row_status(&rows, "allow-baseline"),
        MatchStatus::BaselineDebt
    );
    assert_eq!(row_status(&rows, "allow-stale"), MatchStatus::Stale);
}

#[test]
fn list_rows_report_broad_scope_from_selector_glob() {
    let mut cfg = AllowConfig::empty();
    let mut entry = test_entry("allow-broad", FindingKind::NonRustFile);
    entry.selector.glob = Some("scripts/**".to_string());
    cfg.allow.push(entry);
    let findings = vec![test_finding(
        FindingKind::NonRustFile,
        None,
        "scripts/release.sh",
        "tracked_file",
    )];
    let outcomes = vec![test_outcome(
        MatchStatus::Matched,
        Some("allow-broad"),
        Some(0),
        "matched",
    )];

    let rows = list_rows(&cfg, &findings, &outcomes);

    assert!(
        rows.iter()
            .find(|row| row.id == "allow-broad")
            .is_some_and(|row| row.broad_scope)
    );
}

#[test]
fn render_list_rows_json_records_context_filters_and_rows() {
    let json = sample_list_json_for_contract_test();
    let value = parse_json("list artifact", &json);

    assert!(json.contains("\"schema_version\": 1"));
    assert!(json.contains(&format!(
        "\"schema_id\": \"{}\"",
        allow_report::LIST_SCHEMA_ID
    )));
    assert!(json.contains("\"command\": \"list\""));
    assert!(json.contains("\"claim_boundary\""));
    assert!(json.contains("\"scanner_limitations\""));
    assert!(json.contains("\"source\": \"git_tracked\""));
    assert!(json.contains("\"root\": \"H:/Code/Rust/cargo-allow\""));
    assert!(json.contains("\"files_scanned\": 46"));
    assert!(json.contains("\"kind\": \"panic\""));
    assert!(json.contains("\"family\": \"unwrap\""));
    assert!(json.contains("\"baseline_debt\": true"));
    assert!(json.contains("\"allow_entries\": 1"));
    assert!(json.contains("\"id\": \"allow-json\""));
    assert!(json.contains("\"source_package\": \"allow-core\""));
    assert!(json.contains("\"evidence_count\": 2"));
    assert!(json.contains("\"selector_precision\": 7"));
    assert!(json.contains("\"broad_scope\": false"));
    assert_eq!(
        value.pointer("/filters/kind").and_then(Value::as_str),
        Some("panic")
    );
    assert_eq!(
        value.pointer("/filters/family").and_then(Value::as_str),
        Some("unwrap")
    );
    assert_eq!(
        value.pointer("/filters/owner").and_then(Value::as_str),
        Some("parser")
    );
    assert_eq!(
        value
            .pointer("/filters/classification")
            .and_then(Value::as_str),
        Some("baseline_debt")
    );
    assert_eq!(
        value.pointer("/filters/path").and_then(Value::as_str),
        Some("src/lib.rs")
    );
    assert_eq!(
        value
            .pointer("/filters/source_package")
            .and_then(Value::as_str),
        Some("allow-core")
    );
    assert_eq!(
        value.pointer("/filters/allow_id").and_then(Value::as_str),
        Some("allow-json")
    );
    assert_eq!(
        value.pointer("/filters/status").and_then(Value::as_str),
        Some("baseline_debt")
    );
    assert_eq!(
        value.pointer("/filters/expired").and_then(Value::as_bool),
        Some(false)
    );
    assert_eq!(
        value
            .pointer("/filters/review_due")
            .and_then(Value::as_bool),
        Some(false)
    );
    assert_eq!(
        value.pointer("/filters/stale").and_then(Value::as_bool),
        Some(false)
    );
    assert_eq!(
        value
            .pointer("/filters/baseline_debt")
            .and_then(Value::as_bool),
        Some(true)
    );
    assert_eq!(
        value
            .pointer("/filters/broad_scope")
            .and_then(Value::as_bool),
        Some(false)
    );
    assert_eq!(
        value
            .pointer("/filters/missing_evidence")
            .and_then(Value::as_bool),
        Some(false)
    );
}

fn parse_json(name: &str, json: &str) -> Value {
    match serde_json::from_str(json) {
        Ok(value) => value,
        Err(err) => std::panic::panic_any(format!("{name} should parse as JSON: {err}\n{json}")),
    }
}