gha-expression-proof 1.0.0

GitHub Actions expression evaluator and receipt generator for offline CI compatibility testing
Documentation
use serde_json::{Number, Value};
use std::cmp::Ordering;

#[derive(Debug, Clone, PartialEq)]
pub struct GhaValue {
    pub json: Value,
    pub origin: Option<String>,
}

impl GhaValue {
    pub fn new(json: Value) -> Self {
        Self { json, origin: None }
    }

    pub fn with_origin(json: Value, origin: impl Into<String>) -> Self {
        Self {
            json,
            origin: Some(origin.into()),
        }
    }

    pub fn missing() -> Self {
        Self::new(Value::String(String::new()))
    }

    pub fn truthy(&self) -> bool {
        match &self.json {
            Value::Null => false,
            Value::Bool(value) => *value,
            Value::Number(number) => number.as_f64().is_some_and(|value| value != 0.0),
            Value::String(value) => !value.is_empty(),
            Value::Array(_) | Value::Object(_) => true,
        }
    }
}

fn string_coerce(value: &Value) -> Option<String> {
    match value {
        Value::Null => Some(String::new()),
        Value::Bool(value) => Some(value.to_string()),
        Value::Number(value) => Some(value.to_string()),
        Value::String(value) => Some(value.clone()),
        Value::Array(_) | Value::Object(_) => None,
    }
}

pub fn string_for_render(value: &Value) -> String {
    string_coerce(value).unwrap_or_else(|| serde_json::to_string(value).unwrap_or_default())
}

pub fn loose_equal(left: &GhaValue, right: &GhaValue) -> bool {
    if same_scalar_type(&left.json, &right.json) {
        return match (&left.json, &right.json) {
            (Value::String(left), Value::String(right)) => left.eq_ignore_ascii_case(right),
            (Value::Number(left), Value::Number(right)) => left.as_f64() == right.as_f64(),
            _ => left.json == right.json,
        };
    }

    if matches!(left.json, Value::Array(_) | Value::Object(_))
        || matches!(right.json, Value::Array(_) | Value::Object(_))
    {
        return left.origin.is_some() && left.origin == right.origin;
    }

    let left = number_coerce(&left.json);
    let right = number_coerce(&right.json);
    matches!((left, right), (Some(left), Some(right)) if left == right)
}

pub fn loose_compare(left: &GhaValue, right: &GhaValue) -> Option<Ordering> {
    let left = number_coerce(&left.json)?;
    let right = number_coerce(&right.json)?;
    left.partial_cmp(&right)
}

pub fn number_coerce(value: &Value) -> Option<f64> {
    match value {
        Value::Null => Some(0.0),
        Value::Bool(true) => Some(1.0),
        Value::Bool(false) => Some(0.0),
        Value::Number(value) => value.as_f64(),
        Value::String(value) => {
            if value.is_empty() {
                Some(0.0)
            } else {
                serde_json::from_str::<Number>(value)
                    .ok()
                    .and_then(|number| number.as_f64())
            }
        }
        Value::Array(_) | Value::Object(_) => None,
    }
}

fn same_scalar_type(left: &Value, right: &Value) -> bool {
    matches!(
        (left, right),
        (Value::Null, Value::Null)
            | (Value::Bool(_), Value::Bool(_))
            | (Value::Number(_), Value::Number(_))
            | (Value::String(_), Value::String(_))
    )
}