github_actions_models/common/
expr.rs

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