gha-expression-proof 1.0.0

GitHub Actions expression evaluator and receipt generator for offline CI compatibility testing
Documentation
use anyhow::{Context, Result, bail, ensure};
use camino::Utf8PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
use gha_expression_proof::{
    ContextBuilder, EvaluationOptions, JobStatus, OutputFormat, evaluate_expression,
    evaluate_template, render_receipt,
};
use serde_json::Value;
use std::fs;
use std::process::ExitCode;

#[derive(Debug, Parser)]
#[command(
    version,
    about = "Evaluate GitHub Actions expressions with offline receipts"
)]
struct Cli {
    #[command(subcommand)]
    command: Command,

    #[arg(long, global = true, value_enum, default_value = "text")]
    format: OutputFormat,

    #[arg(long, global = true, value_name = "PATH")]
    output: Option<Utf8PathBuf>,

    #[arg(long, global = true)]
    strict: bool,
}

#[derive(Debug, Subcommand)]
enum Command {
    Eval(EvalArgs),
    Template(TemplateArgs),
}

#[derive(Debug, Args)]
struct EvalArgs {
    #[arg(long, value_name = "EXPR")]
    expr: String,

    #[command(flatten)]
    context: ContextArgs,

    #[arg(long)]
    if_condition: bool,

    #[arg(long, value_enum, default_value = "success")]
    job_status: JobStatusArg,
}

#[derive(Debug, Args)]
struct TemplateArgs {
    #[arg(long, value_name = "TEXT", conflicts_with = "template_file")]
    template: Option<String>,

    #[arg(long, value_name = "PATH", conflicts_with = "template")]
    template_file: Option<Utf8PathBuf>,

    #[command(flatten)]
    context: ContextArgs,

    #[arg(long, value_enum, default_value = "success")]
    job_status: JobStatusArg,
}

#[derive(Debug, Args)]
struct ContextArgs {
    #[arg(long, value_name = "PATH")]
    context: Option<Utf8PathBuf>,

    #[arg(long, value_name = "PATH")]
    github_context: Option<Utf8PathBuf>,

    #[arg(long, value_name = "PATH")]
    event: Option<Utf8PathBuf>,

    #[arg(long = "context-file", value_name = "NAME=PATH")]
    context_files: Vec<String>,

    #[arg(long = "context-json", value_name = "NAME=JSON")]
    context_json: Vec<String>,

    #[arg(long, value_name = "DIR")]
    workspace: Option<Utf8PathBuf>,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
enum JobStatusArg {
    Success,
    Failure,
    Cancelled,
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("error: {error:#}");
            ExitCode::FAILURE
        }
    }
}

fn run() -> Result<()> {
    let cli = Cli::parse();
    let receipt = match &cli.command {
        Command::Eval(args) => {
            let options = options(&args.context, args.job_status.into(), args.if_condition)?;
            evaluate_expression(&args.expr, &options)
        }
        Command::Template(args) => {
            let options = options(&args.context, args.job_status.into(), false)?;
            let template = match (&args.template, &args.template_file) {
                (Some(template), None) => template.clone(),
                (None, Some(path)) => {
                    fs::read_to_string(path).with_context(|| format!("reading {path}"))?
                }
                _ => bail!("provide exactly one of --template or --template-file"),
            };
            evaluate_template(&template, &options)
        }
    };

    let rendered = render_receipt(&receipt, cli.format)?;
    if let Some(output) = cli.output {
        if let Some(parent) = output.parent() {
            fs::create_dir_all(parent).with_context(|| format!("creating {parent}"))?;
        }
        fs::write(&output, rendered).with_context(|| format!("writing {output}"))?;
    } else {
        print!("{rendered}");
    }

    if receipt.summary.failed > 0 || (cli.strict && receipt.summary.warnings > 0) {
        bail!("expression proof failed");
    }

    Ok(())
}

fn options(
    args: &ContextArgs,
    job_status: JobStatus,
    if_condition: bool,
) -> Result<EvaluationOptions> {
    let mut builder = ContextBuilder::new();

    if let Some(path) = &args.context {
        builder.insert_root_object(read_json(path)?)?;
    }
    if let Some(path) = &args.github_context {
        builder.insert_eventsmith_github_context(read_json(path)?)?;
    }
    if let Some(path) = &args.event {
        builder.insert_github_event(read_json(path)?);
    }
    for pair in &args.context_files {
        let (name, path) = split_pair(pair)?;
        builder.insert_context_value(&name, read_json(&Utf8PathBuf::from(path))?)?;
    }
    for pair in &args.context_json {
        let (name, raw) = split_pair(pair)?;
        builder.insert_context_value(
            &name,
            serde_json::from_str(&raw).with_context(|| format!("parsing JSON for {name}"))?,
        )?;
    }

    if let Some(workspace) = &args.workspace {
        ensure!(
            workspace.is_dir(),
            "--workspace must be an existing directory"
        );
    }

    Ok(EvaluationOptions {
        context: builder.build(),
        workspace: args.workspace.clone(),
        if_condition,
        job_status,
    })
}

fn read_json(path: &Utf8PathBuf) -> Result<Value> {
    serde_json::from_slice(&fs::read(path).with_context(|| format!("reading {path}"))?)
        .with_context(|| format!("parsing {path}"))
}

fn split_pair(raw: &str) -> Result<(String, String)> {
    let Some((key, value)) = raw.split_once('=') else {
        bail!("expected NAME=VALUE, got {raw}");
    };
    ensure!(!key.trim().is_empty(), "context name cannot be empty");
    Ok((key.trim().to_owned(), value.to_owned()))
}

impl From<JobStatusArg> for JobStatus {
    fn from(value: JobStatusArg) -> Self {
        match value {
            JobStatusArg::Success => Self::Success,
            JobStatusArg::Failure => Self::Failure,
            JobStatusArg::Cancelled => Self::Cancelled,
        }
    }
}