1use chrono::{DateTime, Duration, TimeZone, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4use std::sync::LazyLock;
5use uuid::{ClockSequence, ContextV1, Timestamp, Uuid};
6
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[serde(into = "String", try_from = "String")]
13#[derive(utoipa::ToSchema)]
14#[schema(value_type=String, example="d7ef132b5d7711eea8c8000c29c33806")]
15pub struct SpoolId(Uuid);
16
17impl std::fmt::Display for SpoolId {
18 fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
19 self.0.simple().fmt(fmt)
20 }
21}
22
23impl From<SpoolId> for String {
24 fn from(id: SpoolId) -> String {
25 id.to_string()
26 }
27}
28
29#[derive(thiserror::Error, Debug)]
31pub enum SpoolIdParseError {
32 #[error(transparent)]
33 Parse(#[from] uuid::Error),
34 #[error("spool id {0} is not a v1 UUID")]
35 NotV1(String),
36}
37
38impl TryFrom<String> for SpoolId {
39 type Error = SpoolIdParseError;
40
41 fn try_from(s: String) -> Result<Self, Self::Error> {
42 let uuid = Uuid::parse_str(&s)?;
43 Self::checked(uuid).ok_or(SpoolIdParseError::NotV1(s))
44 }
45}
46
47impl Default for SpoolId {
48 fn default() -> Self {
49 Self::new()
50 }
51}
52
53impl SpoolId {
54 pub fn new() -> Self {
55 Self(uuid_helper::now_v1())
58 }
59
60 pub fn compute_path(&self, in_dir: &Path) -> PathBuf {
61 let (a, b, c, [d, e, f, g, h, i, j, k]) = self.0.as_fields();
62 let [a1, a2, a3, a4] = a.to_be_bytes();
65 let name = format!(
66 "{a1:02x}/{a2:02x}/{a3:02x}/{a4:02x}/{b:04x}{c:04x}{d:02x}{e:02x}{f:02x}{g:02x}{h:02x}{i:02x}{j:02x}{k:02x}"
67 );
68 in_dir.join(name)
69 }
70
71 pub fn as_bytes(&self) -> &[u8; 16] {
72 self.0.as_bytes()
73 }
74
75 pub fn from_slice(s: &[u8]) -> Option<Self> {
76 let uuid = Uuid::from_slice(s).ok()?;
77 Self::checked(uuid)
78 }
79
80 pub fn from_ascii_bytes(s: &[u8]) -> Option<Self> {
81 let uuid = Uuid::try_parse_ascii(s).ok()?;
82 Self::checked(uuid)
83 }
84
85 pub fn from_path(mut path: &Path) -> Option<Self> {
86 let mut components = vec![];
87
88 for _ in 0..5 {
89 components.push(path.file_name()?.to_str()?);
90 path = path.parent()?;
91 }
92
93 components.reverse();
94 Self::checked(Uuid::parse_str(&components.join("")).ok()?)
95 }
96
97 fn checked(uuid: Uuid) -> Option<Self> {
99 (uuid.get_version_num() == 1).then_some(Self(uuid))
100 }
101
102 pub fn age(&self, now: DateTime<Utc>) -> Duration {
105 let created = self.created();
106 now - created
107 }
108
109 pub fn created(&self) -> DateTime<Utc> {
110 let (seconds, nanos) = self.0.get_timestamp().unwrap().to_unix();
111 Utc.timestamp_opt(seconds.try_into().unwrap(), nanos)
112 .unwrap()
113 }
114
115 pub fn derive_new_with_cloned_timestamp(&self) -> Self {
123 let ts = self
124 .0
125 .get_timestamp()
126 .expect("SpoolId is always v1, and v1 UUIDs always have a timestamp");
127
128 let candidate = Self(uuid_helper::new_v1(ts));
129
130 if candidate != *self {
131 return candidate;
132 }
133
134 static CONTEXT: LazyLock<ContextV1> = LazyLock::new(ContextV1::new_random);
144
145 let (mut seconds, mut subsec_nanos) = ts.to_unix();
146 loop {
147 let (counter, secs, nanos) = CONTEXT.generate_timestamp_sequence(seconds, subsec_nanos);
148 seconds = secs;
149 subsec_nanos = nanos;
150
151 let ts = Timestamp::from_unix_time(
152 seconds,
153 subsec_nanos,
154 counter.into(),
155 CONTEXT.usable_bits() as u8,
156 );
157
158 let candidate = Self(uuid_helper::new_v1(ts));
159
160 if candidate != *self {
161 return candidate;
162 }
163 }
164 }
165}
166
167#[cfg(test)]
168mod test {
169 use super::*;
170
171 #[test]
172 fn roundtrip_path() {
173 let id = SpoolId::new();
174 eprintln!("{id}");
175 let path = id.compute_path(Path::new("."));
176 let id2 = SpoolId::from_path(&path).unwrap();
177 assert_eq!(id, id2);
178 }
179
180 #[test]
181 fn roundtrip_bytes() {
182 let id = SpoolId::new();
183 eprintln!("{id}");
184 let bytes = id.as_bytes();
185 let id2 = SpoolId::from_slice(bytes.as_slice()).unwrap();
186 assert_eq!(id, id2);
187 }
188
189 #[test]
190 fn non_v1_id_is_rejected() {
191 let v4 = "6ba7b810-9dad-41d1-80b4-00c04fd430c8";
194 let err = SpoolId::try_from(v4.to_string()).unwrap_err();
195 assert!(matches!(err, SpoolIdParseError::NotV1(_)), "got: {err:#}");
196 k9::assert_equal!(
197 err.to_string(),
198 "spool id 6ba7b810-9dad-41d1-80b4-00c04fd430c8 is not a v1 UUID"
199 );
200 let err = SpoolId::try_from("not-a-uuid".to_string()).unwrap_err();
201 assert!(matches!(err, SpoolIdParseError::Parse(_)), "got: {err:#}");
202 let v4_uuid = uuid::Uuid::parse_str(v4).unwrap();
203 assert!(SpoolId::from_slice(v4_uuid.as_bytes()).is_none());
204 assert!(SpoolId::from_ascii_bytes(v4.as_bytes()).is_none());
205 let path = Path::new("6b/a7/b8/10/9dad41d180b400c04fd430c8");
208 assert!(SpoolId::from_path(path).is_none());
209 }
210
211 #[test]
212 fn cloned_timestamp_preserves_time() {
213 let id = SpoolId::new();
217 let derived = id.derive_new_with_cloned_timestamp();
218 assert_ne!(id, derived);
219
220 let delta = (derived.created() - id.created()).abs();
221 assert!(
222 delta < Duration::seconds(1),
223 "derived timestamp {} should be close to source {}, delta {delta:?}",
224 derived.created(),
225 id.created()
226 );
227 }
228}