Skip to main content

de_mls/
phase_timer.rs

1//! App-side phase timer.
2//!
3//! Holds the wall-clock anchor (`started_at`). Phase-anchor durations live
4//! in [`crate::ConversationConfig`] (single source of truth);
5
6use std::time::{Duration, Instant};
7
8/// Wall-clock anchor for the active phase. Holds only the anchor
9/// `Instant`; queries take the relevant `Duration` as a parameter.
10/// [`crate::Conversation`] composes the timer with the state
11/// machine and [`crate::ConversationConfig`] durations.
12#[derive(Debug, Clone, Default)]
13pub struct PhaseTimer {
14    /// Meaning depends on the orchestrator's intent at start time:
15    /// - Working: time the first approved proposal arrived
16    ///   (drives the steward-inactivity timer).
17    /// - Freezing: time the freeze window started.
18    /// - Other states: `None`.
19    started_at: Option<Instant>,
20}
21
22impl PhaseTimer {
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    /// Anchor the timer at "now". Called by the orchestrator when entering
28    /// a phase whose timeout matters (Freezing, on first approved proposal
29    /// in Working).
30    pub fn start(&mut self) {
31        self.started_at = Some(Instant::now());
32    }
33
34    /// Drop the anchor. Called by the orchestrator when leaving a
35    /// time-bounded phase.
36    pub fn clear(&mut self) {
37        self.started_at = None;
38    }
39
40    pub fn started_at(&self) -> Option<Instant> {
41        self.started_at
42    }
43
44    /// `false` when no anchor is set. Caller is responsible for state
45    /// guarding and for choosing the right duration for the current phase.
46    pub fn elapsed_since_anchor(&self, duration: Duration) -> bool {
47        match self.started_at {
48            Some(t) => Instant::now() >= t + duration,
49            None => false,
50        }
51    }
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57    use std::time::Duration;
58
59    #[test]
60    fn unset_never_elapsed() {
61        let pt = PhaseTimer::new();
62        assert!(!pt.elapsed_since_anchor(Duration::from_secs(1)));
63    }
64
65    #[test]
66    fn fresh_anchor_not_elapsed() {
67        let mut pt = PhaseTimer::new();
68        pt.start();
69        assert!(!pt.elapsed_since_anchor(Duration::from_secs(60)));
70    }
71
72    #[test]
73    fn elapsed_when_anchor_old_enough() {
74        let mut pt = PhaseTimer::new();
75        pt.started_at = Some(Instant::now() - Duration::from_secs(30));
76        assert!(pt.elapsed_since_anchor(Duration::from_secs(1)));
77    }
78
79    #[test]
80    fn clear_drops_anchor() {
81        let mut pt = PhaseTimer::new();
82        pt.start();
83        assert!(pt.started_at().is_some());
84        pt.clear();
85        assert!(pt.started_at().is_none());
86    }
87}