Skip to main content

ironflow_engine/config/
approval_rule.rs

1//! [`ApprovalRule`] -- one row of the dynamic approval matrix.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::expression::Expression;
7
8/// A conditional approval requirement.
9///
10/// When an approval gate opens, the rules of its
11/// [`ApprovalConfig`](super::ApprovalConfig) are evaluated in order against the
12/// run context; the first rule whose [`condition`](Self::condition) holds
13/// decides how many distinct approvals the gate needs and, optionally, which
14/// groups the approvers must belong to. See [`Expression`] for the condition
15/// syntax.
16///
17/// Deserialization rejects an invalid condition and `required_approvers == 0`.
18///
19/// # Examples
20///
21/// ```
22/// use ironflow_engine::config::ApprovalRule;
23/// use serde_json::json;
24///
25/// let rule = ApprovalRule::new("payload.amount > 10000", 2).with_approver_groups(["finance"]);
26/// assert_eq!(rule.required_approvers(), 2);
27/// assert_eq!(rule.approver_groups(), ["finance".to_string()]);
28/// assert!(rule.matches(&json!({"payload": {"amount": 15000}})));
29/// ```
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31#[serde(try_from = "RawApprovalRule")]
32pub struct ApprovalRule {
33    condition: Expression,
34    required_approvers: usize,
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    approver_groups: Vec<String>,
37}
38
39/// Unvalidated wire form of an [`ApprovalRule`].
40#[derive(Deserialize)]
41struct RawApprovalRule {
42    condition: Expression,
43    required_approvers: usize,
44    #[serde(default)]
45    approver_groups: Vec<String>,
46}
47
48impl TryFrom<RawApprovalRule> for ApprovalRule {
49    type Error = String;
50
51    fn try_from(raw: RawApprovalRule) -> Result<Self, Self::Error> {
52        if raw.required_approvers == 0 {
53            return Err(ZERO_APPROVERS.to_string());
54        }
55        Ok(Self {
56            condition: raw.condition,
57            required_approvers: raw.required_approvers,
58            approver_groups: normalize_groups(raw.approver_groups)?,
59        })
60    }
61}
62
63const ZERO_APPROVERS: &str = "required_approvers must be greater than zero";
64const BLANK_GROUP: &str = "approver group must not be empty";
65
66/// Trim and deduplicate group names, keeping the first occurrence order.
67fn normalize_groups<I, S>(groups: I) -> Result<Vec<String>, String>
68where
69    I: IntoIterator<Item = S>,
70    S: Into<String>,
71{
72    let mut normalized: Vec<String> = Vec::new();
73    for group in groups {
74        let group = group.into().trim().to_string();
75        if group.is_empty() {
76            return Err(BLANK_GROUP.to_string());
77        }
78        if !normalized.contains(&group) {
79            normalized.push(group);
80        }
81    }
82    Ok(normalized)
83}
84
85impl ApprovalRule {
86    /// Create a rule from a condition source and a number of approvers.
87    ///
88    /// # Panics
89    ///
90    /// Panics if `condition` is not a valid [`Expression`] or if
91    /// `required_approvers` is zero.
92    ///
93    /// # Examples
94    ///
95    /// ```
96    /// use ironflow_engine::config::ApprovalRule;
97    ///
98    /// let rule = ApprovalRule::new("labels.env == 'production'", 2);
99    /// assert_eq!(rule.condition().source(), "labels.env == 'production'");
100    /// ```
101    pub fn new(condition: &str, required_approvers: usize) -> Self {
102        let condition = Expression::parse(condition)
103            .unwrap_or_else(|err| panic!("invalid approval rule condition: {err}"));
104        Self::from_expression(condition, required_approvers)
105    }
106
107    /// Create a rule from an already parsed [`Expression`].
108    ///
109    /// # Panics
110    ///
111    /// Panics if `required_approvers` is zero.
112    ///
113    /// # Examples
114    ///
115    /// ```
116    /// use ironflow_engine::config::ApprovalRule;
117    /// use ironflow_engine::expression::Expression;
118    ///
119    /// # fn main() -> Result<(), ironflow_engine::expression::ExpressionError> {
120    /// let rule = ApprovalRule::from_expression(Expression::parse("payload.urgent")?, 3);
121    /// assert_eq!(rule.required_approvers(), 3);
122    /// # Ok(())
123    /// # }
124    /// ```
125    pub fn from_expression(condition: Expression, required_approvers: usize) -> Self {
126        assert!(required_approvers > 0, "{ZERO_APPROVERS}");
127        Self {
128            condition,
129            required_approvers,
130            approver_groups: Vec::new(),
131        }
132    }
133
134    /// Restrict voting to members of the given groups.
135    ///
136    /// Names are trimmed and deduplicated. Admins may always vote.
137    ///
138    /// # Panics
139    ///
140    /// Panics if a group name is empty or only whitespace.
141    ///
142    /// # Examples
143    ///
144    /// ```
145    /// use ironflow_engine::config::ApprovalRule;
146    ///
147    /// let rule = ApprovalRule::new("payload.amount > 10000", 2)
148    ///     .with_approver_groups([" finance ", "legal", "finance"]);
149    /// assert_eq!(rule.approver_groups(), ["finance".to_string(), "legal".to_string()]);
150    /// ```
151    pub fn with_approver_groups<I, S>(mut self, groups: I) -> Self
152    where
153        I: IntoIterator<Item = S>,
154        S: Into<String>,
155    {
156        match normalize_groups(groups) {
157            Ok(groups) => self.approver_groups = groups,
158            Err(err) => panic!("{err}"),
159        }
160        self
161    }
162
163    /// The condition deciding whether this rule applies.
164    ///
165    /// # Examples
166    ///
167    /// ```
168    /// use ironflow_engine::config::ApprovalRule;
169    ///
170    /// let rule = ApprovalRule::new("payload.urgent", 1);
171    /// assert_eq!(rule.condition().to_string(), "payload.urgent");
172    /// ```
173    pub fn condition(&self) -> &Expression {
174        &self.condition
175    }
176
177    /// Number of distinct approvals the gate needs when this rule applies.
178    ///
179    /// # Examples
180    ///
181    /// ```
182    /// use ironflow_engine::config::ApprovalRule;
183    ///
184    /// assert_eq!(ApprovalRule::new("payload.urgent", 3).required_approvers(), 3);
185    /// ```
186    pub fn required_approvers(&self) -> usize {
187        self.required_approvers
188    }
189
190    /// Groups whose members may vote. Empty means no group restriction.
191    ///
192    /// # Examples
193    ///
194    /// ```
195    /// use ironflow_engine::config::ApprovalRule;
196    ///
197    /// assert!(ApprovalRule::new("payload.urgent", 1).approver_groups().is_empty());
198    /// ```
199    pub fn approver_groups(&self) -> &[String] {
200        &self.approver_groups
201    }
202
203    /// Whether the condition holds for the given context.
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use ironflow_engine::config::ApprovalRule;
209    /// use serde_json::json;
210    ///
211    /// let rule = ApprovalRule::new("labels.env == 'production'", 2);
212    /// assert!(rule.matches(&json!({"labels": {"env": "production"}})));
213    /// assert!(!rule.matches(&json!({"labels": {}})));
214    /// ```
215    pub fn matches(&self, ctx: &Value) -> bool {
216        self.condition.evaluate(ctx)
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use serde_json::{from_value, json, to_value};
223
224    use super::*;
225
226    #[test]
227    fn new_parses_the_condition() {
228        let rule = ApprovalRule::new("payload.amount > 10000", 2);
229        assert_eq!(rule.condition().source(), "payload.amount > 10000");
230        assert_eq!(rule.required_approvers(), 2);
231        assert!(rule.approver_groups().is_empty());
232    }
233
234    #[test]
235    fn from_expression_keeps_the_expression() {
236        let expr = Expression::parse("labels.env == 'prod'").expect("parse");
237        let rule = ApprovalRule::from_expression(expr.clone(), 1);
238        assert_eq!(rule.condition(), &expr);
239    }
240
241    #[test]
242    fn matches_evaluates_the_condition() {
243        let rule = ApprovalRule::new("payload.amount > 10000", 2);
244        assert!(rule.matches(&json!({"payload": {"amount": 15000}})));
245        assert!(!rule.matches(&json!({"payload": {"amount": 10}})));
246        assert!(!rule.matches(&json!({})));
247    }
248
249    #[test]
250    fn approver_groups_are_trimmed_and_deduplicated() {
251        let rule = ApprovalRule::new("payload.urgent", 1)
252            .with_approver_groups(vec![" sre ", "finance", "sre", "finance "]);
253        assert_eq!(
254            rule.approver_groups(),
255            ["sre".to_string(), "finance".to_string()]
256        );
257    }
258
259    #[test]
260    #[should_panic(expected = "invalid approval rule condition")]
261    fn new_rejects_an_invalid_condition() {
262        let _ = ApprovalRule::new("foo.bar == 1", 1);
263    }
264
265    #[test]
266    #[should_panic(expected = "required_approvers must be greater than zero")]
267    fn new_rejects_zero_approvers() {
268        let _ = ApprovalRule::new("payload.urgent", 0);
269    }
270
271    #[test]
272    #[should_panic(expected = "approver group must not be empty")]
273    fn with_approver_groups_rejects_a_blank_group() {
274        let _ = ApprovalRule::new("payload.urgent", 1).with_approver_groups(["finance", "  "]);
275    }
276
277    #[test]
278    fn serde_roundtrip() {
279        let rule = ApprovalRule::new("payload.amount > 10000", 2).with_approver_groups(["finance"]);
280        let json = to_value(&rule).expect("serialize");
281        assert_eq!(
282            json,
283            json!({
284                "condition": "payload.amount > 10000",
285                "required_approvers": 2,
286                "approver_groups": ["finance"],
287            })
288        );
289        let back: ApprovalRule = from_value(json).expect("deserialize");
290        assert_eq!(back, rule);
291    }
292
293    #[test]
294    fn serde_omits_empty_groups() {
295        let rule = ApprovalRule::new("payload.urgent", 1);
296        let json = to_value(&rule).expect("serialize");
297        assert!(json.get("approver_groups").is_none());
298        let back: ApprovalRule = from_value(json).expect("deserialize");
299        assert_eq!(back, rule);
300    }
301
302    #[test]
303    fn serde_rejects_zero_approvers() {
304        let err = from_value::<ApprovalRule>(json!({
305            "condition": "payload.urgent",
306            "required_approvers": 0,
307        }))
308        .expect_err("zero approvers");
309        assert!(err.to_string().contains("greater than zero"));
310    }
311
312    #[test]
313    fn serde_rejects_an_invalid_condition() {
314        let err = from_value::<ApprovalRule>(json!({
315            "condition": "foo.bar",
316            "required_approvers": 1,
317        }))
318        .expect_err("invalid condition");
319        assert!(err.to_string().contains("unknown root"));
320    }
321
322    #[test]
323    fn serde_rejects_a_blank_group() {
324        let err = from_value::<ApprovalRule>(json!({
325            "condition": "payload.urgent",
326            "required_approvers": 1,
327            "approver_groups": [" "],
328        }))
329        .expect_err("blank group");
330        assert!(err.to_string().contains("must not be empty"));
331    }
332}