Skip to main content

ralph_workflow/logging/
run_id.rs

1use chrono::Utc;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5/// Unique identifier for a pipeline run.
6///
7/// Format: YYYY-MM-DD_HH-mm-ss.SSSZ[-NN]
8/// where NN is an optional collision counter (01, 02, etc.)
9///
10/// The format is designed to be:
11/// - Human-readable
12/// - Machine-sortable (lexicographic sort == chronological order)
13/// - Filesystem-safe (no colons, valid on macOS, Linux, Windows)
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct RunId(String);
16
17impl RunId {
18    /// Generate a new run ID based on current UTC timestamp.
19    ///
20    /// Returns a `RunId` with format: YYYY-MM-DD_HH-mm-ss.SSSZ
21    #[must_use]
22    pub fn new() -> Self {
23        let now = Utc::now();
24        let base = now.format("%Y-%m-%d_%H-%M-%S%.3fZ").to_string();
25        Self(base)
26    }
27
28    /// Create a `RunId` from a string value (for testing).
29    ///
30    /// This is a test-only constructor that allows creating a `RunId` with
31    /// a fixed timestamp value for deterministic testing of collision handling.
32    ///
33    /// # Warning
34    ///
35    /// This is intended for testing only. Using a fixed `run_id` in production
36    /// could lead to directory collisions. Always use [`RunId::new`]
37    /// or [`RunId::from_checkpoint`] in production code.
38    #[must_use]
39    pub fn for_test(id: &str) -> Self {
40        Self(id.to_string())
41    }
42
43    /// Create a `RunId` from an existing string (for resume).
44    ///
45    /// This is used when loading a checkpoint to continue using
46    /// the same `run_id` from the previous session.
47    #[must_use]
48    pub fn from_checkpoint(id: &str) -> Self {
49        Self(id.to_string())
50    }
51
52    /// Get the run ID as a string slice.
53    #[must_use]
54    pub fn as_str(&self) -> &str {
55        &self.0
56    }
57
58    /// Generate a collision-safe variant with counter suffix.
59    ///
60    /// Used when the base run directory already exists (rare case
61    /// of multiple runs starting in the same millisecond).
62    ///
63    /// # Arguments
64    /// * `counter` - Collision counter (1-99)
65    ///
66    /// # Returns
67    /// A new `RunId` with format: YYYY-MM-DD_HH-mm-ss.SSSZ-NN
68    #[must_use]
69    pub fn with_collision_counter(&self, counter: u32) -> Self {
70        Self(format!("{}-{:02}", self.0, counter))
71    }
72}
73
74impl fmt::Display for RunId {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        write!(f, "{}", self.0)
77    }
78}
79
80impl Default for RunId {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn test_run_id_format() {
92        let run_id = RunId::new();
93        let s = run_id.as_str();
94
95        // Check format: YYYY-MM-DD_HH-mm-ss.SSSZ
96        // Should be 24 characters for base format
97        assert!(s.len() >= 24, "Run ID should be at least 24 chars");
98        assert!(s.ends_with('Z'), "Run ID should end with Z");
99        assert!(
100            s.contains('_'),
101            "Run ID should contain underscore separator"
102        );
103        assert!(
104            s.contains('-'),
105            "Run ID should contain date/time separators"
106        );
107        assert!(
108            s.contains('.'),
109            "Run ID should contain millisecond separator"
110        );
111    }
112
113    #[test]
114    fn test_run_id_from_checkpoint() {
115        let original = "2026-02-06_14-03-27.123Z";
116        let run_id = RunId::from_checkpoint(original);
117        assert_eq!(run_id.as_str(), original);
118    }
119
120    #[test]
121    fn test_run_id_with_collision_counter() {
122        let base = RunId::new();
123        let collided = base.with_collision_counter(1);
124
125        assert!(
126            collided.as_str().ends_with("-01"),
127            "Collision counter should be appended"
128        );
129        assert!(collided.as_str().starts_with(base.as_str()));
130    }
131
132    #[test]
133    fn test_run_id_display() {
134        let run_id = RunId::new();
135        let displayed = format!("{run_id}");
136        assert_eq!(displayed, run_id.as_str());
137    }
138
139    #[test]
140    fn test_run_id_sortable() {
141        // The RunId format (YYYY-MM-DD_HH-mm-ss.SSSZ) is designed so that
142        // lexicographic order equals chronological order.
143        // Verify the format property with two known-ordered strings — no real clock needed.
144        let earlier = RunId::for_test("2024-01-01_00-00-01.000Z");
145        let later = RunId::for_test("2024-01-01_00-00-02.000Z");
146        assert!(
147            earlier.as_str() < later.as_str(),
148            "RunId format should be lexicographically sortable in chronological order"
149        );
150    }
151}