ironflow_engine/config/
approvers.rs1use serde::{Deserialize, Serialize};
4
5use ironflow_store::entities::ApprovalRequirement;
6
7#[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#[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
75fn 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 pub fn any() -> Self {
108 Self {
109 required_approvers: 1,
110 approver_groups: Vec::new(),
111 reason: None,
112 }
113 }
114
115 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 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 pub fn because(mut self, reason: &str) -> Self {
184 self.reason = Some(reason.to_string());
185 self
186 }
187
188 pub fn required(&self) -> u32 {
198 self.required_approvers
199 }
200
201 pub fn groups(&self) -> &[String] {
211 &self.approver_groups
212 }
213
214 pub fn reason(&self) -> Option<&str> {
224 self.reason.as_deref()
225 }
226
227 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 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}