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())
        );
    }
}