1use std::time::Duration;
4
5use ironflow_store::entities::{ApprovalRequirement, ApprovalRuleEvaluation, Assignee};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use super::{ApprovalRule, EscalationPolicy};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ApprovalConfig {
44 message: String,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 timeout_seconds: Option<u64>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 deadline_secs: Option<u64>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 on_timeout: Option<EscalationPolicy>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 assignee: Option<Assignee>,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 rules: Vec<ApprovalRule>,
55}
56
57impl ApprovalConfig {
58 pub fn new(message: &str) -> Self {
69 Self {
70 message: message.to_string(),
71 timeout_seconds: None,
72 deadline_secs: None,
73 on_timeout: None,
74 assignee: None,
75 rules: Vec::new(),
76 }
77 }
78
79 pub fn with_timeout_seconds(mut self, seconds: u64) -> Self {
101 self.timeout_seconds = Some(seconds);
102 self
103 }
104
105 pub fn with_deadline(self, deadline: Duration) -> Self {
124 self.with_deadline_secs(deadline.as_secs())
125 }
126
127 pub fn with_deadline_secs(mut self, secs: u64) -> Self {
143 assert!(secs > 0, "approval deadline must be greater than zero");
144 self.deadline_secs = Some(secs);
145 self
146 }
147
148 pub fn on_timeout(mut self, policy: EscalationPolicy) -> Self {
161 self.on_timeout = Some(policy);
162 self
163 }
164
165 pub fn assigned_to(mut self, assignee: Assignee) -> Self {
181 assert!(
182 !assignee.name().trim().is_empty(),
183 "approval assignee must not be empty"
184 );
185 self.assignee = Some(assignee);
186 self
187 }
188
189 pub fn with_rule(mut self, rule: ApprovalRule) -> Self {
203 self.rules.push(rule);
204 self
205 }
206
207 pub fn rules(&self) -> &[ApprovalRule] {
217 &self.rules
218 }
219
220 pub fn evaluate_rules(&self, ctx: &Value) -> ApprovalRequirement {
247 let mut evaluated = Vec::new();
248 for (index, rule) in self.rules.iter().enumerate() {
249 let index = u32::try_from(index).unwrap_or(u32::MAX);
250 let matched = rule.matches(ctx);
251 evaluated.push(ApprovalRuleEvaluation {
252 index,
253 condition: rule.condition().source().to_string(),
254 matched,
255 });
256 if matched {
257 let required = u32::try_from(rule.required_approvers()).unwrap_or(u32::MAX);
258 return ApprovalRequirement {
259 rule_index: Some(index),
260 condition: Some(rule.condition().source().to_string()),
261 required_approvers: required,
262 approver_groups: rule.approver_groups().to_vec(),
263 evaluated,
264 };
265 }
266 }
267 ApprovalRequirement {
268 evaluated,
269 ..ApprovalRequirement::default()
270 }
271 }
272
273 pub fn message(&self) -> &str {
275 &self.message
276 }
277
278 pub fn timeout_seconds(&self) -> Option<u64> {
280 self.timeout_seconds
281 }
282
283 pub fn deadline(&self) -> Option<Duration> {
295 self.deadline_secs.map(Duration::from_secs)
296 }
297
298 pub fn deadline_secs(&self) -> Option<u64> {
300 self.deadline_secs
301 }
302
303 pub fn on_timeout_policy(&self) -> Option<&EscalationPolicy> {
305 self.on_timeout.as_ref()
306 }
307
308 pub fn assignee(&self) -> Option<&Assignee> {
310 self.assignee.as_ref()
311 }
312
313 pub fn effective_deadline_secs(&self) -> Option<u64> {
334 self.deadline_secs.or(self.timeout_seconds)
335 }
336
337 pub fn effective_policy(&self) -> EscalationPolicy {
350 self.on_timeout
351 .clone()
352 .unwrap_or(EscalationPolicy::AutoReject)
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use serde_json::{from_str, from_value, json, to_string};
359
360 use super::*;
361 use crate::config::NotificationTarget;
362
363 #[test]
364 fn new_sets_message() {
365 let config = ApprovalConfig::new("Deploy?");
366 assert_eq!(config.message(), "Deploy?");
367 assert!(config.timeout_seconds().is_none());
368 assert!(config.deadline_secs().is_none());
369 assert!(config.on_timeout_policy().is_none());
370 assert!(config.assignee().is_none());
371 }
372
373 #[test]
374 fn with_timeout() {
375 let config = ApprovalConfig::new("Approve?").with_timeout_seconds(7200);
376 assert_eq!(config.timeout_seconds(), Some(7200));
377 }
378
379 #[test]
380 fn with_deadline_stores_whole_seconds() {
381 let config = ApprovalConfig::new("Approve?").with_deadline(Duration::from_millis(90_500));
382 assert_eq!(config.deadline_secs(), Some(90));
383 assert_eq!(config.deadline(), Some(Duration::from_secs(90)));
384 }
385
386 #[test]
387 fn on_timeout_stores_the_policy() {
388 let config = ApprovalConfig::new("Approve?").on_timeout(EscalationPolicy::AutoApprove);
389 assert_eq!(
390 config.on_timeout_policy(),
391 Some(&EscalationPolicy::AutoApprove)
392 );
393 }
394
395 #[test]
396 fn assigned_to_stores_the_assignee() {
397 let config =
398 ApprovalConfig::new("Approve?").assigned_to(Assignee::group("release-managers"));
399 assert_eq!(
400 config.assignee(),
401 Some(&Assignee::group("release-managers"))
402 );
403 }
404
405 #[test]
406 fn effective_deadline_prefers_the_explicit_deadline() {
407 let config = ApprovalConfig::new("Approve?")
408 .with_timeout_seconds(7200)
409 .with_deadline_secs(60);
410 assert_eq!(config.effective_deadline_secs(), Some(60));
411 }
412
413 #[test]
414 fn effective_deadline_falls_back_to_the_legacy_timeout() {
415 let config = ApprovalConfig::new("Approve?").with_timeout_seconds(7200);
416 assert_eq!(config.effective_deadline_secs(), Some(7200));
417 }
418
419 #[test]
420 fn effective_deadline_is_none_without_any_timer() {
421 assert_eq!(
422 ApprovalConfig::new("Approve?").effective_deadline_secs(),
423 None
424 );
425 }
426
427 #[test]
428 fn effective_policy_defaults_to_auto_reject() {
429 let config = ApprovalConfig::new("Approve?").with_deadline_secs(60);
430 assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
431 }
432
433 #[test]
434 fn effective_policy_returns_the_configured_policy() {
435 let policy = EscalationPolicy::Chain(vec![
436 EscalationPolicy::Notify(vec![NotificationTarget::Webhook {
437 url: "https://example.com/sla".to_string(),
438 }]),
439 EscalationPolicy::AutoReject,
440 ]);
441 let config = ApprovalConfig::new("Approve?")
442 .with_deadline_secs(60)
443 .on_timeout(policy.clone());
444 assert_eq!(config.effective_policy(), policy);
445 }
446
447 #[test]
448 #[should_panic(expected = "approval deadline must be greater than zero")]
449 fn with_deadline_secs_rejects_zero() {
450 let _ = ApprovalConfig::new("Approve?").with_deadline_secs(0);
451 }
452
453 #[test]
454 #[should_panic(expected = "approval assignee must not be empty")]
455 fn assigned_to_rejects_blank() {
456 let _ = ApprovalConfig::new("Approve?").assigned_to(Assignee::group(" "));
457 }
458
459 #[test]
460 fn serde_roundtrip() {
461 let config = ApprovalConfig::new("Deploy to prod?")
462 .with_timeout_seconds(3600)
463 .with_deadline_secs(1800)
464 .on_timeout(EscalationPolicy::Escalate(Assignee::group("sre-oncall")))
465 .assigned_to(Assignee::group("release-managers"));
466
467 let json = serde_json::to_string(&config).expect("serialize");
468 let back: ApprovalConfig = serde_json::from_str(&json).expect("deserialize");
469
470 assert_eq!(back.message(), config.message());
471 assert_eq!(back.timeout_seconds(), config.timeout_seconds());
472 assert_eq!(back.deadline_secs(), config.deadline_secs());
473 assert_eq!(back.on_timeout_policy(), config.on_timeout_policy());
474 assert_eq!(back.assignee(), config.assignee());
475 }
476
477 #[test]
478 fn serde_minimal() {
479 let config = ApprovalConfig::new("Approve?");
480 let json = serde_json::to_string(&config).expect("serialize");
481 assert!(!json.contains("timeout_seconds"));
482 assert!(!json.contains("deadline_secs"));
483 assert!(!json.contains("on_timeout"));
484 assert!(!json.contains("assignee"));
485 assert!(!json.contains("rules"));
486 }
487
488 fn matrix() -> ApprovalConfig {
489 ApprovalConfig::new("Release the payment?")
490 .with_rule(
491 ApprovalRule::new("payload.amount > 100000", 3)
492 .with_approver_groups(["finance", "board"]),
493 )
494 .with_rule(
495 ApprovalRule::new("payload.amount > 10000", 2).with_approver_groups(["finance"]),
496 )
497 }
498
499 #[test]
500 fn with_rule_appends_in_order() {
501 let config = matrix();
502 assert_eq!(config.rules().len(), 2);
503 assert_eq!(config.rules()[0].required_approvers(), 3);
504 assert_eq!(config.rules()[1].required_approvers(), 2);
505 }
506
507 #[test]
508 fn evaluate_rules_first_match_wins() {
509 let ctx = json!({"payload": {"amount": 500000}});
510 let requirement = matrix().evaluate_rules(&ctx);
511
512 assert_eq!(requirement.rule_index, Some(0));
513 assert_eq!(
514 requirement.condition.as_deref(),
515 Some("payload.amount > 100000")
516 );
517 assert_eq!(requirement.required_approvers, 3);
518 assert_eq!(requirement.approver_groups, vec!["finance", "board"]);
519 assert_eq!(
520 requirement.evaluated,
521 vec![ApprovalRuleEvaluation {
522 index: 0,
523 condition: "payload.amount > 100000".to_string(),
524 matched: true,
525 }]
526 );
527 }
528
529 #[test]
530 fn evaluate_rules_records_misses_before_the_match() {
531 let ctx = json!({"payload": {"amount": 15000}});
532 let requirement = matrix().evaluate_rules(&ctx);
533
534 assert_eq!(requirement.rule_index, Some(1));
535 assert_eq!(requirement.required_approvers, 2);
536 assert_eq!(requirement.approver_groups, vec!["finance"]);
537 let matched: Vec<bool> = requirement.evaluated.iter().map(|e| e.matched).collect();
538 assert_eq!(matched, vec![false, true]);
539 assert_eq!(requirement.evaluated[1].index, 1);
540 }
541
542 #[test]
543 fn evaluate_rules_falls_through_to_the_default() {
544 let ctx = json!({"payload": {"amount": 10}});
545 let requirement = matrix().evaluate_rules(&ctx);
546
547 assert_eq!(requirement.rule_index, None);
548 assert_eq!(requirement.condition, None);
549 assert_eq!(requirement.required_approvers, 1);
550 assert!(requirement.approver_groups.is_empty());
551 assert_eq!(requirement.evaluated.len(), 2);
552 assert!(requirement.evaluated.iter().all(|e| !e.matched));
553 }
554
555 #[test]
556 fn evaluate_rules_without_rules_is_the_default() {
557 let requirement = ApprovalConfig::new("Approve?").evaluate_rules(&json!({}));
558 assert_eq!(requirement, ApprovalRequirement::default());
559 }
560
561 #[test]
562 fn serde_roundtrip_with_rules() {
563 let config = matrix();
564 let json = to_string(&config).expect("serialize");
565 let back: ApprovalConfig = from_str(&json).expect("deserialize");
566
567 assert_eq!(back.rules(), config.rules());
568 assert_eq!(to_string(&back).expect("serialize"), json);
569 }
570
571 #[test]
572 fn serde_accepts_a_config_written_before_rules_existed() {
573 let raw = r#"{"message":"Approve?","assignee":"user:alice"}"#;
574 let config: ApprovalConfig = from_str(raw).expect("deserialize");
575
576 assert!(config.rules().is_empty());
577 assert_eq!(config.assignee(), Some(&Assignee::user("alice")));
578 }
579
580 #[test]
581 fn serde_rejects_a_rule_with_zero_approvers() {
582 let result = from_value::<ApprovalConfig>(json!({
583 "message": "Approve?",
584 "rules": [{"condition": "payload.urgent", "required_approvers": 0}],
585 }));
586 assert!(result.is_err());
587 }
588
589 #[test]
590 fn serde_rejects_a_rule_with_an_invalid_condition() {
591 let result = from_value::<ApprovalConfig>(json!({
592 "message": "Approve?",
593 "rules": [{"condition": "payload.amount >", "required_approvers": 1}],
594 }));
595 assert!(result.is_err());
596 }
597
598 #[test]
599 fn serde_accepts_a_config_written_before_escalation_existed() {
600 let config: ApprovalConfig =
601 serde_json::from_str(r#"{"message":"Approve?","timeout_seconds":60}"#)
602 .expect("deserialize");
603
604 assert_eq!(config.effective_deadline_secs(), Some(60));
605 assert_eq!(config.effective_policy(), EscalationPolicy::AutoReject);
606 }
607}