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