ironflow_engine/config/
approval_rule.rs1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::expression::Expression;
7
8#[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#[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
66fn 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 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 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 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 pub fn condition(&self) -> &Expression {
174 &self.condition
175 }
176
177 pub fn required_approvers(&self) -> usize {
187 self.required_approvers
188 }
189
190 pub fn approver_groups(&self) -> &[String] {
200 &self.approver_groups
201 }
202
203 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}