assay-cli 3.23.0

CLI for Assay
use anyhow::{Context, Result};
use assay_evidence::lint::engine::{lint_bundle_with_options, LintOptions};
use assay_evidence::lint::packs::{load_packs, LoadedPack};
use assay_evidence::lint::sarif::{to_sarif_with_options, SarifOptions};
use assay_evidence::lint::Severity;
use assay_evidence::VerifyLimits;
use clap::Args;
use std::fs::File;

/// Default pack when --pack is omitted (ADR-023).
const DEFAULT_PACK: &str = "cicd-starter";

#[derive(Debug, Args, Clone)]
pub struct LintArgs {
    /// Bundle to lint (omit when using --explain)
    #[arg(value_name = "BUNDLE", required_unless_present = "explain")]
    pub bundle: Option<std::path::PathBuf>,

    /// Output format: json, sarif, or text
    #[arg(long, default_value = "text")]
    pub format: String,

    /// Fail (exit 1) if findings at or above this severity exist
    #[arg(long, default_value = "error")]
    pub fail_on: String,

    /// Comma-separated pack references (built-in name or file path)
    #[arg(long, value_delimiter = ',')]
    pub pack: Option<Vec<String>>,

    /// Explain a rule (print help_markdown and exit). Use short id (e.g. CICD-003) or canonical (e.g. cicd-starter@1.0.0:CICD-003)
    #[arg(long, value_name = "RULE_ID")]
    pub explain: Option<String>,

    /// Maximum results in output (for GitHub SARIF limits)
    #[arg(long, default_value = "500")]
    pub max_results: usize,
}

pub fn cmd_lint(args: LintArgs) -> Result<i32> {
    // --explain: show rule help and exit (no bundle needed)
    if let Some(rule_ref) = &args.explain {
        let packs = match load_packs_for_lint(&args.pack) {
            Ok(p) => p,
            Err(e) => {
                eprintln!("Pack loading failed: {}", e);
                return Ok(3);
            }
        };
        return explain_rule(&packs, rule_ref);
    }

    let bundle = args
        .bundle
        .as_ref()
        .expect("bundle required when not --explain");
    let f = File::open(bundle)
        .with_context(|| format!("failed to open bundle {}", bundle.display()))?;

    let limits = VerifyLimits::default();

    // Load packs: explicit --pack or default (ADR-023)
    let (packs, is_default_pack) = if let Some(pack_refs) = &args.pack {
        match load_packs(pack_refs) {
            Ok(p) => (p, false),
            Err(e) => {
                eprintln!("Pack loading failed: {}", e);
                return Ok(3); // Exit code 3 = pack error
            }
        }
    } else {
        match load_packs(&[DEFAULT_PACK.to_string()]) {
            Ok(p) => (p, true),
            Err(e) => {
                eprintln!("Default pack loading failed: {}", e);
                return Ok(3);
            }
        }
    };

    // Build lint options
    let options = LintOptions {
        packs,
        max_results: Some(args.max_results),
        bundle_path: Some(bundle.display().to_string()),
    };

    let result = match lint_bundle_with_options(f, limits, options) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("Verification failed: {}", e);
            return Ok(2);
        }
    };

    let report = &result.report;
    let pack_meta = &result.pack_meta;

    match args.format.as_str() {
        "json" => {
            // Add disclaimer to JSON output for compliance packs
            let mut json_report = serde_json::to_value(report)?;
            if let Some(meta) = pack_meta {
                if let Some(disclaimer) = &meta.disclaimer {
                    json_report
                        .as_object_mut()
                        .unwrap()
                        .insert("disclaimer".into(), serde_json::json!(disclaimer));
                }
                if meta.truncated {
                    json_report
                        .as_object_mut()
                        .unwrap()
                        .insert("truncated".into(), serde_json::json!(true));
                    json_report.as_object_mut().unwrap().insert(
                        "truncated_count".into(),
                        serde_json::json!(meta.truncated_count),
                    );
                }
            }
            println!("{}", serde_json::to_string_pretty(&json_report)?);
        }
        "sarif" => {
            #[allow(deprecated)]
            let sarif_options = SarifOptions {
                pack_meta: pack_meta.clone(),
                bundle_path: Some(bundle.display().to_string()),
                working_directory: None, // Deprecated: no longer included in output
            };
            let sarif = to_sarif_with_options(report, sarif_options);
            println!("{}", serde_json::to_string_pretty(&sarif)?);
        }
        _ => {
            eprintln!("Assay Evidence Lint");
            eprintln!("===================");
            eprintln!(
                "Bundle: {} (events: {}, verified: {})",
                report.bundle_meta.bundle_id, report.bundle_meta.event_count, report.verified
            );

            // Print pack info
            if let Some(meta) = pack_meta {
                let pack_str = meta
                    .packs
                    .iter()
                    .map(|p| {
                        let suffix = if is_default_pack && p.name == DEFAULT_PACK {
                            " (default)"
                        } else {
                            ""
                        };
                        format!("{}@{}{}", p.name, p.version, suffix)
                    })
                    .collect::<Vec<_>>()
                    .join(", ");
                eprintln!("Packs: {}", pack_str);
                if is_default_pack {
                    eprintln!("(Next: add compliance packs — --pack eu-ai-act-baseline or --pack soc2-baseline)");
                }

                // Print disclaimer for compliance packs
                if let Some(disclaimer) = &meta.disclaimer {
                    eprintln!();
                    eprintln!("⚠️  COMPLIANCE DISCLAIMER");
                    eprintln!("{}", disclaimer);
                }

                if meta.truncated {
                    eprintln!();
                    eprintln!(
                        "⚠️  Results truncated: {} findings omitted (--max-results {})",
                        meta.truncated_count, args.max_results
                    );
                }
            }
            eprintln!();

            if report.findings.is_empty() {
                eprintln!("No findings.");
            } else {
                for finding in &report.findings {
                    let loc_str = match &finding.location {
                        Some(loc) => format!("seq:{} line:{}", loc.seq, loc.line),
                        None => "global".into(),
                    };

                    // Extract article_ref from tags if present
                    let article_ref = finding
                        .tags
                        .iter()
                        .find(|t| t.starts_with("article_ref:"))
                        .map(|t| t.strip_prefix("article_ref:").unwrap_or(""));

                    if let Some(ref_) = article_ref {
                        eprintln!(
                            "[{}] {} ({}) {} [Article {}]",
                            finding.severity, finding.rule_id, loc_str, finding.message, ref_
                        );
                    } else {
                        eprintln!(
                            "[{}] {} ({}) {}",
                            finding.severity, finding.rule_id, loc_str, finding.message
                        );
                    }
                    eprintln!(
                        "  Hint: run `assay evidence lint --explain {}`",
                        finding.rule_id
                    );
                }
                eprintln!();
                eprintln!(
                    "Summary: {} total ({} errors, {} warnings, {} info)",
                    report.summary.total,
                    report.summary.errors,
                    report.summary.warnings,
                    report.summary.infos
                );
            }
        }
    }

    // Exit codes: 0 = no findings at/above threshold, 1 = findings found, 2 = verification failure, 3 = pack error
    let threshold = match args.fail_on.as_str() {
        "error" => Severity::Error,
        "warn" | "warning" => Severity::Warn,
        "info" => Severity::Info,
        _ => Severity::Error,
    };

    if report.has_findings_at_or_above(&threshold) {
        Ok(1)
    } else {
        Ok(0)
    }
}

/// Load packs for lint (default or explicit).
fn load_packs_for_lint(
    pack_refs: &Option<Vec<String>>,
) -> Result<Vec<LoadedPack>, assay_evidence::lint::packs::PackError> {
    let refs: Vec<String> = pack_refs
        .clone()
        .unwrap_or_else(|| vec![DEFAULT_PACK.to_string()]);
    load_packs(&refs)
}

/// Print rule help and exit. Returns Ok(0) on success, Ok(1) if not found or ambiguous.
fn explain_rule(packs: &[LoadedPack], rule_ref: &str) -> Result<i32> {
    let want_canonical = rule_ref.contains('@') && rule_ref.contains(':');
    let mut matches: Vec<(
        &LoadedPack,
        &assay_evidence::lint::packs::schema::PackRule,
        String,
    )> = Vec::new();

    for p in packs {
        for r in &p.definition.rules {
            let canonical = p.canonical_rule_id(&r.id);
            let ok = if want_canonical {
                canonical == rule_ref
            } else {
                r.id == rule_ref || canonical == rule_ref
            };
            if ok {
                matches.push((p, r, canonical));
            }
        }
    }

    if matches.is_empty() {
        eprintln!("Rule '{}' not found in selected packs.", rule_ref);
        return Ok(1);
    }

    if matches.len() > 1 && !want_canonical {
        eprintln!("Rule '{}' is ambiguous. Specify canonical form:", rule_ref);
        for (_, _, canonical) in &matches {
            eprintln!("  --explain {}", canonical);
        }
        return Ok(1);
    }

    let (p, r, canonical) = &matches[0];

    println!("{}", canonical);
    println!("Pack: {}@{}", p.definition.name, p.definition.version);
    println!("Severity: {:?}", r.severity);
    println!();
    println!("{}", r.description);

    if let Some(article) = &r.article_ref {
        println!();
        println!("Reference: {}", article);
    }

    if let Some(help) = &r.help_markdown {
        println!();
        println!("---");
        println!("{}", help.trim());
        println!();
    } else {
        println!();
        println!("(No additional help text provided.)");
    }

    Ok(0)
}