1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
//! Time abstraction for the scheduler (AD-5 from docs/life_agent.md).
//!
//! Every time-sensitive code path takes `&dyn Clock` instead of calling
//! `Utc::now()` directly. Production code wires in [`SystemClock`]; tests
//! wire in [`MockClock`] and advance it deterministically, so firing-order
//! assertions never depend on a real sleep.
//!
//! Trait objects — not a generic parameter — because the scheduler's
//! `BinaryHeap` needs a single concrete type across both the production
//! path and the test path, and because the cost of a vtable call is lost
//! in the noise next to the JSON I/O and Telegram HTTP each firing does.
use std::sync::Mutex;
use chrono::{DateTime, Duration, Utc};
/// Monotonic source of wall-clock `DateTime<Utc>`. `Send + Sync` so the
/// scheduler can keep an `Arc<dyn Clock>` across the producer thread and
/// the consumer.
pub trait Clock: Send + Sync {
fn now(&self) -> DateTime<Utc>;
}
/// Production clock — delegates straight to `Utc::now()`.
#[derive(Debug, Default)]
pub struct SystemClock;
impl Clock for SystemClock {
fn now(&self) -> DateTime<Utc> {
Utc::now()
}
}
/// Test clock — holds a fixed instant behind a Mutex. Advance with
/// [`MockClock::advance`] / [`MockClock::set`] from tests; the scheduler
/// reads `now()` like any other clock.
#[derive(Debug)]
pub struct MockClock {
instant: Mutex<DateTime<Utc>>,
}
impl MockClock {
/// Construct at the given UTC datetime.
#[must_use]
pub fn new(at: DateTime<Utc>) -> Self {
Self {
instant: Mutex::new(at),
}
}
/// Move the mock clock forward by `delta`. Negative deltas move it back
/// — we allow that explicitly because some catch-up tests want to
/// rewind between assertions.
pub fn advance(&self, delta: Duration) {
if let Ok(mut g) = self.instant.lock() {
*g += delta;
}
}
/// Jump the clock to an absolute instant.
pub fn set(&self, at: DateTime<Utc>) {
if let Ok(mut g) = self.instant.lock() {
*g = at;
}
}
}
impl Clock for MockClock {
fn now(&self) -> DateTime<Utc> {
self.instant
.lock()
.map_or_else(|poisoned| *poisoned.into_inner(), |g| *g)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn system_clock_returns_recent_time() {
let c = SystemClock;
let before = Utc::now();
let t = c.now();
let after = Utc::now();
assert!(t >= before && t <= after);
}
#[test]
fn mock_clock_starts_at_given_instant() {
let at = Utc.with_ymd_and_hms(2026, 4, 21, 7, 0, 0).unwrap();
let c = MockClock::new(at);
assert_eq!(c.now(), at);
}
#[test]
fn mock_clock_advance_moves_forward() {
let at = Utc.with_ymd_and_hms(2026, 4, 21, 7, 0, 0).unwrap();
let c = MockClock::new(at);
c.advance(Duration::minutes(30));
assert_eq!(c.now(), at + Duration::minutes(30));
}
#[test]
fn mock_clock_advance_accepts_negative_delta() {
let at = Utc.with_ymd_and_hms(2026, 4, 21, 7, 0, 0).unwrap();
let c = MockClock::new(at);
c.advance(-Duration::hours(1));
assert_eq!(c.now(), at - Duration::hours(1));
}
#[test]
fn mock_clock_set_jumps_to_instant() {
let a = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let b = Utc.with_ymd_and_hms(2026, 12, 31, 23, 59, 59).unwrap();
let c = MockClock::new(a);
c.set(b);
assert_eq!(c.now(), b);
}
#[test]
fn clock_is_object_safe() {
// Compile-time check: the scheduler wants Arc<dyn Clock>, so the
// trait must be object-safe.
fn takes_dyn(_c: &dyn Clock) {}
let c = MockClock::new(Utc::now());
takes_dyn(&c);
takes_dyn(&SystemClock);
}
#[test]
fn mock_clock_is_send_sync() {
// Used by the scheduler across threads, so this matters.
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<MockClock>();
assert_send_sync::<SystemClock>();
}
}