Skip to main content

fleetreach_cli/
diff.rs

1//! The `diff` command: compare two saved fleet reports (`scan -f json`) and show
2//! what appeared, what cleared, and which surviving advisories changed blast
3//! radius. A first-class take on the scan `--baseline` flag — that flag keeps only
4//! *new* findings from a live scan; `diff` is pure (no scanning, no DB, no network),
5//! works off two JSON files, and reports fixed + still-open too.
6//!
7//! Exit code: `0` clean (no gating-new findings), `1` a new finding tripped the
8//! gate, `2` a file could not be read or parsed. `--exit-zero` forces `0` for a
9//! report-only run.
10
11use std::fs;
12use std::io::IsTerminal;
13use std::path::PathBuf;
14
15use clap::{Parser, ValueEnum};
16use fleetreach_core::FleetReport;
17use fleetreach_report as report;
18
19use crate::cli::{fail, SeverityArg};
20
21#[derive(Parser)]
22pub struct DiffArgs {
23    /// Prior report JSON — the baseline to compare against.
24    baseline: PathBuf,
25    /// Current report JSON, from `fleetreach scan -f json`.
26    current: PathBuf,
27    #[arg(short, long, value_enum, default_value_t = DiffFormat::Table)]
28    format: DiffFormat,
29    #[arg(
30        long,
31        value_enum,
32        default_value_t = SeverityArg::Low,
33        help = "fail if any NEW vuln is at/above this severity (Unknown always counts)"
34    )]
35    fail_on: SeverityArg,
36    #[arg(long, help = "also fail if a NEW supply-chain warning appeared")]
37    fail_on_warnings: bool,
38    #[arg(long, help = "always exit 0 (report-only; never gate on new findings)")]
39    exit_zero: bool,
40}
41
42#[derive(Clone, Copy, ValueEnum)]
43enum DiffFormat {
44    Table,
45    Json,
46}
47
48/// Run `fleetreach diff`, returning the process exit code.
49pub fn run_diff(args: DiffArgs) -> u8 {
50    let baseline = match read_report(&args.baseline) {
51        Ok(r) => r,
52        Err(e) => return fail(&e),
53    };
54    let current = match read_report(&args.current) {
55        Ok(r) => r,
56        Err(e) => return fail(&e),
57    };
58
59    let diff = report::diff_reports(&baseline, &current);
60    match args.format {
61        DiffFormat::Json => match report::to_diff_json(&diff) {
62            Ok(json) => println!("{json}"),
63            Err(e) => return fail(&format!("serializing diff: {e}")),
64        },
65        DiffFormat::Table => {
66            println!(
67                "{}",
68                report::to_diff_table(&diff, std::io::stdout().is_terminal())
69            );
70        }
71    }
72
73    if args.exit_zero {
74        return 0;
75    }
76    u8::from(diff.regressions(args.fail_on.into(), args.fail_on_warnings) > 0)
77}
78
79/// Read and parse a saved fleet report, mapping IO/JSON failures to a message.
80fn read_report(path: &PathBuf) -> Result<FleetReport, String> {
81    let text = fs::read_to_string(path)
82        .map_err(|e| format!("reading report `{}`: {e}", path.display()))?;
83    serde_json::from_str(&text).map_err(|e| format!("parsing report `{}`: {e}", path.display()))
84}