envelope_cli/models/
ids.rs

1//! Strongly-typed ID wrappers for all entity types
2//!
3//! Using newtype wrappers prevents accidentally mixing up IDs from different
4//! entity types at compile time.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::str::FromStr;
9use uuid::Uuid;
10
11/// Macro to generate ID newtype wrappers
12macro_rules! define_id {
13    ($name:ident, $display_prefix:literal) => {
14        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15        #[serde(transparent)]
16        pub struct $name(Uuid);
17
18        impl $name {
19            /// Create a new random ID
20            pub fn new() -> Self {
21                Self(Uuid::new_v4())
22            }
23
24            /// Create an ID from an existing UUID
25            pub fn from_uuid(uuid: Uuid) -> Self {
26                Self(uuid)
27            }
28
29            /// Get the underlying UUID
30            pub fn as_uuid(&self) -> &Uuid {
31                &self.0
32            }
33
34            /// Parse an ID from a string
35            pub fn parse(s: &str) -> Result<Self, uuid::Error> {
36                Ok(Self(Uuid::parse_str(s)?))
37            }
38        }
39
40        impl Default for $name {
41            fn default() -> Self {
42                Self::new()
43            }
44        }
45
46        impl fmt::Display for $name {
47            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48                write!(f, "{}{}", $display_prefix, &self.0.to_string()[..8])
49            }
50        }
51
52        impl From<Uuid> for $name {
53            fn from(uuid: Uuid) -> Self {
54                Self(uuid)
55            }
56        }
57
58        impl FromStr for $name {
59            type Err = uuid::Error;
60
61            fn from_str(s: &str) -> Result<Self, Self::Err> {
62                // Try to parse the full UUID
63                if let Ok(uuid) = Uuid::parse_str(s) {
64                    return Ok(Self(uuid));
65                }
66                // Try stripping common prefixes
67                let s = s.strip_prefix($display_prefix).unwrap_or(s);
68                Ok(Self(Uuid::parse_str(s)?))
69            }
70        }
71    };
72}
73
74define_id!(AccountId, "acc-");
75define_id!(TransactionId, "txn-");
76define_id!(CategoryId, "cat-");
77define_id!(CategoryGroupId, "grp-");
78define_id!(PayeeId, "pay-");
79define_id!(IncomeId, "inc-");
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn test_account_id_creation() {
87        let id = AccountId::new();
88        assert!(!id.as_uuid().is_nil());
89    }
90
91    #[test]
92    fn test_id_display() {
93        let id = AccountId::new();
94        let display = format!("{}", id);
95        assert!(display.starts_with("acc-"));
96        assert_eq!(display.len(), 12); // "acc-" + 8 chars
97    }
98
99    #[test]
100    fn test_id_equality() {
101        let id1 = AccountId::new();
102        let id2 = id1;
103        assert_eq!(id1, id2);
104
105        let id3 = AccountId::new();
106        assert_ne!(id1, id3);
107    }
108
109    #[test]
110    fn test_id_serialization() {
111        let id = AccountId::new();
112        let json = serde_json::to_string(&id).unwrap();
113        let deserialized: AccountId = serde_json::from_str(&json).unwrap();
114        assert_eq!(id, deserialized);
115    }
116
117    #[test]
118    fn test_id_parse() {
119        let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
120        let id = AccountId::parse(uuid_str).unwrap();
121        assert_eq!(id.as_uuid().to_string(), uuid_str);
122    }
123
124    #[test]
125    fn test_different_id_types_not_mixable() {
126        // This test documents that different ID types are distinct at compile time
127        let account_id = AccountId::new();
128        let transaction_id = TransactionId::new();
129
130        // These are different types - can't be compared directly
131        // This would fail to compile:
132        // assert_ne!(account_id, transaction_id);
133
134        // But we can compare their underlying UUIDs if needed
135        assert_ne!(account_id.as_uuid(), transaction_id.as_uuid());
136    }
137}