Skip to main content

fd_core/
id.rs

1//! Strongly-typed IDs for FerrumDeck entities
2//!
3//! All IDs use ULID (Universally Unique Lexicographically Sortable Identifier)
4//! for time-ordered, collision-resistant identification.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use ulid::Ulid;
9
10/// Macro to generate strongly-typed ID wrappers
11macro_rules! define_id {
12    ($name:ident, $prefix:expr) => {
13        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
14        #[serde(transparent)]
15        pub struct $name(Ulid);
16
17        impl $name {
18            /// Create a new ID
19            pub fn new() -> Self {
20                Self(Ulid::new())
21            }
22
23            /// Create from an existing ULID
24            pub fn from_ulid(ulid: Ulid) -> Self {
25                Self(ulid)
26            }
27
28            /// Parse from string (with or without prefix)
29            pub fn parse(s: &str) -> Result<Self, IdParseError> {
30                let s = s.strip_prefix($prefix).unwrap_or(s);
31                let s = s.strip_prefix('_').unwrap_or(s);
32                let ulid = Ulid::from_string(s).map_err(|_| IdParseError::InvalidFormat)?;
33                Ok(Self(ulid))
34            }
35
36            /// Get the inner ULID
37            pub fn inner(&self) -> Ulid {
38                self.0
39            }
40
41            /// Get the timestamp from the ID
42            pub fn timestamp(&self) -> u64 {
43                self.0.timestamp_ms()
44            }
45
46            /// Convert to prefixed string representation
47            pub fn to_prefixed_string(&self) -> String {
48                format!("{}_{}", $prefix, self.0)
49            }
50        }
51
52        impl Default for $name {
53            fn default() -> Self {
54                Self::new()
55            }
56        }
57
58        impl fmt::Display for $name {
59            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60                write!(f, "{}_{}", $prefix, self.0)
61            }
62        }
63
64        impl std::str::FromStr for $name {
65            type Err = IdParseError;
66
67            fn from_str(s: &str) -> Result<Self, Self::Err> {
68                Self::parse(s)
69            }
70        }
71    };
72}
73
74/// Error parsing an ID
75#[derive(Debug, Clone, thiserror::Error)]
76pub enum IdParseError {
77    #[error("invalid ID format")]
78    InvalidFormat,
79}
80
81// Define all entity IDs
82define_id!(TenantId, "ten");
83define_id!(WorkspaceId, "wks");
84define_id!(ProjectId, "prj");
85define_id!(AgentId, "agt");
86define_id!(AgentVersionId, "agv");
87define_id!(ToolId, "tol");
88define_id!(ToolVersionId, "tov");
89define_id!(RunId, "run");
90define_id!(StepId, "stp");
91define_id!(PolicyRuleId, "pol");
92define_id!(PolicyDecisionId, "pdc");
93define_id!(ApprovalId, "apr");
94define_id!(AuditEventId, "aud");
95define_id!(ApiKeyId, "key");
96define_id!(ArtifactId, "art");
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use std::collections::HashSet;
102
103    // ==========================================================================
104    // CORE-ID-001: RunId generates valid ULID with "run_" prefix
105    // ==========================================================================
106    #[test]
107    fn test_run_id_generation() {
108        let id = RunId::new();
109        let s = id.to_string();
110        assert!(s.starts_with("run_"));
111        assert_eq!(s.len(), 30); // "run_" (4) + ULID (26)
112    }
113
114    // ==========================================================================
115    // CORE-ID-002: StepId generates valid ULID with "stp_" prefix
116    // ==========================================================================
117    #[test]
118    fn test_step_id_generation() {
119        let id = StepId::new();
120        let s = id.to_string();
121        assert!(s.starts_with("stp_"));
122        assert_eq!(s.len(), 30);
123    }
124
125    // ==========================================================================
126    // CORE-ID-003: AgentId generates valid ULID with "agt_" prefix
127    // ==========================================================================
128    #[test]
129    fn test_agent_id_generation() {
130        let id = AgentId::new();
131        let s = id.to_string();
132        assert!(s.starts_with("agt_"));
133        assert_eq!(s.len(), 30);
134    }
135
136    // ==========================================================================
137    // CORE-ID-004: IDs can be parsed from valid strings
138    // ==========================================================================
139    #[test]
140    fn test_id_parsing_valid() {
141        let id = RunId::new();
142        let s = id.to_string();
143        let parsed = RunId::parse(&s).unwrap();
144        assert_eq!(id, parsed);
145    }
146
147    #[test]
148    fn test_run_id_parse_without_prefix() {
149        let id = RunId::new();
150        let ulid_str = id.inner().to_string();
151        let parsed = RunId::parse(&ulid_str).unwrap();
152        assert_eq!(id, parsed);
153    }
154
155    // ==========================================================================
156    // CORE-ID-005: Parsing fails for wrong prefix
157    // ==========================================================================
158    #[test]
159    fn test_id_parsing_different_prefix_fails() {
160        // Implementation only strips its own prefix, so wrong prefix causes failure
161        let id = RunId::new();
162        let s = id.to_string();
163        // Replace run_ with stp_ - this leaves "stp_" in the string
164        let wrong_prefix = s.replace("run_", "stp_");
165        // Should fail because "stp_<ULID>" is not a valid ULID
166        let result = RunId::parse(&wrong_prefix);
167        assert!(result.is_err());
168    }
169
170    #[test]
171    fn test_id_parsing_with_correct_prefix_works() {
172        // Parsing with correct prefix should work
173        let id = RunId::new();
174        let prefixed = id.to_prefixed_string();
175        let result = RunId::parse(&prefixed);
176        assert!(result.is_ok());
177        assert_eq!(result.unwrap(), id);
178    }
179
180    // ==========================================================================
181    // CORE-ID-006: Parsing fails for invalid ULID portion
182    // ==========================================================================
183    #[test]
184    fn test_id_parsing_invalid_ulid() {
185        let result = RunId::parse("run_INVALID_ULID_STRING!!!");
186        assert!(result.is_err());
187        match result {
188            Err(IdParseError::InvalidFormat) => {}
189            _ => panic!("Expected InvalidFormat error"),
190        }
191    }
192
193    #[test]
194    fn test_id_parsing_empty_string() {
195        let result = RunId::parse("");
196        assert!(result.is_err());
197    }
198
199    #[test]
200    fn test_id_parsing_too_short() {
201        let result = RunId::parse("run_ABC");
202        assert!(result.is_err());
203    }
204
205    // ==========================================================================
206    // CORE-ID-007: Generated IDs are unique
207    // ==========================================================================
208    #[test]
209    fn test_id_uniqueness() {
210        let mut ids = HashSet::new();
211        for _ in 0..1000 {
212            let id = RunId::new();
213            assert!(ids.insert(id), "Duplicate ID generated");
214        }
215        assert_eq!(ids.len(), 1000);
216    }
217
218    // ==========================================================================
219    // CORE-ID-008: IDs are time-ordered
220    // ==========================================================================
221    #[test]
222    fn test_id_ordering() {
223        let id1 = RunId::new();
224        std::thread::sleep(std::time::Duration::from_millis(2));
225        let id2 = RunId::new();
226
227        // ULID timestamps should be ordered
228        assert!(id2.timestamp() >= id1.timestamp());
229
230        // String comparison should also preserve order
231        assert!(id2.to_string() > id1.to_string());
232    }
233
234    // ==========================================================================
235    // CORE-ID-009: IDs serialize/deserialize correctly
236    // ==========================================================================
237    #[test]
238    fn test_id_serialization() {
239        let id = RunId::new();
240
241        // Serialize to JSON
242        let json = serde_json::to_string(&id).unwrap();
243
244        // Should be a quoted ULID string
245        assert!(json.starts_with('"'));
246        assert!(json.ends_with('"'));
247
248        // Deserialize back
249        let parsed: RunId = serde_json::from_str(&json).unwrap();
250        assert_eq!(id, parsed);
251    }
252
253    #[test]
254    fn test_id_serialization_in_struct() {
255        #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
256        struct TestStruct {
257            id: RunId,
258            name: String,
259        }
260
261        let original = TestStruct {
262            id: RunId::new(),
263            name: "test".to_string(),
264        };
265
266        let json = serde_json::to_string(&original).unwrap();
267        let parsed: TestStruct = serde_json::from_str(&json).unwrap();
268        assert_eq!(original, parsed);
269    }
270
271    // ==========================================================================
272    // CORE-ID-010: Same ID strings are equal
273    // ==========================================================================
274    #[test]
275    fn test_id_equality() {
276        let id1 = RunId::new();
277        let id2 = RunId::parse(&id1.to_string()).unwrap();
278        assert_eq!(id1, id2);
279    }
280
281    #[test]
282    fn test_id_hash_consistency() {
283        use std::collections::hash_map::DefaultHasher;
284        use std::hash::{Hash, Hasher};
285
286        let id1 = RunId::new();
287        let id2 = RunId::parse(&id1.to_string()).unwrap();
288
289        let hash1 = {
290            let mut h = DefaultHasher::new();
291            id1.hash(&mut h);
292            h.finish()
293        };
294        let hash2 = {
295            let mut h = DefaultHasher::new();
296            id2.hash(&mut h);
297            h.finish()
298        };
299        assert_eq!(hash1, hash2);
300    }
301
302    // ==========================================================================
303    // Additional ID type tests
304    // ==========================================================================
305    #[test]
306    fn test_all_id_types_have_correct_prefix() {
307        assert!(TenantId::new().to_string().starts_with("ten_"));
308        assert!(WorkspaceId::new().to_string().starts_with("wks_"));
309        assert!(ProjectId::new().to_string().starts_with("prj_"));
310        assert!(AgentVersionId::new().to_string().starts_with("agv_"));
311        assert!(ToolId::new().to_string().starts_with("tol_"));
312        assert!(ToolVersionId::new().to_string().starts_with("tov_"));
313        assert!(PolicyRuleId::new().to_string().starts_with("pol_"));
314        assert!(PolicyDecisionId::new().to_string().starts_with("pdc_"));
315        assert!(ApprovalId::new().to_string().starts_with("apr_"));
316        assert!(AuditEventId::new().to_string().starts_with("aud_"));
317        assert!(ApiKeyId::new().to_string().starts_with("key_"));
318        assert!(ArtifactId::new().to_string().starts_with("art_"));
319    }
320
321    #[test]
322    fn test_id_from_ulid() {
323        let ulid = Ulid::new();
324        let id = RunId::from_ulid(ulid);
325        assert_eq!(id.inner(), ulid);
326    }
327
328    #[test]
329    fn test_id_default() {
330        let id: RunId = Default::default();
331        assert!(id.to_string().starts_with("run_"));
332    }
333
334    #[test]
335    fn test_id_from_str_trait() {
336        let id = RunId::new();
337        let s = id.to_string();
338        let parsed: RunId = s.parse().unwrap();
339        assert_eq!(id, parsed);
340    }
341
342    #[test]
343    fn test_id_display_and_to_prefixed_string() {
344        let id = RunId::new();
345        // Display should equal to_prefixed_string
346        assert_eq!(id.to_string(), id.to_prefixed_string());
347    }
348
349    #[test]
350    fn test_id_timestamp_extraction() {
351        let before = std::time::SystemTime::now()
352            .duration_since(std::time::UNIX_EPOCH)
353            .unwrap()
354            .as_millis() as u64;
355
356        let id = RunId::new();
357
358        let after = std::time::SystemTime::now()
359            .duration_since(std::time::UNIX_EPOCH)
360            .unwrap()
361            .as_millis() as u64;
362
363        assert!(id.timestamp() >= before);
364        assert!(id.timestamp() <= after);
365    }
366}