don't
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(atproto): add support for parsing and handling TIDs

Signed-off-by: tjh <did:plc:65gha4t3avpfpzmvpbwovss7>

+375
+1
Cargo.lock
··· 137 137 "serde", 138 138 "serde_json", 139 139 "thiserror", 140 + "time", 140 141 ] 141 142 142 143 [[package]]
+1
crates/atproto/Cargo.toml
··· 10 10 [dependencies] 11 11 serde = { workspace = true, optional = true } 12 12 thiserror.workspace = true 13 + time = { workspace = true, optional = true } 13 14 14 15 [dev-dependencies] 15 16 serde_json.workspace = true
+1
crates/atproto/src/lib.rs
··· 5 5 pub mod did; 6 6 pub mod handle; 7 7 pub mod nsid; 8 + pub mod tid; 8 9 9 10 pub use did::Did; 10 11 pub use handle::Handle;
+372
crates/atproto/src/tid.rs
··· 1 + use core::fmt; 2 + 3 + static LOOKUP: [u8; 32] = [ 4 + b'2', b'3', b'4', b'5', b'6', b'7', b'a', b'b', b'c', b'd', b'e', b'f', b'g', b'h', b'i', b'j', 5 + b'k', b'l', b'm', b'n', b'o', b'p', b'q', b'r', b's', b't', b'u', b'v', b'w', b'x', b'y', b'z', 6 + ]; 7 + 8 + const MASK_TIMESTAMP: u64 = 0x001f_ffff_ffff_ffff; 9 + const MASK_CLOCK_ID: u64 = 0x03ff; 10 + const BITS_CLOCK_ID: usize = 10; 11 + 12 + /// Timestamp Identifier 13 + /// 14 + /// See: <https://atproto.com/specs/tid> 15 + #[derive(Default, Hash, PartialEq, Eq, PartialOrd, Ord)] 16 + pub struct Tid(u64); 17 + 18 + impl Tid { 19 + const MAX_CLOCK_ID: u16 = MASK_CLOCK_ID as u16; 20 + const MIN_CLOCK_ID: u16 = u16::MIN; 21 + 22 + const MAX_TIMESTAMP: u64 = MASK_TIMESTAMP; 23 + const MIN_TIMESTAMP: u64 = u64::MIN; 24 + 25 + /// Maximum TID value. 26 + /// 27 + /// ```rust 28 + /// # use atproto::tid::Tid; 29 + /// let tid = Tid::MAX; 30 + /// 31 + /// assert_eq!(tid.micros(), 0b11111111111111111111111111111111111111111111111111111); 32 + /// assert_eq!(tid.clock_id(), 0b1111111111); 33 + /// assert_eq!(tid.to_string(), "bzzzzzzzzzzzz"); 34 + // ``` 35 + // 36 + pub const MAX: Self = Self::new(Self::MAX_TIMESTAMP, Self::MAX_CLOCK_ID); 37 + 38 + /// Minimum TID value. 39 + /// 40 + /// ```rust 41 + /// # use atproto::tid::Tid; 42 + /// let tid = Tid::MIN; 43 + /// 44 + /// assert_eq!(tid.micros(), 0); 45 + /// assert_eq!(tid.clock_id(), 0); 46 + /// assert_eq!(tid.to_string(), "2222222222222"); 47 + // ``` 48 + // 49 + pub const MIN: Self = Self::new(Self::MIN_TIMESTAMP, Self::MIN_CLOCK_ID); 50 + 51 + /// Set the microsecond timestamp for the TID. 52 + /// 53 + /// # Panics 54 + /// 55 + /// Panics if `micros` is greater than [`Self::MAX_TIMESTAMP`]. 56 + /// 57 + pub const fn set_micros(&mut self, micros: u64) { 58 + if micros > Self::MAX_TIMESTAMP { 59 + panic!("TID timestamp must be in the range 0..=9007199254740991 microseconds"); 60 + } 61 + self.0 |= (micros & MASK_TIMESTAMP) << BITS_CLOCK_ID; 62 + } 63 + 64 + /// Set the clock ID for the TID. 65 + /// 66 + /// # Panics 67 + /// 68 + /// Panics if `clock_id` is greater than [`Self::MAX_CLOCK_ID`]. 69 + /// 70 + pub const fn set_clock_id(&mut self, clock_id: u16) { 71 + if clock_id > Self::MAX_CLOCK_ID { 72 + panic!("TID clock ID must be in the range 0..=1023"); 73 + } 74 + self.0 |= (clock_id as u64) & MASK_CLOCK_ID 75 + } 76 + 77 + /// Create a new TID with the specified timestamp and clock ID. 78 + /// 79 + /// # Panics 80 + /// 81 + /// Panics under the following conditions: 82 + /// 83 + /// * `micros` exceeds [`Self::MAX_TIMESTAMP`]. 84 + /// * `clock_id` exceeds [`Self::MAX_CLOCK_ID`] 85 + /// 86 + pub const fn new(micros: u64, clock_id: u16) -> Self { 87 + let mut new = Tid(0); 88 + new.set_micros(micros); 89 + new.set_clock_id(clock_id); 90 + new 91 + } 92 + 93 + /// Create a TID with the specified timestamp and clock ID. 94 + /// 95 + /// # Example 96 + /// 97 + /// ```rust 98 + /// # use atproto::tid::Tid; 99 + /// let tid = Tid::from_secs(1_000_000, 0); 100 + /// assert_eq!(tid.micros(), 1_000_000_000_000); 101 + /// assert_eq!(tid.clock_id(), 0); 102 + /// ``` 103 + /// 104 + /// # Panics 105 + /// 106 + /// Panics under the following conditions: 107 + /// 108 + /// * `seconds` exceeds [`Self::MAX_TIMESTAMP`] when converted to microseconds. 109 + /// * `clock_id` exceeds [`Self::MAX_CLOCK_ID`] 110 + /// 111 + pub const fn from_secs(seconds: u64, clock_id: u16) -> Self { 112 + Self::new(seconds * 1_000_000, clock_id) 113 + } 114 + 115 + /// Parse a TID from a BASE32-sortable encoded string. 116 + /// 117 + /// # Examples 118 + /// 119 + /// ```rust 120 + /// # use atproto::tid::Tid; 121 + /// let t = Tid::parse("3m6g5swroas22").unwrap(); 122 + /// assert_eq!(t.micros(), 1764033024479448); 123 + /// assert_eq!(t.clock_id(), 0); 124 + /// 125 + /// let t = Tid::parse("2222222222222").unwrap(); 126 + /// assert_eq!(t.micros(), 0); 127 + /// assert_eq!(t.clock_id(), 0); 128 + /// 129 + /// let t = Tid::parse("bzzzzzzzzzzzz").unwrap(); 130 + /// assert_eq!(t.micros(), 9007199254740991); 131 + /// assert_eq!(t.clock_id(), 1023); 132 + /// ``` 133 + /// 134 + pub fn parse(s: &str) -> Result<Self, Error> { 135 + parse(s) 136 + } 137 + 138 + /// Get the microsecond offset from UNIX epoch. 139 + /// 140 + /// ```rust 141 + /// # use atproto::tid::Tid; 142 + /// assert_eq!(Tid::MIN.micros(), 0); 143 + /// assert_eq!(Tid::MAX.micros(), 9007199254740991); 144 + /// ``` 145 + /// 146 + pub fn micros(&self) -> u64 { 147 + (self.0 >> BITS_CLOCK_ID) & Self::MAX_TIMESTAMP 148 + } 149 + 150 + /// Get the clock ID for the TID. 151 + /// 152 + /// ```rust 153 + /// # use atproto::tid::Tid; 154 + /// assert_eq!(Tid::MIN.clock_id(), 0); 155 + /// assert_eq!(Tid::MAX.clock_id(), 1023); 156 + /// ``` 157 + /// 158 + pub fn clock_id(&self) -> u16 { 159 + // CONVERSION: Clock ID mask ensures this will always produce an equivalent u16. 160 + (self.0 & MASK_CLOCK_ID) as _ 161 + } 162 + } 163 + 164 + #[cfg(feature = "time")] 165 + impl Tid { 166 + /// Set the microsecond timestamp for the TID. 167 + /// 168 + /// # Example 169 + /// 170 + /// ```rust 171 + /// # use atproto::tid::Tid; 172 + /// # use time::OffsetDateTime; 173 + /// const DT: OffsetDateTime = time::macros::datetime!(2025-11-25 10:28:43.234 UTC); 174 + /// 175 + /// let mut t = Tid::default(); 176 + /// t.set_datetime(DT); 177 + /// assert_eq!(t.as_datetime(), DT); 178 + /// assert_eq!(t.clock_id(), 0); 179 + /// 180 + /// let mut t = Tid::default(); 181 + /// t.set_datetime(OffsetDateTime::UNIX_EPOCH); 182 + /// assert_eq!(t.as_datetime(), OffsetDateTime::UNIX_EPOCH); 183 + /// assert_eq!(t.clock_id(), 0); 184 + /// ``` 185 + /// 186 + /// # Panics 187 + /// 188 + /// Panics if `dt` is later than 2255-06-05 23:47:34.740991 +00:00:00. 189 + /// 190 + pub fn set_datetime(&mut self, dt: time::OffsetDateTime) { 191 + let micros = dt.unix_timestamp_nanos() / 1000; 192 + self.set_micros(micros.try_into().unwrap()) 193 + } 194 + 195 + /// Create a TID from the specified [`time::OffsetDateTime`] and clock ID. 196 + /// 197 + /// # Panics 198 + /// 199 + /// Panics if `dt` is later than 2255-06-05 23:47:34.740991 +00:00:00. 200 + /// 201 + pub fn from_datetime(dt: time::OffsetDateTime, clock_id: u16) -> Self { 202 + let micros = dt.unix_timestamp_nanos() / 1000; 203 + Self::new(micros.try_into().unwrap(), clock_id) 204 + } 205 + 206 + /// Convert the TID to a [`time::OffsetDateTime`] 207 + pub fn as_datetime(&self) -> time::OffsetDateTime { 208 + time::OffsetDateTime::from_unix_timestamp_nanos(self.nanos()) 209 + .expect("2^53 microseconds is less than MAX for OffsetDateTime") 210 + } 211 + 212 + fn nanos(&self) -> i128 { 213 + i128::from(self.micros()) * 1000 214 + } 215 + } 216 + 217 + #[cfg(not(feature = "time"))] 218 + impl fmt::Debug for Tid { 219 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 220 + f.debug_struct("Tid") 221 + .field("ts", &self.micros()) 222 + .field("clk", &self.clock_id()) 223 + .finish() 224 + } 225 + } 226 + 227 + #[cfg(feature = "time")] 228 + impl fmt::Debug for Tid { 229 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 230 + f.debug_struct("Tid") 231 + .field("ts", &self.as_datetime()) 232 + .field("clk", &self.clock_id()) 233 + .finish() 234 + } 235 + } 236 + 237 + impl fmt::Display for Tid { 238 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 239 + use fmt::Write; 240 + 241 + for i in (0..13).rev() { 242 + let offset = i * 5; 243 + let masked = self.0 & (0x1f << offset); 244 + let index = (masked >> offset) & 0xff; 245 + let c = LOOKUP[index as usize] as char; 246 + f.write_char(c)?; 247 + } 248 + Ok(()) 249 + } 250 + } 251 + 252 + impl std::str::FromStr for Tid { 253 + type Err = Error; 254 + fn from_str(s: &str) -> Result<Self, Self::Err> { 255 + Self::parse(s) 256 + } 257 + } 258 + 259 + #[derive(Debug, thiserror::Error)] 260 + pub enum Error { 261 + #[error("Invalid length for TID")] 262 + Length, 263 + #[error("Invalid prefix '{found}' for TID")] 264 + Prefix { found: char }, 265 + #[error("Invalid character {found} at position {position}")] 266 + Encoding { position: usize, found: char }, 267 + #[error("Most-significant bit set")] 268 + HighBitSet, 269 + } 270 + 271 + pub fn parse(s: &str) -> Result<Tid, Error> { 272 + let bytes = s.as_bytes(); 273 + if bytes.len() != 13 { 274 + return Err(Error::Length); 275 + } 276 + 277 + let mut value = 0; 278 + for (idx, character) in bytes.iter().enumerate() { 279 + let pos = 0x1f 280 + & LOOKUP 281 + .iter() 282 + .position(|v| v == character) 283 + .ok_or(Error::Encoding { 284 + position: idx, 285 + found: *character as char, 286 + })? as u64; 287 + 288 + if idx == 0 && pos >= 16 { 289 + return Err(Error::Prefix { 290 + found: *character as char, 291 + }); 292 + } 293 + 294 + value = (value << 5) | pos; 295 + } 296 + 297 + if value & (1 << 63) != 0 { 298 + return Err(Error::HighBitSet); 299 + } 300 + 301 + Ok(Tid(value)) 302 + } 303 + 304 + #[cfg(test)] 305 + mod tests { 306 + use std::str::FromStr as _; 307 + 308 + use super::Tid; 309 + 310 + #[test] 311 + fn valid_tids() { 312 + for src in [ 313 + "3m6cal3nqfk2q", 314 + "3m3zm7eurxk26", 315 + "2222222222222", 316 + "a222222222222", 317 + "bzzzzzzzzzzzz", 318 + ] { 319 + let _: Tid = src.parse().unwrap(); 320 + } 321 + } 322 + 323 + #[test] 324 + fn invalid_tids() { 325 + for tid in [ 326 + "invalid-chars!", 327 + "invalid-ILOU0", 328 + "xyz1234567890", 329 + "z234567abcdef", 330 + "3jzfcijpj2z21", 331 + "0000000000000", 332 + "zzzzzzzzzzzzz", 333 + "kjzfcijpj2z2a", 334 + ] { 335 + Tid::from_str(tid).expect_err(&format!("{tid} Should be invalid")); 336 + } 337 + } 338 + 339 + #[test] 340 + fn max() { 341 + let tid = Tid::MAX; 342 + assert_eq!(tid.micros(), Tid::MAX_TIMESTAMP); 343 + assert_eq!(tid.clock_id(), Tid::MAX_CLOCK_ID); 344 + 345 + // Ensure this cannot panic. 346 + #[cfg(feature = "time")] 347 + let _ = tid.as_datetime(); 348 + } 349 + 350 + #[test] 351 + fn tid_parts() { 352 + let value = "3kao2cl6lyj2p"; 353 + let tid: Tid = value.parse().unwrap(); 354 + 355 + assert_eq!(tid.clock_id(), 21); 356 + assert_eq!(tid.micros(), 1696134411208655); 357 + 358 + #[cfg(feature = "time")] 359 + assert_eq!( 360 + tid.as_datetime(), 361 + time::macros::datetime!(2023-10-01 04:26:51.208655 UTC) 362 + ); 363 + } 364 + 365 + #[test] 366 + #[cfg(feature = "time")] 367 + fn make() { 368 + let dt = time::macros::datetime!(2025-11-23 17:12:30.5 UTC); 369 + let tid = Tid::from_datetime(dt, 0); 370 + assert_eq!(tid.to_string(), "3m6csnhoj7222"); 371 + } 372 + }