Skip to main content

bento_kit/time/
time_use.rs

1//! [`TimeUse`] — multi-tag profiler, port of Node's `XTimeUse`.
2
3use std::collections::HashMap;
4use std::time::{Duration, Instant};
5
6#[derive(Debug, Clone, Copy)]
7struct Span {
8    start: Instant,
9    end: Option<Instant>,
10}
11
12/// A multi-stage profiler with named tags.
13///
14/// Mirrors Node's `XTimeUse`. On construction it records a "global" start
15/// time. Every method takes a `tag: Option<&str>`:
16///
17/// - `None` operates on the global timer.
18/// - `Some("foo")` operates on a per-tag span tracked in a `HashMap`.
19///
20/// **Idempotent stop**: once a span has been stopped, subsequent
21/// [`TimeUse::stop`] / [`TimeUse::elapsed`] calls return the cached duration.
22///
23/// **Fallback**: calling [`TimeUse::stop`] / [`TimeUse::elapsed`] with a
24/// tag that was never started uses the global start as the origin, so
25/// you can ad-hoc query "how long since the profiler was created"
26/// under any label.
27///
28/// ```
29/// use bento_kit::time::TimeUse;
30/// let mut t = TimeUse::new();
31///
32/// t.start(Some("connect"));
33/// // ... establish connection ...
34/// let connect_ms = t.stop(Some("connect")).as_millis();
35///
36/// t.start(Some("query"));
37/// // ... run query ...
38/// let query_ms = t.stop(Some("query")).as_millis();
39///
40/// let total_ms = t.stop(None).as_millis();
41/// # let _ = (connect_ms, query_ms, total_ms);
42/// ```
43#[derive(Debug)]
44pub struct TimeUse {
45    global_start: Instant,
46    global_end: Option<Instant>,
47    spans: HashMap<String, Span>,
48}
49
50impl TimeUse {
51    /// Construct a new profiler. The global timer starts immediately.
52    pub fn new() -> Self {
53        Self {
54            global_start: Instant::now(),
55            global_end: None,
56            spans: HashMap::new(),
57        }
58    }
59
60    /// Reset the timer for the given tag (or the global timer if `None`)
61    /// to "now". Other tags are unaffected.
62    pub fn start(&mut self, tag: Option<&str>) {
63        let now = Instant::now();
64        match tag {
65            None => {
66                self.global_start = now;
67                self.global_end = None;
68            }
69            Some(t) => {
70                self.spans.insert(
71                    t.to_string(),
72                    Span {
73                        start: now,
74                        end: None,
75                    },
76                );
77            }
78        }
79    }
80
81    /// Stop the timer for the given tag (or the global timer if `None`)
82    /// and return its elapsed duration.
83    ///
84    /// - **Idempotent**: subsequent calls return the cached duration.
85    /// - **Tag fallback**: if `tag` was never started, the global start
86    ///   is used as the origin and "now" is recorded as the end.
87    pub fn stop(&mut self, tag: Option<&str>) -> Duration {
88        let now = Instant::now();
89        match tag {
90            None => {
91                let end = *self.global_end.get_or_insert(now);
92                end.saturating_duration_since(self.global_start)
93            }
94            Some(t) => {
95                let global_start = self.global_start;
96                let span = self.spans.entry(t.to_string()).or_insert(Span {
97                    start: global_start,
98                    end: None,
99                });
100                let end = *span.end.get_or_insert(now);
101                end.saturating_duration_since(span.start)
102            }
103        }
104    }
105
106    /// Peek the elapsed duration without recording an end time.
107    ///
108    /// Returns the cached duration if the timer has already been stopped.
109    /// Falls back to the global start if the tag was never started.
110    pub fn elapsed(&self, tag: Option<&str>) -> Duration {
111        let now = Instant::now();
112        match tag {
113            None => {
114                let end = self.global_end.unwrap_or(now);
115                end.saturating_duration_since(self.global_start)
116            }
117            Some(t) => match self.spans.get(t) {
118                Some(span) => span
119                    .end
120                    .unwrap_or(now)
121                    .saturating_duration_since(span.start),
122                None => now.saturating_duration_since(self.global_start),
123            },
124        }
125    }
126
127    /// Stop and immediately restart the timer for the given tag (or the
128    /// global timer if `None`). Returns the duration of the just-finished
129    /// span.
130    pub fn restart(&mut self, tag: Option<&str>) -> Duration {
131        let elapsed = self.stop(tag);
132        self.start(tag);
133        elapsed
134    }
135
136    /// Number of tags currently tracked.
137    pub fn tag_count(&self) -> usize {
138        self.spans.len()
139    }
140}
141
142impl Default for TimeUse {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use std::thread::sleep;
152
153    #[test]
154    fn global_elapsed_grows() {
155        let t = TimeUse::new();
156        sleep(Duration::from_millis(5));
157        let a = t.elapsed(None);
158        sleep(Duration::from_millis(5));
159        let b = t.elapsed(None);
160        assert!(b >= a);
161        assert!(b.as_millis() >= 10);
162    }
163
164    #[test]
165    fn stop_is_idempotent() {
166        let mut t = TimeUse::new();
167        sleep(Duration::from_millis(10));
168        let first = t.stop(None);
169        sleep(Duration::from_millis(10));
170        let second = t.stop(None);
171        assert_eq!(first, second);
172    }
173
174    #[test]
175    fn elapsed_after_stop_returns_cached() {
176        let mut t = TimeUse::new();
177        sleep(Duration::from_millis(10));
178        let stopped = t.stop(None);
179        sleep(Duration::from_millis(10));
180        assert_eq!(t.elapsed(None), stopped);
181    }
182
183    #[test]
184    fn independent_tags() {
185        // Upper bounds intentionally absent: CI runners (especially macOS
186        // and Windows) can stretch a 10ms sleep well past 50ms under load.
187        // The lower bound is what actually validates the timer.
188        let mut t = TimeUse::new();
189        t.start(Some("a"));
190        sleep(Duration::from_millis(10));
191        let a = t.stop(Some("a"));
192
193        t.start(Some("b"));
194        sleep(Duration::from_millis(20));
195        let b = t.stop(Some("b"));
196
197        assert!(a.as_millis() >= 10);
198        assert!(b.as_millis() >= 20);
199        assert_eq!(t.tag_count(), 2);
200    }
201
202    #[test]
203    fn stop_tag_is_idempotent() {
204        let mut t = TimeUse::new();
205        t.start(Some("k"));
206        sleep(Duration::from_millis(5));
207        let first = t.stop(Some("k"));
208        sleep(Duration::from_millis(20));
209        let second = t.stop(Some("k"));
210        assert_eq!(first, second);
211    }
212
213    #[test]
214    fn stop_unknown_tag_falls_back_to_global_start() {
215        let mut t = TimeUse::new();
216        sleep(Duration::from_millis(10));
217        let elapsed = t.stop(Some("never_started"));
218        assert!(elapsed.as_millis() >= 10);
219    }
220
221    #[test]
222    fn elapsed_unknown_tag_falls_back_to_global_start() {
223        let t = TimeUse::new();
224        sleep(Duration::from_millis(10));
225        let elapsed = t.elapsed(Some("never_started"));
226        assert!(elapsed.as_millis() >= 10);
227    }
228
229    #[test]
230    fn stop_global_does_not_reset() {
231        let mut t = TimeUse::new();
232        sleep(Duration::from_millis(10));
233        let first = t.stop(None);
234        sleep(Duration::from_millis(20));
235        assert_eq!(t.stop(None), first);
236    }
237
238    #[test]
239    fn restart_global_returns_previous_duration() {
240        let mut t = TimeUse::new();
241        sleep(Duration::from_millis(10));
242        let prev = t.restart(None);
243        assert!(prev.as_millis() >= 10);
244        // We deliberately do not assert anything about post-restart elapsed
245        // here. CI runners can stretch wall-clock by tens of ms even
246        // between two function calls; relative comparisons against a
247        // 10ms baseline are not robust. The reset behavior is
248        // implicit in `restart = stop + start`, both of which have
249        // their own dedicated tests.
250    }
251
252    #[test]
253    fn restart_tag_returns_previous_duration() {
254        let mut t = TimeUse::new();
255        t.start(Some("loop"));
256        sleep(Duration::from_millis(10));
257        let first = t.restart(Some("loop"));
258        assert!(first.as_millis() >= 10);
259        // See restart_global_returns_previous_duration — no post-restart
260        // timing assertion, by design.
261    }
262}