github_actions_models/common/expr.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140
//! GitHub Actions expression parsing and handling.
use serde::{Deserialize, Serialize};
/// An explicit GitHub Actions expression, fenced by `${{ <expr> }}`.
#[derive(Debug, PartialEq, Serialize)]
pub struct ExplicitExpr(String);
impl ExplicitExpr {
/// Construct an `ExplicitExpr` from the given string, consuming it
/// in the process.
///
/// Returns `None` if the input is not a valid explicit expression.
pub fn from_curly(expr: impl Into<String>) -> Option<Self> {
// Invariant preservation: we store the full string, but
// we expect it to be a well-formed expression.
let expr = expr.into();
let trimmed = expr.trim();
if !trimmed.starts_with("${{") || !trimmed.ends_with("}}") {
return None;
}
Some(ExplicitExpr(expr))
}
/// Return the original string underlying this expression, including
/// its exact whitespace and curly delimiters.
pub fn as_raw(&self) -> &str {
&self.0
}
/// Return the "curly" form of this expression, with leading and trailing
/// whitespace removed.
///
/// Whitespace *within* the expression body is not removed or normalized.
pub fn as_curly(&self) -> &str {
self.as_raw().trim()
}
/// Return the "bare" form of this expression, i.e. the `body` within
/// `${{ body }}`. Leading and trailing whitespace within
/// the expression body is removed.
pub fn as_bare(&self) -> &str {
self.as_curly()
.strip_prefix("${{")
.and_then(|e| e.strip_suffix("}}"))
.map(|e| e.trim())
.expect("invariant violated: ExplicitExpr must be an expression")
}
}
impl<'de> Deserialize<'de> for ExplicitExpr {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
let Some(expr) = Self::from_curly(raw) else {
return Err(serde::de::Error::custom(
"invalid expression: expected '${{' and '}}' delimiters",
));
};
Ok(expr)
}
}
/// A "literal or expr" type, for places in GitHub Actions where a
/// key can either have a literal value (array, object, etc.) or an
/// expression string.
#[derive(Debug, Deserialize, PartialEq, Serialize)]
#[serde(untagged)]
pub enum LoE<T> {
// Observe that `Expr` comes first, since `LoE<String>` should always
// attempt to parse as an expression before falling back on a literal
// string.
Expr(ExplicitExpr),
Literal(T),
}
impl<T> Default for LoE<T>
where
T: Default,
{
fn default() -> Self {
Self::Literal(T::default())
}
}
/// A convenience alias for a `bool` literal or an actions expression.
pub type BoE = LoE<bool>;
#[cfg(test)]
mod tests {
use super::{ExplicitExpr, LoE};
#[test]
fn test_expr_invalid() {
let cases = &[
"not an expression",
"${{ missing end ",
"missing beginning }}",
];
for case in cases {
let case = format!("\"{case}\"");
assert!(serde_yaml::from_str::<ExplicitExpr>(&case).is_err());
}
}
#[test]
fn test_expr() {
let expr = "\" ${{ foo }} \\t \"";
let expr: ExplicitExpr = serde_yaml::from_str(expr).unwrap();
assert_eq!(expr.as_bare(), "foo");
}
#[test]
fn test_loe() {
let lit = "\"normal string\"";
assert_eq!(
serde_yaml::from_str::<LoE<String>>(lit).unwrap(),
LoE::Literal("normal string".to_string())
);
let expr = "\"${{ expr }}\"";
assert!(matches!(
serde_yaml::from_str::<LoE<String>>(expr).unwrap(),
LoE::Expr(_)
));
// Invalid expr deserializes as string.
let invalid = "\"${{ invalid \"";
assert_eq!(
serde_yaml::from_str::<LoE<String>>(invalid).unwrap(),
LoE::Literal("${{ invalid ".to_string())
);
}
}