Skip to main content

ironflow_engine/config/
approvers.rs

1//! [`Approvers`] -- who must approve a gate, and how many of them.
2
3use serde::{Deserialize, Serialize};
4
5use ironflow_store::entities::ApprovalRequirement;
6
7/// The approvers an approval gate requires.
8///
9/// The workflow handler computes them in plain Rust, from its typed input and
10/// the outputs of earlier steps, and passes them to
11/// [`ApprovalConfig::requiring`](super::ApprovalConfig::requiring). The engine
12/// records them on the gate as an [`ApprovalRequirement`] when it opens; that
13/// record stays the source of truth on replay and resume.
14///
15/// Deserialization rejects zero approvers and a blank group name.
16///
17/// # Examples
18///
19/// ```
20/// use ironflow_engine::config::Approvers;
21///
22/// let amount = 15_000;
23/// let approvers = match amount {
24///     a if a > 100_000 => Approvers::at_least(3)
25///         .from_groups(["finance", "board"])
26///         .because("amount > 100k"),
27///     a if a > 10_000 => Approvers::at_least(2)
28///         .from_groups(["finance"])
29///         .because("amount > 10k"),
30///     _ => Approvers::any(),
31/// };
32///
33/// assert_eq!(approvers.required(), 2);
34/// assert_eq!(approvers.groups(), ["finance".to_string()]);
35/// assert_eq!(approvers.reason(), Some("amount > 10k"));
36/// ```
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(try_from = "RawApprovers")]
39pub struct Approvers {
40    required_approvers: u32,
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    approver_groups: Vec<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    reason: Option<String>,
45}
46
47/// Unvalidated wire form of [`Approvers`].
48#[derive(Deserialize)]
49struct RawApprovers {
50    required_approvers: u32,
51    #[serde(default)]
52    approver_groups: Vec<String>,
53    #[serde(default)]
54    reason: Option<String>,
55}
56
57impl TryFrom<RawApprovers> for Approvers {
58    type Error = String;
59
60    fn try_from(raw: RawApprovers) -> Result<Self, Self::Error> {
61        if raw.required_approvers == 0 {
62            return Err(ZERO_APPROVERS.to_string());
63        }
64        Ok(Self {
65            required_approvers: raw.required_approvers,
66            approver_groups: normalize_groups(raw.approver_groups)?,
67            reason: raw.reason,
68        })
69    }
70}
71
72const ZERO_APPROVERS: &str = "an approval gate needs at least one approver";
73const BLANK_GROUP: &str = "approver group must not be empty";
74
75/// Trim and deduplicate group names, keeping the first occurrence order.
76fn normalize_groups<I, S>(groups: I) -> Result<Vec<String>, String>
77where
78    I: IntoIterator<Item = S>,
79    S: Into<String>,
80{
81    let mut normalized: Vec<String> = Vec::new();
82    for group in groups {
83        let group = group.into().trim().to_string();
84        if group.is_empty() {
85            return Err(BLANK_GROUP.to_string());
86        }
87        if !normalized.contains(&group) {
88            normalized.push(group);
89        }
90    }
91    Ok(normalized)
92}
93
94impl Approvers {
95    /// One approval, from anyone allowed to answer the gate.
96    ///
97    /// # Examples
98    ///
99    /// ```
100    /// use ironflow_engine::config::Approvers;
101    ///
102    /// let approvers = Approvers::any();
103    /// assert_eq!(approvers.required(), 1);
104    /// assert!(approvers.groups().is_empty());
105    /// assert_eq!(approvers.reason(), None);
106    /// ```
107    pub fn any() -> Self {
108        Self {
109            required_approvers: 1,
110            approver_groups: Vec::new(),
111            reason: None,
112        }
113    }
114
115    /// At least `count` distinct approvals.
116    ///
117    /// # Panics
118    ///
119    /// Panics if `count` is zero: a gate nobody has to approve is not a gate.
120    ///
121    /// # Examples
122    ///
123    /// ```
124    /// use ironflow_engine::config::Approvers;
125    ///
126    /// assert_eq!(Approvers::at_least(3).required(), 3);
127    /// ```
128    ///
129    /// ```should_panic
130    /// use ironflow_engine::config::Approvers;
131    ///
132    /// let _ = Approvers::at_least(0);
133    /// ```
134    pub fn at_least(count: u32) -> Self {
135        assert!(count > 0, "{ZERO_APPROVERS}");
136        Self {
137            required_approvers: count,
138            ..Self::any()
139        }
140    }
141
142    /// Restrict voting to members of the given groups.
143    ///
144    /// Names are trimmed and deduplicated. Admins may always vote.
145    ///
146    /// # Panics
147    ///
148    /// Panics if a group name is empty or only whitespace.
149    ///
150    /// # Examples
151    ///
152    /// ```
153    /// use ironflow_engine::config::Approvers;
154    ///
155    /// let approvers = Approvers::at_least(2).from_groups([" finance ", "legal", "finance"]);
156    /// assert_eq!(approvers.groups(), ["finance".to_string(), "legal".to_string()]);
157    /// ```
158    pub fn from_groups<I, S>(mut self, groups: I) -> Self
159    where
160        I: IntoIterator<Item = S>,
161        S: Into<String>,
162    {
163        match normalize_groups(groups) {
164            Ok(groups) => self.approver_groups = groups,
165            Err(err) => panic!("{err}"),
166        }
167        self
168    }
169
170    /// Record why these approvers are required.
171    ///
172    /// The reason is an audit label shown on the dashboard and carried by the
173    /// approval events. It is never evaluated.
174    ///
175    /// # Examples
176    ///
177    /// ```
178    /// use ironflow_engine::config::Approvers;
179    ///
180    /// let approvers = Approvers::at_least(2).because("amount > 10k");
181    /// assert_eq!(approvers.reason(), Some("amount > 10k"));
182    /// ```
183    pub fn because(mut self, reason: &str) -> Self {
184        self.reason = Some(reason.to_string());
185        self
186    }
187
188    /// Number of distinct approvals the gate needs.
189    ///
190    /// # Examples
191    ///
192    /// ```
193    /// use ironflow_engine::config::Approvers;
194    ///
195    /// assert_eq!(Approvers::any().required(), 1);
196    /// ```
197    pub fn required(&self) -> u32 {
198        self.required_approvers
199    }
200
201    /// Groups whose members may vote. Empty means no group restriction.
202    ///
203    /// # Examples
204    ///
205    /// ```
206    /// use ironflow_engine::config::Approvers;
207    ///
208    /// assert!(Approvers::at_least(2).groups().is_empty());
209    /// ```
210    pub fn groups(&self) -> &[String] {
211        &self.approver_groups
212    }
213
214    /// Why these approvers are required, if the handler said so.
215    ///
216    /// # Examples
217    ///
218    /// ```
219    /// use ironflow_engine::config::Approvers;
220    ///
221    /// assert_eq!(Approvers::any().because("routine").reason(), Some("routine"));
222    /// ```
223    pub fn reason(&self) -> Option<&str> {
224        self.reason.as_deref()
225    }
226
227    /// The requirement the engine records on the gate.
228    ///
229    /// # Examples
230    ///
231    /// ```
232    /// use ironflow_engine::config::{ApprovalRequirement, Approvers};
233    ///
234    /// let requirement = Approvers::at_least(2).from_groups(["finance"]).to_requirement();
235    /// assert_eq!(requirement.required_approvers, 2);
236    /// assert_eq!(requirement.approver_groups, vec!["finance".to_string()]);
237    /// assert_eq!(Approvers::any().to_requirement(), ApprovalRequirement::default());
238    /// ```
239    pub fn to_requirement(&self) -> ApprovalRequirement {
240        ApprovalRequirement {
241            reason: self.reason.clone(),
242            required_approvers: self.required_approvers,
243            approver_groups: self.approver_groups.clone(),
244        }
245    }
246}
247
248impl Default for Approvers {
249    /// Same as [`Approvers::any`].
250    fn default() -> Self {
251        Self::any()
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use serde_json::{from_value, json, to_value};
258
259    use super::*;
260
261    #[test]
262    fn any_requires_one_approval_from_anyone() {
263        let approvers = Approvers::any();
264        assert_eq!(approvers.required(), 1);
265        assert!(approvers.groups().is_empty());
266        assert_eq!(approvers.reason(), None);
267        assert_eq!(approvers, Approvers::default());
268    }
269
270    #[test]
271    fn at_least_sets_the_count() {
272        assert_eq!(Approvers::at_least(4).required(), 4);
273    }
274
275    #[test]
276    #[should_panic(expected = "an approval gate needs at least one approver")]
277    fn at_least_rejects_zero() {
278        let _ = Approvers::at_least(0);
279    }
280
281    #[test]
282    fn from_groups_trims_and_deduplicates() {
283        let approvers =
284            Approvers::at_least(1).from_groups(vec![" sre ", "finance", "sre", "finance "]);
285        assert_eq!(
286            approvers.groups(),
287            ["sre".to_string(), "finance".to_string()]
288        );
289    }
290
291    #[test]
292    fn from_groups_keeps_unicode_names() {
293        let approvers = Approvers::any().from_groups(["équipe-sécurité"]);
294        assert_eq!(approvers.groups(), ["équipe-sécurité".to_string()]);
295    }
296
297    #[test]
298    #[should_panic(expected = "approver group must not be empty")]
299    fn from_groups_rejects_a_blank_group() {
300        let _ = Approvers::at_least(1).from_groups(["finance", "  "]);
301    }
302
303    #[test]
304    fn because_sets_the_reason() {
305        let approvers = Approvers::at_least(2).because("amount > 10k");
306        assert_eq!(approvers.reason(), Some("amount > 10k"));
307    }
308
309    #[test]
310    fn to_requirement_copies_every_field() {
311        let requirement = Approvers::at_least(3)
312            .from_groups(["finance", "board"])
313            .because("amount > 100k")
314            .to_requirement();
315        assert_eq!(
316            requirement,
317            ApprovalRequirement {
318                reason: Some("amount > 100k".to_string()),
319                required_approvers: 3,
320                approver_groups: vec!["finance".to_string(), "board".to_string()],
321            }
322        );
323    }
324
325    #[test]
326    fn serde_roundtrip() {
327        let approvers = Approvers::at_least(2)
328            .from_groups(["finance"])
329            .because("amount > 10k");
330        let json = to_value(&approvers).expect("serialize");
331        assert_eq!(
332            json,
333            json!({
334                "required_approvers": 2,
335                "approver_groups": ["finance"],
336                "reason": "amount > 10k",
337            })
338        );
339        let back: Approvers = from_value(json).expect("deserialize");
340        assert_eq!(back, approvers);
341    }
342
343    #[test]
344    fn serde_omits_empty_groups_and_reason() {
345        let json = to_value(Approvers::any()).expect("serialize");
346        assert_eq!(json, json!({"required_approvers": 1}));
347    }
348
349    #[test]
350    fn serde_rejects_zero_approvers() {
351        let err =
352            from_value::<Approvers>(json!({"required_approvers": 0})).expect_err("zero approvers");
353        assert!(err.to_string().contains("at least one approver"));
354    }
355
356    #[test]
357    fn serde_rejects_a_blank_group() {
358        let err = from_value::<Approvers>(json!({
359            "required_approvers": 1,
360            "approver_groups": [" "],
361        }))
362        .expect_err("blank group");
363        assert!(err.to_string().contains("must not be empty"));
364    }
365}