Skip to main content

oximedia_workflow/
sla_tracking.rs

1//! SLA tracking for workflow deadlines, breach detection, and escalation rules.
2
3#![allow(dead_code)]
4#![allow(clippy::cast_precision_loss)]
5
6use std::collections::HashMap;
7use std::time::{Duration, Instant};
8
9/// SLA tier / severity level.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum SlaLevel {
12    /// Bronze tier — standard turnaround.
13    Bronze,
14    /// Silver tier — expedited turnaround.
15    Silver,
16    /// Gold tier — same-day turnaround.
17    Gold,
18    /// Platinum tier — mission-critical.
19    Platinum,
20}
21
22/// Status of an SLA entry.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum SlaStatus {
25    /// SLA is active and within deadline.
26    Active,
27    /// SLA is at risk (within warning threshold).
28    AtRisk,
29    /// SLA has been breached.
30    Breached,
31    /// SLA has been met successfully.
32    Met,
33}
34
35/// An escalation rule triggered on SLA breach.
36#[derive(Debug, Clone)]
37pub struct EscalationRule {
38    /// Rule identifier.
39    pub id: String,
40    /// Minimum level that triggers this rule.
41    pub trigger_level: SlaLevel,
42    /// Contacts to notify (email addresses, usernames, etc.).
43    pub notify_contacts: Vec<String>,
44    /// Delay before escalating after breach is detected.
45    pub escalation_delay: Duration,
46    /// Whether this rule has already fired.
47    pub fired: bool,
48}
49
50impl EscalationRule {
51    /// Create a new escalation rule.
52    #[must_use]
53    pub fn new(id: &str, trigger_level: SlaLevel, contacts: Vec<String>, delay: Duration) -> Self {
54        Self {
55            id: id.to_string(),
56            trigger_level,
57            notify_contacts: contacts,
58            escalation_delay: delay,
59            fired: false,
60        }
61    }
62
63    /// Mark this rule as fired.
64    pub fn fire(&mut self) {
65        self.fired = true;
66    }
67}
68
69/// Internal SLA entry stored in the tracker.
70#[derive(Debug, Clone)]
71struct SlaEntryInternal {
72    id: String,
73    name: String,
74    deadline: Instant,
75    warning_threshold: Duration,
76    status: SlaStatus,
77    level: SlaLevel,
78    escalation_rules: Vec<EscalationRule>,
79    created_at: Instant,
80    breach_detected_at: Option<Instant>,
81}
82
83impl SlaEntryInternal {
84    fn new(id: &str, name: &str, ttd: Duration, level: SlaLevel) -> Self {
85        let warning = if ttd.as_secs() == 0 {
86            Duration::ZERO
87        } else {
88            ttd / 5
89        };
90        Self {
91            id: id.to_string(),
92            name: name.to_string(),
93            deadline: Instant::now() + ttd,
94            warning_threshold: warning,
95            status: SlaStatus::Active,
96            level,
97            escalation_rules: Vec::new(),
98            created_at: Instant::now(),
99            breach_detected_at: None,
100        }
101    }
102
103    fn remaining(&self) -> Duration {
104        let now = Instant::now();
105        if now >= self.deadline {
106            Duration::ZERO
107        } else {
108            self.deadline - now
109        }
110    }
111
112    fn refresh_status(&mut self) {
113        if self.status == SlaStatus::Met {
114            return;
115        }
116        let now = Instant::now();
117        if now >= self.deadline {
118            if self.status != SlaStatus::Breached {
119                self.breach_detected_at = Some(now);
120            }
121            self.status = SlaStatus::Breached;
122        } else if self.remaining() <= self.warning_threshold {
123            self.status = SlaStatus::AtRisk;
124        }
125    }
126}
127
128/// SLA tracker managing multiple SLA entries.
129#[derive(Debug, Default)]
130pub struct SlaTracker {
131    entries: HashMap<String, SlaEntryInternal>,
132}
133
134impl SlaTracker {
135    /// Create a new empty SLA tracker.
136    #[must_use]
137    pub fn new() -> Self {
138        Self::default()
139    }
140
141    /// Register a new SLA entry with a time-to-deadline and level.
142    pub fn register(&mut self, id: &str, name: &str, ttd: Duration, level: SlaLevel) {
143        let entry = SlaEntryInternal::new(id, name, ttd, level);
144        self.entries.insert(id.to_string(), entry);
145    }
146
147    /// Add an escalation rule to an existing entry.
148    pub fn add_escalation_rule(&mut self, entry_id: &str, rule: EscalationRule) -> bool {
149        if let Some(e) = self.entries.get_mut(entry_id) {
150            e.escalation_rules.push(rule);
151            true
152        } else {
153            false
154        }
155    }
156
157    /// Mark an SLA as successfully met.
158    pub fn mark_met(&mut self, id: &str) -> bool {
159        if let Some(e) = self.entries.get_mut(id) {
160            e.status = SlaStatus::Met;
161            true
162        } else {
163            false
164        }
165    }
166
167    /// Tick: refresh all statuses and return list of newly breached IDs.
168    pub fn tick(&mut self) -> Vec<String> {
169        let mut breached = Vec::new();
170        for entry in self.entries.values_mut() {
171            let was_breached = entry.status == SlaStatus::Breached;
172            entry.refresh_status();
173            if !was_breached && entry.status == SlaStatus::Breached {
174                breached.push(entry.id.clone());
175            }
176        }
177        breached
178    }
179
180    /// Return all at-risk entry IDs.
181    #[must_use]
182    pub fn at_risk_ids(&self) -> Vec<String> {
183        self.entries
184            .values()
185            .filter(|e| e.status == SlaStatus::AtRisk)
186            .map(|e| e.id.clone())
187            .collect()
188    }
189
190    /// Return all breached entry IDs.
191    #[must_use]
192    pub fn breached_ids(&self) -> Vec<String> {
193        self.entries
194            .values()
195            .filter(|e| e.status == SlaStatus::Breached)
196            .map(|e| e.id.clone())
197            .collect()
198    }
199
200    /// Return status of a specific entry.
201    #[must_use]
202    pub fn status(&self, id: &str) -> Option<SlaStatus> {
203        self.entries.get(id).map(|e| e.status.clone())
204    }
205
206    /// Return level of a specific entry.
207    #[must_use]
208    pub fn level(&self, id: &str) -> Option<&SlaLevel> {
209        self.entries.get(id).map(|e| &e.level)
210    }
211
212    /// Return remaining duration for a specific entry.
213    #[must_use]
214    pub fn remaining(&self, id: &str) -> Option<Duration> {
215        self.entries.get(id).map(SlaEntryInternal::remaining)
216    }
217
218    /// Count total entries.
219    #[must_use]
220    pub fn count(&self) -> usize {
221        self.entries.len()
222    }
223
224    /// Count entries by status.
225    #[must_use]
226    pub fn count_by_status(&self, status: &SlaStatus) -> usize {
227        self.entries
228            .values()
229            .filter(|e| &e.status == status)
230            .count()
231    }
232
233    /// Breach rate (breached / total), returns 0.0 if no entries.
234    #[must_use]
235    pub fn breach_rate(&self) -> f64 {
236        if self.entries.is_empty() {
237            return 0.0;
238        }
239        let breached = self.count_by_status(&SlaStatus::Breached) as f64;
240        let total = self.entries.len() as f64;
241        breached / total
242    }
243
244    /// Compliance rate (met / total), returns 1.0 if no entries.
245    #[must_use]
246    pub fn compliance_rate(&self) -> f64 {
247        if self.entries.is_empty() {
248            return 1.0;
249        }
250        let met = self.count_by_status(&SlaStatus::Met) as f64;
251        let total = self.entries.len() as f64;
252        met / total
253    }
254
255    /// Fire pending escalation rules for breached entries.
256    pub fn fire_escalations(&mut self) -> Vec<String> {
257        let mut fired_ids = Vec::new();
258        for entry in self.entries.values_mut() {
259            if entry.status != SlaStatus::Breached {
260                continue;
261            }
262            if let Some(breach_time) = entry.breach_detected_at {
263                let elapsed = Instant::now().duration_since(breach_time);
264                for rule in &mut entry.escalation_rules {
265                    if !rule.fired && elapsed >= rule.escalation_delay {
266                        rule.fired = true;
267                        fired_ids.push(rule.id.clone());
268                    }
269                }
270            }
271        }
272        fired_ids
273    }
274}
275
276/// Breach detection report.
277#[derive(Debug, Clone)]
278pub struct BreachReport {
279    /// ID of the breached entry.
280    pub entry_id: String,
281    /// Name of the breached entry.
282    pub entry_name: String,
283    /// Level of the SLA.
284    pub level: SlaLevel,
285    /// How long ago the breach occurred (approximate).
286    pub breach_age: Duration,
287}
288
289/// Build a breach report for all currently breached entries.
290#[must_use]
291pub fn build_breach_report(tracker: &SlaTracker) -> Vec<BreachReport> {
292    tracker
293        .entries
294        .values()
295        .filter(|e| e.status == SlaStatus::Breached)
296        .map(|e| BreachReport {
297            entry_id: e.id.clone(),
298            entry_name: e.name.clone(),
299            level: e.level.clone(),
300            breach_age: e
301                .breach_detected_at
302                .map_or(Duration::ZERO, |t| Instant::now().duration_since(t)),
303        })
304        .collect()
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_tracker_register_and_count() {
313        let mut tracker = SlaTracker::new();
314        tracker.register(
315            "w1",
316            "Workflow 1",
317            Duration::from_secs(60),
318            SlaLevel::Bronze,
319        );
320        tracker.register("w2", "Workflow 2", Duration::from_secs(120), SlaLevel::Gold);
321        assert_eq!(tracker.count(), 2);
322    }
323
324    #[test]
325    fn test_initial_status_active() {
326        let mut tracker = SlaTracker::new();
327        tracker.register(
328            "w1",
329            "Workflow 1",
330            Duration::from_secs(60),
331            SlaLevel::Silver,
332        );
333        assert_eq!(tracker.status("w1"), Some(SlaStatus::Active));
334    }
335
336    #[test]
337    fn test_mark_met() {
338        let mut tracker = SlaTracker::new();
339        tracker.register("w1", "Workflow 1", Duration::from_secs(60), SlaLevel::Gold);
340        assert!(tracker.mark_met("w1"));
341        assert_eq!(tracker.status("w1"), Some(SlaStatus::Met));
342    }
343
344    #[test]
345    fn test_mark_met_nonexistent() {
346        let mut tracker = SlaTracker::new();
347        assert!(!tracker.mark_met("nonexistent"));
348    }
349
350    #[test]
351    fn test_status_unknown_id() {
352        let tracker = SlaTracker::new();
353        assert_eq!(tracker.status("missing"), None);
354    }
355
356    #[test]
357    fn test_breach_on_zero_duration() {
358        let mut tracker = SlaTracker::new();
359        tracker.register("w1", "Workflow 1", Duration::ZERO, SlaLevel::Platinum);
360        tracker.tick();
361        assert_eq!(tracker.status("w1"), Some(SlaStatus::Breached));
362    }
363
364    #[test]
365    fn test_compliance_rate_all_met() {
366        let mut tracker = SlaTracker::new();
367        tracker.register("w1", "W1", Duration::from_secs(60), SlaLevel::Bronze);
368        tracker.register("w2", "W2", Duration::from_secs(60), SlaLevel::Bronze);
369        tracker.mark_met("w1");
370        tracker.mark_met("w2");
371        assert!((tracker.compliance_rate() - 1.0).abs() < f64::EPSILON);
372    }
373
374    #[test]
375    fn test_breach_rate_none_breached() {
376        let mut tracker = SlaTracker::new();
377        tracker.register("w1", "W1", Duration::from_secs(3600), SlaLevel::Silver);
378        assert!((tracker.breach_rate() - 0.0).abs() < f64::EPSILON);
379    }
380
381    #[test]
382    fn test_breach_rate_empty() {
383        let tracker = SlaTracker::new();
384        assert_eq!(tracker.breach_rate(), 0.0);
385    }
386
387    #[test]
388    fn test_compliance_rate_empty() {
389        let tracker = SlaTracker::new();
390        assert_eq!(tracker.compliance_rate(), 1.0);
391    }
392
393    #[test]
394    fn test_at_risk_ids_empty() {
395        let tracker = SlaTracker::new();
396        assert!(tracker.at_risk_ids().is_empty());
397    }
398
399    #[test]
400    fn test_breached_ids_empty() {
401        let tracker = SlaTracker::new();
402        assert!(tracker.breached_ids().is_empty());
403    }
404
405    #[test]
406    fn test_count_by_status() {
407        let mut tracker = SlaTracker::new();
408        tracker.register("w1", "W1", Duration::from_secs(60), SlaLevel::Bronze);
409        tracker.register("w2", "W2", Duration::from_secs(60), SlaLevel::Bronze);
410        tracker.mark_met("w1");
411        assert_eq!(tracker.count_by_status(&SlaStatus::Met), 1);
412        assert_eq!(tracker.count_by_status(&SlaStatus::Active), 1);
413    }
414
415    #[test]
416    fn test_escalation_rule_new() {
417        let rule = EscalationRule::new(
418            "rule1",
419            SlaLevel::Gold,
420            vec!["ops@example.com".to_string()],
421            Duration::from_secs(300),
422        );
423        assert_eq!(rule.id, "rule1");
424        assert!(!rule.fired);
425        assert_eq!(rule.notify_contacts.len(), 1);
426    }
427
428    #[test]
429    fn test_escalation_rule_fire() {
430        let mut rule = EscalationRule::new(
431            "rule2",
432            SlaLevel::Platinum,
433            vec!["cto@example.com".to_string()],
434            Duration::ZERO,
435        );
436        rule.fire();
437        assert!(rule.fired);
438    }
439
440    #[test]
441    fn test_add_escalation_rule() {
442        let mut tracker = SlaTracker::new();
443        tracker.register("w1", "W1", Duration::from_secs(60), SlaLevel::Gold);
444        let rule = EscalationRule::new("r1", SlaLevel::Gold, vec![], Duration::from_secs(30));
445        assert!(tracker.add_escalation_rule("w1", rule));
446    }
447
448    #[test]
449    fn test_add_escalation_rule_missing_entry() {
450        let mut tracker = SlaTracker::new();
451        let rule = EscalationRule::new("r1", SlaLevel::Gold, vec![], Duration::from_secs(30));
452        assert!(!tracker.add_escalation_rule("nonexistent", rule));
453    }
454
455    #[test]
456    fn test_breach_report_fields() {
457        let report = BreachReport {
458            entry_id: "w1".to_string(),
459            entry_name: "Workflow 1".to_string(),
460            level: SlaLevel::Gold,
461            breach_age: Duration::from_secs(10),
462        };
463        assert_eq!(report.entry_id, "w1");
464        assert_eq!(report.level, SlaLevel::Gold);
465    }
466
467    #[test]
468    fn test_build_breach_report_empty() {
469        let tracker = SlaTracker::new();
470        let report = build_breach_report(&tracker);
471        assert!(report.is_empty());
472    }
473
474    #[test]
475    fn test_level_accessor() {
476        let mut tracker = SlaTracker::new();
477        tracker.register("w1", "W1", Duration::from_secs(60), SlaLevel::Platinum);
478        assert_eq!(tracker.level("w1"), Some(&SlaLevel::Platinum));
479    }
480
481    #[test]
482    fn test_remaining_positive() {
483        let mut tracker = SlaTracker::new();
484        tracker.register("w1", "W1", Duration::from_secs(3600), SlaLevel::Bronze);
485        let rem = tracker.remaining("w1").expect("should succeed in test");
486        assert!(rem > Duration::from_secs(3590));
487    }
488}