fhir-diff 0.1.0

Semantic diff of FHIR packages and profiles with a breaking-change gate
Documentation
use std::path::PathBuf;
use std::process::ExitCode;

use clap::{Parser, ValueEnum};
use fhir_diff::{Filter, Pool, Severity, diff, load, report};

/// Semantic diff of two versions of a FHIR package: what changed in each profile and whether
/// it breaks existing data or implementations.
#[derive(Parser)]
#[command(name = "fhir-diff", version)]
struct Cli {
    /// The old package: `name@version` (from the package registry) or a folder.
    old: String,
    /// The new package.
    new: String,
    /// Compare only these profiles (canonical URL or name); repeatable.
    #[arg(long = "profile", value_name = "URL|NAME")]
    profiles: Vec<String>,
    /// Report format: `text` for a terminal or CI log, `markdown` for people, `json` for tools,
    /// `sarif` for code scanning.
    #[arg(long, value_enum, default_value_t = Format::Text)]
    format: Format,
    /// Write the report here instead of standard output.
    #[arg(long, short)]
    output: Option<PathBuf>,
    /// Report changes of at least this severity.
    #[arg(long, value_enum, default_value_t = Level::Info)]
    min_severity: Level,
    /// Exit with status 1 when a change of at least this severity is found.
    #[arg(long, value_enum, default_value_t = FailOn::Never)]
    fail_on: FailOn,
}

#[derive(Clone, Copy, ValueEnum)]
enum Format {
    Text,
    Markdown,
    Json,
    Sarif,
}

#[derive(Clone, Copy, ValueEnum)]
enum Level {
    Info,
    Warning,
    Breaking,
}

impl From<Level> for Severity {
    fn from(level: Level) -> Self {
        match level {
            Level::Info => Severity::Info,
            Level::Warning => Severity::Warning,
            Level::Breaking => Severity::Breaking,
        }
    }
}

#[derive(Clone, Copy, ValueEnum)]
enum FailOn {
    Never,
    Warning,
    Breaking,
}

fn main() -> ExitCode {
    let cli = Cli::parse();
    match run(&cli) {
        Ok(code) => code,
        Err(error) => {
            eprintln!("error: {error}");
            ExitCode::from(2)
        }
    }
}

fn run(cli: &Cli) -> Result<ExitCode, String> {
    let old = load(&cli.old)?;
    let new = load(&cli.new)?;
    let (old_pool, new_pool) = Pool::pair(&old, &new);
    let filter = Filter {
        profiles: cli.profiles.clone(),
    };
    let report = diff(
        &old.package.id(),
        &old_pool,
        &new.package.id(),
        &new_pool,
        &filter,
    );
    let min = Severity::from(cli.min_severity);
    let text = match cli.format {
        Format::Text => report::text(&report, min),
        Format::Markdown => report::markdown(&report, min),
        Format::Json => report::json(&report),
        Format::Sarif => report::sarif(&report, min),
    };
    match &cli.output {
        Some(path) => std::fs::write(path, text).map_err(|e| format!("{}: {e}", path.display()))?,
        None => println!("{text}"),
    }
    let threshold = match cli.fail_on {
        FailOn::Never => None,
        FailOn::Warning => Some(Severity::Warning),
        FailOn::Breaking => Some(Severity::Breaking),
    };
    Ok(match (threshold, report.worst()) {
        (Some(threshold), Some(worst)) if worst >= threshold => ExitCode::from(1),
        _ => ExitCode::SUCCESS,
    })
}