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,
}
}
}