1use core::{fmt, mem};
50
51use alloc::{collections::BTreeMap, string::String, vec::Vec};
52
53use thiserror::Error;
54
55use crate::{
56 coroutine::*,
57 item::{TMP, VdirItemKind},
58 path::VdirPath,
59};
60
61const UUID_LEN: usize = 16;
63
64#[derive(Clone, Debug, Error)]
66pub enum VdirItemStoreError {
67 #[error("Vdir item store failed: unexpected arg {0:?}")]
70 UnexpectedArg(Option<VdirReply>),
71}
72
73#[derive(Clone, Debug)]
75pub struct VdirItemStoreOutput {
76 pub id: String,
79 pub path: VdirPath,
81}
82
83#[derive(Clone, Debug, Default, Eq, PartialEq)]
85pub struct VdirItemStoreOptions {}
86
87#[derive(Debug)]
90pub struct VdirItemStore {
91 state: State,
92 #[allow(dead_code)]
93 opts: VdirItemStoreOptions,
94}
95
96impl VdirItemStore {
97 pub fn new(
103 collection: impl Into<VdirPath>,
104 id: Option<String>,
105 kind: VdirItemKind,
106 contents: Vec<u8>,
107 opts: VdirItemStoreOptions,
108 ) -> Self {
109 Self {
110 opts,
111 state: State::Start {
112 collection: collection.into(),
113 id,
114 kind,
115 contents,
116 },
117 }
118 }
119}
120
121impl VdirCoroutine for VdirItemStore {
122 type Yield = VdirYield;
123 type Return = Result<VdirItemStoreOutput, VdirItemStoreError>;
124
125 fn resume(&mut self, arg: Option<VdirReply>) -> VdirCoroutineState<Self::Yield, Self::Return> {
126 match (&mut self.state, arg) {
127 (
128 State::Start {
129 collection,
130 id,
131 kind,
132 contents,
133 },
134 None,
135 ) => {
136 let collection = mem::take(collection);
137 let kind = *kind;
138 let contents = mem::take(contents);
139
140 match id.take() {
141 Some(id) => {
142 let (tmp_path, final_path) = build_paths(&collection, &id, kind);
143 let files = BTreeMap::from_iter([(tmp_path.clone(), contents)]);
144 self.state = State::AwaitFileCreate {
145 id,
146 tmp_path,
147 final_path,
148 };
149 VdirCoroutineState::Yielded(VdirYield::WantsFileCreate(files))
150 }
151 None => {
152 self.state = State::AwaitRandom {
153 collection,
154 kind,
155 contents,
156 };
157 VdirCoroutineState::Yielded(VdirYield::WantsRandom { len: UUID_LEN })
158 }
159 }
160 }
161 (
162 State::AwaitRandom {
163 collection,
164 kind,
165 contents,
166 },
167 Some(VdirReply::Random(bytes)),
168 ) => {
169 let collection = mem::take(collection);
170 let kind = *kind;
171 let contents = mem::take(contents);
172
173 let id = uuid_v4(&bytes);
174 let (tmp_path, final_path) = build_paths(&collection, &id, kind);
175 let files = BTreeMap::from_iter([(tmp_path.clone(), contents)]);
176 self.state = State::AwaitFileCreate {
177 id,
178 tmp_path,
179 final_path,
180 };
181 VdirCoroutineState::Yielded(VdirYield::WantsFileCreate(files))
182 }
183 (
184 State::AwaitFileCreate {
185 id,
186 tmp_path,
187 final_path,
188 },
189 Some(VdirReply::FileCreate),
190 ) => {
191 let id = mem::take(id);
192 let tmp_path = mem::take(tmp_path);
193 let final_path = mem::take(final_path);
194
195 let pairs = vec![(tmp_path, final_path.clone())];
196 self.state = State::AwaitRename { id, final_path };
197 VdirCoroutineState::Yielded(VdirYield::WantsRename(pairs))
198 }
199 (State::AwaitRename { id, final_path }, Some(VdirReply::Rename)) => {
200 let out = VdirItemStoreOutput {
201 id: mem::take(id),
202 path: mem::take(final_path),
203 };
204 VdirCoroutineState::Complete(Ok(out))
205 }
206 (_, arg) => {
207 let err = VdirItemStoreError::UnexpectedArg(arg);
208 VdirCoroutineState::Complete(Err(err))
209 }
210 }
211 }
212}
213
214#[derive(Debug)]
215enum State {
216 Start {
217 collection: VdirPath,
218 id: Option<String>,
219 kind: VdirItemKind,
220 contents: Vec<u8>,
221 },
222 AwaitRandom {
223 collection: VdirPath,
224 kind: VdirItemKind,
225 contents: Vec<u8>,
226 },
227 AwaitFileCreate {
228 id: String,
229 tmp_path: VdirPath,
230 final_path: VdirPath,
231 },
232 AwaitRename {
233 id: String,
234 final_path: VdirPath,
235 },
236}
237
238impl fmt::Display for State {
239 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240 match self {
241 Self::Start { .. } => f.write_str("start"),
242 Self::AwaitRandom { .. } => f.write_str("await random reply"),
243 Self::AwaitFileCreate { .. } => f.write_str("await file create reply"),
244 Self::AwaitRename { .. } => f.write_str("await rename reply"),
245 }
246 }
247}
248
249fn build_paths(collection: &VdirPath, id: &str, kind: VdirItemKind) -> (VdirPath, VdirPath) {
252 let ext = kind.extension();
253 let final_path = collection.join(&format!("{id}.{ext}"));
254 let tmp_path = collection.join(&format!("{id}.{ext}.{TMP}"));
255 (tmp_path, final_path)
256}
257
258fn uuid_v4(bytes: &[u8]) -> String {
261 let mut bytes: [u8; UUID_LEN] = bytes[..UUID_LEN].try_into().unwrap_or([0u8; UUID_LEN]);
262 bytes[6] = (bytes[6] & 0x0f) | 0x40;
263 bytes[8] = (bytes[8] & 0x3f) | 0x80;
264
265 let mut id = String::with_capacity(36);
266 let groups: [&[u8]; 5] = [
267 &bytes[0..4],
268 &bytes[4..6],
269 &bytes[6..8],
270 &bytes[8..10],
271 &bytes[10..16],
272 ];
273
274 for (i, group) in groups.iter().enumerate() {
275 if i > 0 {
276 id.push('-');
277 }
278 for byte in *group {
279 id.push(hex_nibble(byte >> 4));
280 id.push(hex_nibble(byte & 0x0f));
281 }
282 }
283
284 id
285}
286
287fn hex_nibble(n: u8) -> char {
289 match n {
290 0..=9 => (b'0' + n) as char,
291 10..=15 => (b'a' + (n - 10)) as char,
292 _ => '0',
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn reuses_supplied_id() {
302 let mut cor = VdirItemStore::new(
303 "root/contacts",
304 Some("alice".into()),
305 VdirItemKind::Vcard,
306 b"BEGIN:VCARD".to_vec(),
307 VdirItemStoreOptions::default(),
308 );
309
310 let files = match cor.resume(None) {
311 VdirCoroutineState::Yielded(VdirYield::WantsFileCreate(files)) => files,
312 state => panic!("expected WantsFileCreate, got {state:?}"),
313 };
314 let tmp = VdirPath::from("root/contacts/alice.vcf.tmp");
315 assert!(files.contains_key(&tmp));
316
317 let pairs = match cor.resume(Some(VdirReply::FileCreate)) {
318 VdirCoroutineState::Yielded(VdirYield::WantsRename(pairs)) => pairs,
319 state => panic!("expected WantsRename, got {state:?}"),
320 };
321 assert_eq!(
322 pairs,
323 vec![(tmp, VdirPath::from("root/contacts/alice.vcf"))]
324 );
325
326 let out = match cor.resume(Some(VdirReply::Rename)) {
327 VdirCoroutineState::Complete(Ok(out)) => out,
328 state => panic!("expected Complete(Ok), got {state:?}"),
329 };
330 assert_eq!(out.id, "alice");
331 assert_eq!(out.path, VdirPath::from("root/contacts/alice.vcf"));
332 }
333
334 #[test]
335 fn generates_uuid_when_id_missing() {
336 let mut cor = VdirItemStore::new(
337 "root/contacts",
338 None,
339 VdirItemKind::Ical,
340 b"BEGIN:VCAL".to_vec(),
341 VdirItemStoreOptions::default(),
342 );
343
344 match cor.resume(None) {
345 VdirCoroutineState::Yielded(VdirYield::WantsRandom { len }) => {
346 assert_eq!(len, UUID_LEN)
347 }
348 state => panic!("expected WantsRandom, got {state:?}"),
349 }
350
351 let bytes = vec![0xabu8; UUID_LEN];
352 let files = match cor.resume(Some(VdirReply::Random(bytes))) {
353 VdirCoroutineState::Yielded(VdirYield::WantsFileCreate(files)) => files,
354 state => panic!("expected WantsFileCreate, got {state:?}"),
355 };
356
357 let tmp = files.keys().next().unwrap();
358 assert!(tmp.as_str().ends_with(".ics.tmp"));
359 let id = tmp.file_name().unwrap();
360 assert_eq!(id.chars().nth(14), Some('4'));
363 assert!(matches!(id.chars().nth(19), Some('8'..='b')));
364 }
365
366 #[test]
367 fn unexpected_reply_returns_error() {
368 let mut cor = VdirItemStore::new(
369 "root/contacts",
370 Some("alice".into()),
371 VdirItemKind::Vcard,
372 b"x".to_vec(),
373 VdirItemStoreOptions::default(),
374 );
375 let _ = cor.resume(None);
376
377 let err = match cor.resume(Some(VdirReply::DirCreate)) {
378 VdirCoroutineState::Complete(Err(err)) => err,
379 state => panic!("expected Complete(Err), got {state:?}"),
380 };
381 assert!(matches!(err, VdirItemStoreError::UnexpectedArg(_)));
382 }
383}