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    pub fn new() -> Self {
22        let now = Utc::now();
23        let base = now.format("%Y-%m-%d_%H-%M-%S%.3fZ").to_string();
24        Self(base)
25    }
26
27    /// Create a RunId from a string value (for testing).
28    ///
29    /// This is a test-only constructor that allows creating a RunId with
30    /// a fixed timestamp value for deterministic testing of collision handling.
31    ///
32    /// # Warning
33    ///
34    /// This is intended for testing only. Using a fixed run_id in production
35    /// could lead to directory collisions. Always use [`RunId::new`]
36    /// or [`RunId::from_checkpoint`] in production code.
37    pub fn for_test(id: &str) -> Self {
38        Self(id.to_string())
39    }
40
41    /// Create a RunId from an existing string (for resume).
42    ///
43    /// This is used when loading a checkpoint to continue using
44    /// the same run_id from the previous session.
45    pub fn from_checkpoint(id: &str) -> Self {
46        Self(id.to_string())
47    }
48
49    /// Get the run ID as a string slice.
50    pub fn as_str(&self) -> &str {
51        &self.0
52    }
53
54    /// Generate a collision-safe variant with counter suffix.
55    ///
56    /// Used when the base run directory already exists (rare case
57    /// of multiple runs starting in the same millisecond).
58    ///
59    /// # Arguments
60    /// * `counter` - Collision counter (1-99)
61    ///
62    /// # Returns
63    /// A new RunId with format: YYYY-MM-DD_HH-mm-ss.SSSZ-NN
64    pub fn with_collision_counter(&self, counter: u32) -> Self {
65        Self(format!("{}-{:02}", self.0, counter))
66    }
67}
68
69impl fmt::Display for RunId {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(f, "{}", self.0)
72    }
73}
74
75impl Default for RunId {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn test_run_id_format() {
87        let run_id = RunId::new();
88        let s = run_id.as_str();
89
90        // Check format: YYYY-MM-DD_HH-mm-ss.SSSZ
91        // Should be 24 characters for base format
92        assert!(s.len() >= 24, "Run ID should be at least 24 chars");
93        assert!(s.ends_with('Z'), "Run ID should end with Z");
94        assert!(
95            s.contains('_'),
96            "Run ID should contain underscore separator"
97        );
98        assert!(
99            s.contains('-'),
100            "Run ID should contain date/time separators"
101        );
102        assert!(
103            s.contains('.'),
104            "Run ID should contain millisecond separator"
105        );
106    }
107
108    #[test]
109    fn test_run_id_from_checkpoint() {
110        let original = "2026-02-06_14-03-27.123Z";
111        let run_id = RunId::from_checkpoint(original);
112        assert_eq!(run_id.as_str(), original);
113    }
114
115    #[test]
116    fn test_run_id_with_collision_counter() {
117        let base = RunId::new();
118        let collided = base.with_collision_counter(1);
119
120        assert!(
121            collided.as_str().ends_with("-01"),
122            "Collision counter should be appended"
123        );
124        assert!(collided.as_str().starts_with(base.as_str()));
125    }
126
127    #[test]
128    fn test_run_id_display() {
129        let run_id = RunId::new();
130        let displayed = format!("{}", run_id);
131        assert_eq!(displayed, run_id.as_str());
132    }
133
134    #[test]
135    fn test_run_id_sortable() {
136        // Create two run IDs with a small delay
137        let first = RunId::new();
138        std::thread::sleep(std::time::Duration::from_millis(10));
139        let second = RunId::new();
140
141        // Lexicographic comparison should match chronological order
142        assert!(
143            first.as_str() < second.as_str(),
144            "Run IDs should sort chronologically"
145        );
146    }
147}