assay-cli 3.7.0

CLI for Assay
use crate::cli::args::{
    OutputFormat, TrustBasisArgs, TrustBasisDiffArgs, TrustBasisGenerateArgs, TrustBasisSub,
};
use crate::exit_codes::{EXIT_SUCCESS, EXIT_TEST_FAILURE};
use anyhow::{bail, Context, Result};
use assay_evidence::lint::engine::LintOptions;
use assay_evidence::lint::packs::load_packs;
use assay_evidence::{
    diff_trust_basis, duplicate_trust_basis_claim_ids, generate_trust_basis,
    to_canonical_json_bytes, TrustBasis, TrustBasisClaimLevelDiff, TrustBasisClaimMetadataDiff,
    TrustBasisClaimPresenceDiff, TrustBasisDiffReport, TrustBasisOptions, TrustClaimBoundary,
    TrustClaimId, TrustClaimLevel, TrustClaimSource, VerifyLimits,
};
use std::fs::File;
use std::io::Write;

pub fn run(args: TrustBasisArgs) -> Result<i32> {
    match args.cmd {
        TrustBasisSub::Generate(args) => cmd_generate(args),
        TrustBasisSub::Diff(args) => cmd_diff(args),
    }
}

fn cmd_generate(args: TrustBasisGenerateArgs) -> Result<i32> {
    let bundle = File::open(&args.bundle)
        .with_context(|| format!("failed to open bundle {}", args.bundle.display()))?;

    let lint = if let Some(pack_refs) = &args.pack {
        let packs = load_packs(pack_refs).context("failed to load trust-basis packs")?;
        Some(LintOptions {
            packs,
            max_results: Some(args.max_results),
            bundle_path: Some(args.bundle.display().to_string()),
        })
    } else {
        None
    };

    let trust_basis =
        generate_trust_basis(bundle, VerifyLimits::default(), TrustBasisOptions { lint })
            .context("failed to generate trust basis")?;

    let output =
        to_canonical_json_bytes(&trust_basis).context("failed to serialize trust basis")?;

    if let Some(out_path) = args.out {
        if let Some(parent) = out_path.parent() {
            std::fs::create_dir_all(parent).with_context(|| {
                format!(
                    "failed to create trust-basis output directory {}",
                    parent.display()
                )
            })?;
        }
        std::fs::write(&out_path, output)
            .with_context(|| format!("failed to write trust basis to {}", out_path.display()))?;
        eprintln!("Wrote canonical trust basis to {}", out_path.display());
    } else {
        std::io::stdout()
            .write_all(&output)
            .context("failed to write trust basis to stdout")?;
    }

    Ok(EXIT_SUCCESS)
}

fn cmd_diff(args: TrustBasisDiffArgs) -> Result<i32> {
    let baseline = read_trust_basis(&args.baseline)?;
    let candidate = read_trust_basis(&args.candidate)?;
    ensure_unique_claim_ids("baseline", &baseline)?;
    ensure_unique_claim_ids("candidate", &candidate)?;
    let report = diff_trust_basis(&baseline, &candidate);

    match args.format {
        OutputFormat::Text => write_diff_text(&report).context("failed to write diff output")?,
        OutputFormat::Json => write_diff_json(&report).context("failed to write diff output")?,
    }

    if args.fail_on_regression && report.has_regressions() {
        eprintln!("Trust Basis regression check failed");
        return Ok(EXIT_TEST_FAILURE);
    }

    Ok(EXIT_SUCCESS)
}

fn read_trust_basis(path: &std::path::Path) -> Result<TrustBasis> {
    let file = File::open(path)
        .with_context(|| format!("failed to open trust basis {}", path.display()))?;
    serde_json::from_reader(file)
        .with_context(|| format!("failed to parse trust basis {}", path.display()))
}

fn ensure_unique_claim_ids(label: &str, trust_basis: &TrustBasis) -> Result<()> {
    let duplicates = duplicate_trust_basis_claim_ids(trust_basis);
    if duplicates.is_empty() {
        return Ok(());
    }

    let duplicate_labels = duplicates
        .into_iter()
        .map(id_label)
        .collect::<Vec<_>>()
        .join(", ");
    bail!("{label} Trust Basis contains duplicate claim id(s): {duplicate_labels}");
}

fn write_diff_json(report: &TrustBasisDiffReport) -> Result<()> {
    let mut stdout = std::io::stdout();
    serde_json::to_writer_pretty(&mut stdout, report)?;
    stdout.write_all(b"\n")?;
    Ok(())
}

fn write_diff_text(report: &TrustBasisDiffReport) -> Result<()> {
    let mut stdout = std::io::stdout();
    writeln!(stdout, "Assay Trust Basis Diff")?;
    writeln!(stdout, "Claim identity: {}", report.claim_identity)?;
    if !report.has_changes() {
        writeln!(stdout, "No Trust Basis differences found.")?;
        return Ok(());
    }

    write_level_diffs(&mut stdout, "Regressions", &report.regressed_claims)?;
    write_level_diffs(&mut stdout, "Improvements", &report.improved_claims)?;
    write_presence_diffs(&mut stdout, "Removed claims", &report.removed_claims)?;
    write_presence_diffs(&mut stdout, "Added claims", &report.added_claims)?;
    write_metadata_diffs(&mut stdout, &report.metadata_changes)?;
    writeln!(stdout, "Unchanged claims: {}", report.unchanged_claim_count)?;
    Ok(())
}

fn write_level_diffs(
    writer: &mut impl Write,
    title: &str,
    diffs: &[TrustBasisClaimLevelDiff],
) -> Result<()> {
    if diffs.is_empty() {
        return Ok(());
    }

    writeln!(writer, "{title}:")?;
    for diff in diffs {
        writeln!(
            writer,
            "- {}: {} -> {}",
            id_label(diff.claim_id),
            level_label(diff.baseline_level),
            level_label(diff.candidate_level)
        )?;
    }
    Ok(())
}

fn write_presence_diffs(
    writer: &mut impl Write,
    title: &str,
    diffs: &[TrustBasisClaimPresenceDiff],
) -> Result<()> {
    if diffs.is_empty() {
        return Ok(());
    }

    writeln!(writer, "{title}:")?;
    for diff in diffs {
        writeln!(
            writer,
            "- {}: {} ({}, {})",
            id_label(diff.claim_id),
            optional_level_label(diff.baseline_level.or(diff.candidate_level)),
            optional_source_label(diff.baseline_source.or(diff.candidate_source)),
            optional_boundary_label(diff.baseline_boundary.or(diff.candidate_boundary))
        )?;
    }
    Ok(())
}

fn write_metadata_diffs(
    writer: &mut impl Write,
    diffs: &[TrustBasisClaimMetadataDiff],
) -> Result<()> {
    if diffs.is_empty() {
        return Ok(());
    }

    writeln!(writer, "Metadata changes:")?;
    for diff in diffs {
        writeln!(
            writer,
            "- {}: source {} -> {}, boundary {} -> {}, note changed: {}",
            id_label(diff.claim_id),
            source_label(diff.baseline_source),
            source_label(diff.candidate_source),
            boundary_label(diff.baseline_boundary),
            boundary_label(diff.candidate_boundary),
            diff.note_changed
        )?;
    }
    Ok(())
}

fn id_label(id: TrustClaimId) -> String {
    json_label(id)
}

fn level_label(level: TrustClaimLevel) -> String {
    json_label(level)
}

fn source_label(source: TrustClaimSource) -> String {
    json_label(source)
}

fn boundary_label(boundary: TrustClaimBoundary) -> String {
    json_label(boundary)
}

fn optional_level_label(level: Option<TrustClaimLevel>) -> String {
    level
        .map(level_label)
        .unwrap_or_else(|| "absent".to_string())
}

fn optional_source_label(source: Option<TrustClaimSource>) -> String {
    source
        .map(source_label)
        .unwrap_or_else(|| "unknown-source".to_string())
}

fn optional_boundary_label(boundary: Option<TrustClaimBoundary>) -> String {
    boundary
        .map(boundary_label)
        .unwrap_or_else(|| "unknown-boundary".to_string())
}

fn json_label(value: impl serde::Serialize) -> String {
    serde_json::to_string(&value)
        .expect("trust basis labels should serialize")
        .trim_matches('"')
        .to_string()
}