bento-kit 0.1.1

A bento box of common Rust utilities: id generation, timing, masking
Documentation
//! [`TimeUse`] — multi-tag profiler, port of Node's `XTimeUse`.

use std::collections::HashMap;
use std::time::{Duration, Instant};

#[derive(Debug, Clone, Copy)]
struct Span {
    start: Instant,
    end: Option<Instant>,
}

/// A multi-stage profiler with named tags.
///
/// Mirrors Node's `XTimeUse`. On construction it records a "global" start
/// time. Every method takes a `tag: Option<&str>`:
///
/// - `None` operates on the global timer.
/// - `Some("foo")` operates on a per-tag span tracked in a `HashMap`.
///
/// **Idempotent stop**: once a span has been stopped, subsequent
/// [`TimeUse::stop`] / [`TimeUse::elapsed`] calls return the cached duration.
///
/// **Fallback**: calling [`TimeUse::stop`] / [`TimeUse::elapsed`] with a
/// tag that was never started uses the global start as the origin, so
/// you can ad-hoc query "how long since the profiler was created"
/// under any label.
///
/// ```
/// use bento_kit::time::TimeUse;
/// let mut t = TimeUse::new();
///
/// t.start(Some("connect"));
/// // ... establish connection ...
/// let connect_ms = t.stop(Some("connect")).as_millis();
///
/// t.start(Some("query"));
/// // ... run query ...
/// let query_ms = t.stop(Some("query")).as_millis();
///
/// let total_ms = t.stop(None).as_millis();
/// # let _ = (connect_ms, query_ms, total_ms);
/// ```
#[derive(Debug)]
pub struct TimeUse {
    global_start: Instant,
    global_end: Option<Instant>,
    spans: HashMap<String, Span>,
}

impl TimeUse {
    /// Construct a new profiler. The global timer starts immediately.
    pub fn new() -> Self {
        Self {
            global_start: Instant::now(),
            global_end: None,
            spans: HashMap::new(),
        }
    }

    /// Reset the timer for the given tag (or the global timer if `None`)
    /// to "now". Other tags are unaffected.
    pub fn start(&mut self, tag: Option<&str>) {
        let now = Instant::now();
        match tag {
            None => {
                self.global_start = now;
                self.global_end = None;
            }
            Some(t) => {
                self.spans.insert(
                    t.to_string(),
                    Span {
                        start: now,
                        end: None,
                    },
                );
            }
        }
    }

    /// Stop the timer for the given tag (or the global timer if `None`)
    /// and return its elapsed duration.
    ///
    /// - **Idempotent**: subsequent calls return the cached duration.
    /// - **Tag fallback**: if `tag` was never started, the global start
    ///   is used as the origin and "now" is recorded as the end.
    pub fn stop(&mut self, tag: Option<&str>) -> Duration {
        let now = Instant::now();
        match tag {
            None => {
                let end = *self.global_end.get_or_insert(now);
                end.saturating_duration_since(self.global_start)
            }
            Some(t) => {
                let global_start = self.global_start;
                let span = self.spans.entry(t.to_string()).or_insert(Span {
                    start: global_start,
                    end: None,
                });
                let end = *span.end.get_or_insert(now);
                end.saturating_duration_since(span.start)
            }
        }
    }

    /// Peek the elapsed duration without recording an end time.
    ///
    /// Returns the cached duration if the timer has already been stopped.
    /// Falls back to the global start if the tag was never started.
    pub fn elapsed(&self, tag: Option<&str>) -> Duration {
        let now = Instant::now();
        match tag {
            None => {
                let end = self.global_end.unwrap_or(now);
                end.saturating_duration_since(self.global_start)
            }
            Some(t) => match self.spans.get(t) {
                Some(span) => span
                    .end
                    .unwrap_or(now)
                    .saturating_duration_since(span.start),
                None => now.saturating_duration_since(self.global_start),
            },
        }
    }

    /// Stop and immediately restart the timer for the given tag (or the
    /// global timer if `None`). Returns the duration of the just-finished
    /// span.
    pub fn restart(&mut self, tag: Option<&str>) -> Duration {
        let elapsed = self.stop(tag);
        self.start(tag);
        elapsed
    }

    /// Number of tags currently tracked.
    pub fn tag_count(&self) -> usize {
        self.spans.len()
    }
}

impl Default for TimeUse {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::thread::sleep;

    #[test]
    fn global_elapsed_grows() {
        let t = TimeUse::new();
        sleep(Duration::from_millis(5));
        let a = t.elapsed(None);
        sleep(Duration::from_millis(5));
        let b = t.elapsed(None);
        assert!(b >= a);
        assert!(b.as_millis() >= 10);
    }

    #[test]
    fn stop_is_idempotent() {
        let mut t = TimeUse::new();
        sleep(Duration::from_millis(10));
        let first = t.stop(None);
        sleep(Duration::from_millis(10));
        let second = t.stop(None);
        assert_eq!(first, second);
    }

    #[test]
    fn elapsed_after_stop_returns_cached() {
        let mut t = TimeUse::new();
        sleep(Duration::from_millis(10));
        let stopped = t.stop(None);
        sleep(Duration::from_millis(10));
        assert_eq!(t.elapsed(None), stopped);
    }

    #[test]
    fn independent_tags() {
        // Upper bounds intentionally absent: CI runners (especially macOS
        // and Windows) can stretch a 10ms sleep well past 50ms under load.
        // The lower bound is what actually validates the timer.
        let mut t = TimeUse::new();
        t.start(Some("a"));
        sleep(Duration::from_millis(10));
        let a = t.stop(Some("a"));

        t.start(Some("b"));
        sleep(Duration::from_millis(20));
        let b = t.stop(Some("b"));

        assert!(a.as_millis() >= 10);
        assert!(b.as_millis() >= 20);
        assert_eq!(t.tag_count(), 2);
    }

    #[test]
    fn stop_tag_is_idempotent() {
        let mut t = TimeUse::new();
        t.start(Some("k"));
        sleep(Duration::from_millis(5));
        let first = t.stop(Some("k"));
        sleep(Duration::from_millis(20));
        let second = t.stop(Some("k"));
        assert_eq!(first, second);
    }

    #[test]
    fn stop_unknown_tag_falls_back_to_global_start() {
        let mut t = TimeUse::new();
        sleep(Duration::from_millis(10));
        let elapsed = t.stop(Some("never_started"));
        assert!(elapsed.as_millis() >= 10);
    }

    #[test]
    fn elapsed_unknown_tag_falls_back_to_global_start() {
        let t = TimeUse::new();
        sleep(Duration::from_millis(10));
        let elapsed = t.elapsed(Some("never_started"));
        assert!(elapsed.as_millis() >= 10);
    }

    #[test]
    fn stop_global_does_not_reset() {
        let mut t = TimeUse::new();
        sleep(Duration::from_millis(10));
        let first = t.stop(None);
        sleep(Duration::from_millis(20));
        assert_eq!(t.stop(None), first);
    }

    #[test]
    fn restart_global_returns_previous_duration() {
        let mut t = TimeUse::new();
        sleep(Duration::from_millis(10));
        let prev = t.restart(None);
        assert!(prev.as_millis() >= 10);
        // We deliberately do not assert anything about post-restart elapsed
        // here. CI runners can stretch wall-clock by tens of ms even
        // between two function calls; relative comparisons against a
        // 10ms baseline are not robust. The reset behavior is
        // implicit in `restart = stop + start`, both of which have
        // their own dedicated tests.
    }

    #[test]
    fn restart_tag_returns_previous_duration() {
        let mut t = TimeUse::new();
        t.start(Some("loop"));
        sleep(Duration::from_millis(10));
        let first = t.restart(Some("loop"));
        assert!(first.as_millis() >= 10);
        // See restart_global_returns_previous_duration — no post-restart
        // timing assertion, by design.
    }
}