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}