Skip to main content

atrg_repo/
tid.rs

1//! TID (Timestamp Identifier) generation and parsing.
2//!
3//! TIDs are base32-sortable timestamps used as record keys in the AT Protocol.
4//! Format: 13 characters from the charset `234567abcdefghijklmnopqrstuvwxyz`.
5//! Encodes microseconds since Unix epoch in the upper bits, random clock ID in the lower 10 bits.
6
7use crate::error::RepoError;
8
9/// The base32-sortable charset used for TID encoding.
10const TID_CHARSET: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz";
11
12/// Expected length of a TID string.
13const TID_LEN: usize = 13;
14
15/// A TID (Timestamp Identifier) — base32-sortable, used as record keys.
16///
17/// TIDs encode a microsecond timestamp and a random clock ID into a
18/// 13-character base32-sortable string. They are lexicographically ordered
19/// by creation time, making them ideal for record keys in AT Protocol
20/// repositories.
21#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub struct Tid(String);
23
24impl Tid {
25    /// Generate a new TID from the current timestamp and a random clock ID.
26    ///
27    /// Uses `std::time::SystemTime` for the timestamp and `rand` for the
28    /// 10-bit clock ID.
29    pub fn now() -> Self {
30        use rand::Rng;
31        use std::time::{SystemTime, UNIX_EPOCH};
32
33        let micros = SystemTime::now()
34            .duration_since(UNIX_EPOCH)
35            .expect("system clock before Unix epoch")
36            .as_micros() as u64;
37
38        let clock_id: u64 = rand::thread_rng().gen_range(0..1024);
39
40        // TID is a 64-bit value: upper 54 bits are microseconds, lower 10 bits are clock ID.
41        let tid_value = (micros << 10) | clock_id;
42
43        Self(encode_base32_sortable(tid_value))
44    }
45
46    /// Parse a TID string, validating format.
47    ///
48    /// A valid TID is exactly 13 characters long, using only characters from
49    /// the base32-sortable charset (`234567abcdefghijklmnopqrstuvwxyz`).
50    ///
51    /// # Errors
52    ///
53    /// Returns [`RepoError::InvalidTid`] if the string is not a valid TID.
54    pub fn parse(s: &str) -> Result<Self, RepoError> {
55        if s.len() != TID_LEN {
56            return Err(RepoError::InvalidTid(format!(
57                "TID must be {TID_LEN} characters, got {}",
58                s.len()
59            )));
60        }
61
62        for (i, ch) in s.chars().enumerate() {
63            if !TID_CHARSET.contains(&(ch as u8)) {
64                return Err(RepoError::InvalidTid(format!(
65                    "invalid character '{}' at position {} in TID",
66                    ch, i
67                )));
68            }
69        }
70
71        Ok(Self(s.to_string()))
72    }
73
74    /// Return the inner string representation.
75    pub fn as_str(&self) -> &str {
76        &self.0
77    }
78
79    /// Consume the TID and return the inner string.
80    pub fn into_string(self) -> String {
81        self.0
82    }
83}
84
85impl std::fmt::Display for Tid {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.write_str(&self.0)
88    }
89}
90
91impl serde::Serialize for Tid {
92    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
93        serializer.serialize_str(&self.0)
94    }
95}
96
97impl<'de> serde::Deserialize<'de> for Tid {
98    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
99        let s = String::deserialize(deserializer)?;
100        Tid::parse(&s).map_err(serde::de::Error::custom)
101    }
102}
103
104/// Encode a 64-bit value as a 13-character base32-sortable string (big-endian).
105fn encode_base32_sortable(mut value: u64) -> String {
106    let mut buf = [b'2'; TID_LEN]; // '2' is the zero character in our charset
107
108    for i in (0..TID_LEN).rev() {
109        let idx = (value & 0x1F) as usize;
110        buf[i] = TID_CHARSET[idx];
111        value >>= 5;
112    }
113
114    // SAFETY: all bytes come from TID_CHARSET which is valid ASCII/UTF-8.
115    String::from_utf8(buf.to_vec()).expect("TID charset is valid UTF-8")
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn now_produces_13_chars() {
124        let tid = Tid::now();
125        assert_eq!(tid.as_str().len(), 13);
126    }
127
128    #[test]
129    fn now_uses_valid_charset() {
130        let tid = Tid::now();
131        for ch in tid.as_str().chars() {
132            assert!(
133                TID_CHARSET.contains(&(ch as u8)),
134                "unexpected character '{ch}' in TID"
135            );
136        }
137    }
138
139    #[test]
140    fn now_is_monotonically_increasing() {
141        let a = Tid::now();
142        // Small sleep to ensure different timestamp.
143        std::thread::sleep(std::time::Duration::from_millis(2));
144        let b = Tid::now();
145        assert!(b.as_str() >= a.as_str(), "TIDs should be sortable by time");
146    }
147
148    #[test]
149    fn parse_valid_tid() {
150        // 13 chars from the valid charset
151        let tid = Tid::parse("2222222222222").unwrap();
152        assert_eq!(tid.as_str(), "2222222222222");
153    }
154
155    #[test]
156    fn parse_rejects_too_short() {
157        let result = Tid::parse("222222");
158        assert!(result.is_err());
159        match result.unwrap_err() {
160            RepoError::InvalidTid(msg) => assert!(msg.contains("13 characters")),
161            other => panic!("expected InvalidTid, got: {other}"),
162        }
163    }
164
165    #[test]
166    fn parse_rejects_too_long() {
167        let result = Tid::parse("22222222222222");
168        assert!(result.is_err());
169    }
170
171    #[test]
172    fn parse_rejects_invalid_chars() {
173        let result = Tid::parse("222222222222A");
174        assert!(result.is_err());
175        match result.unwrap_err() {
176            RepoError::InvalidTid(msg) => assert!(msg.contains("invalid character")),
177            other => panic!("expected InvalidTid, got: {other}"),
178        }
179    }
180
181    #[test]
182    fn parse_rejects_uppercase() {
183        let result = Tid::parse("222222222222Z");
184        assert!(result.is_err());
185    }
186
187    #[test]
188    fn parse_rejects_0_and_1() {
189        // '0' and '1' are not in the base32-sortable charset
190        assert!(Tid::parse("0222222222222").is_err());
191        assert!(Tid::parse("1222222222222").is_err());
192    }
193
194    #[test]
195    fn display_matches_as_str() {
196        let tid = Tid::now();
197        assert_eq!(format!("{tid}"), tid.as_str());
198    }
199
200    #[test]
201    fn roundtrip_through_generated() {
202        let tid = Tid::now();
203        let parsed = Tid::parse(tid.as_str()).unwrap();
204        assert_eq!(tid, parsed);
205    }
206
207    #[test]
208    fn encode_zero_value() {
209        let encoded = encode_base32_sortable(0);
210        assert_eq!(encoded, "2222222222222");
211    }
212
213    #[test]
214    fn test_into_string() {
215        let tid = Tid::now();
216        let s = tid.clone().into_string();
217        assert_eq!(s.len(), 13);
218        assert_eq!(s, tid.as_str());
219    }
220
221    #[test]
222    fn test_serde_roundtrip() {
223        let tid = Tid::now();
224        let json = serde_json::to_string(&tid).unwrap();
225        let deserialized: Tid = serde_json::from_str(&json).unwrap();
226        assert_eq!(deserialized.as_str(), tid.as_str());
227    }
228
229    #[test]
230    fn test_deserialize_invalid_tid_wrong_length() {
231        let result = serde_json::from_str::<Tid>(r#""abc""#);
232        assert!(result.is_err());
233    }
234
235    #[test]
236    fn test_deserialize_invalid_tid_bad_chars() {
237        // 13 chars but with invalid uppercase
238        let result = serde_json::from_str::<Tid>(r#""AAAAAAAAAAAAA""#);
239        assert!(result.is_err());
240    }
241
242    #[test]
243    fn test_deserialize_valid_tid() {
244        let tid: Tid = serde_json::from_str(r#""2222222222222""#).unwrap();
245        assert_eq!(tid.as_str(), "2222222222222");
246    }
247}