Skip to main content

io_vdir/collection/
update.rs

1//! I/O-free coroutine rewriting Vdir collection metadata atomically.
2//!
3//! Writes each non-empty metadata field (`display_name`,
4//! `description`, `color`) to a temporary file, then renames it onto
5//! the canonical name.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! use std::fs;
11//!
12//! use io_vdir::{
13//!     collection::{VdirCollection, update::*},
14//!     coroutine::*,
15//! };
16//!
17//! let mut collection = VdirCollection::from_path("/tmp/vdir/contacts");
18//! collection.display_name = Some("Contacts".into());
19//! let opts = VdirCollectionUpdateOptions::default();
20//! let mut coroutine = VdirCollectionUpdate::new(collection, opts);
21//! let mut arg = None;
22//!
23//! loop {
24//!     match coroutine.resume(arg.take()) {
25//!         VdirCoroutineState::Yielded(VdirYield::WantsFileCreate(files)) => {
26//!             for (path, bytes) in files {
27//!                 fs::write(path.as_str(), bytes).unwrap();
28//!             }
29//!             arg = Some(VdirReply::FileCreate);
30//!         }
31//!         VdirCoroutineState::Yielded(VdirYield::WantsRename(pairs)) => {
32//!             for (from, to) in pairs {
33//!                 fs::rename(from.as_str(), to.as_str()).unwrap();
34//!             }
35//!             arg = Some(VdirReply::Rename);
36//!         }
37//!         VdirCoroutineState::Complete(Ok(())) => break,
38//!         VdirCoroutineState::Complete(Err(err)) => panic!("{err}"),
39//!         state => panic!("unexpected state {state:?}"),
40//!     }
41//! }
42//! ```
43
44use core::{fmt, mem};
45
46use alloc::{collections::BTreeMap, vec::Vec};
47
48use thiserror::Error;
49
50use crate::{
51    collection::{COLOR, DESCRIPTION, DISPLAYNAME, VdirCollection},
52    coroutine::*,
53    item::TMP,
54    path::VdirPath,
55};
56
57/// Failure causes during a [`VdirCollectionUpdate`] step.
58#[derive(Clone, Debug, Error)]
59pub enum VdirCollectionUpdateError {
60    /// The driver fed back a reply that does not match the pending
61    /// request.
62    #[error("Vdir collection update failed: unexpected arg {0:?}")]
63    UnexpectedArg(Option<VdirReply>),
64}
65
66/// Options for [`VdirCollectionUpdate::new`].
67#[derive(Clone, Debug, Default, Eq, PartialEq)]
68pub struct VdirCollectionUpdateOptions {}
69
70/// Rewrites Vdir collection metadata atomically via temp files.
71#[derive(Debug)]
72pub struct VdirCollectionUpdate {
73    state: State,
74    #[allow(dead_code)]
75    opts: VdirCollectionUpdateOptions,
76}
77
78impl VdirCollectionUpdate {
79    /// Creates a new coroutine that will write the metadata of
80    /// `collection` to disk.
81    pub fn new(collection: VdirCollection, opts: VdirCollectionUpdateOptions) -> Self {
82        Self {
83            opts,
84            state: State::Start(collection),
85        }
86    }
87}
88
89impl VdirCoroutine for VdirCollectionUpdate {
90    type Yield = VdirYield;
91    type Return = Result<(), VdirCollectionUpdateError>;
92
93    fn resume(&mut self, arg: Option<VdirReply>) -> VdirCoroutineState<Self::Yield, Self::Return> {
94        match (&mut self.state, arg) {
95            (State::Start(collection), None) => {
96                let collection = mem::take(collection);
97                let mut files = BTreeMap::new();
98                let mut renames = Vec::new();
99
100                if let Some(name) = collection.display_name.filter(|s| !s.is_empty()) {
101                    let final_path = collection.path.join(DISPLAYNAME);
102                    let tmp_path = final_path.with_file_name(&format!("{DISPLAYNAME}.{TMP}"));
103                    files.insert(tmp_path.clone(), name.into_bytes());
104                    renames.push((tmp_path, final_path));
105                }
106
107                if let Some(desc) = collection.description.filter(|s| !s.is_empty()) {
108                    let final_path = collection.path.join(DESCRIPTION);
109                    let tmp_path = final_path.with_file_name(&format!("{DESCRIPTION}.{TMP}"));
110                    files.insert(tmp_path.clone(), desc.into_bytes());
111                    renames.push((tmp_path, final_path));
112                }
113
114                if let Some(color) = collection.color.filter(|s| !s.is_empty()) {
115                    let final_path = collection.path.join(COLOR);
116                    let tmp_path = final_path.with_file_name(&format!("{COLOR}.{TMP}"));
117                    files.insert(tmp_path.clone(), color.into_bytes());
118                    renames.push((tmp_path, final_path));
119                }
120
121                if files.is_empty() {
122                    return VdirCoroutineState::Complete(Ok(()));
123                }
124
125                self.state = State::AwaitFileCreate { renames };
126                VdirCoroutineState::Yielded(VdirYield::WantsFileCreate(files))
127            }
128            (State::AwaitFileCreate { renames }, Some(VdirReply::FileCreate)) => {
129                let renames = mem::take(renames);
130                self.state = State::AwaitRename;
131                VdirCoroutineState::Yielded(VdirYield::WantsRename(renames))
132            }
133            (State::AwaitRename, Some(VdirReply::Rename)) => VdirCoroutineState::Complete(Ok(())),
134            (_, arg) => {
135                let err = VdirCollectionUpdateError::UnexpectedArg(arg);
136                VdirCoroutineState::Complete(Err(err))
137            }
138        }
139    }
140}
141
142#[derive(Debug)]
143enum State {
144    Start(VdirCollection),
145    AwaitFileCreate { renames: Vec<(VdirPath, VdirPath)> },
146    AwaitRename,
147}
148
149impl fmt::Display for State {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        match self {
152            Self::Start(_) => f.write_str("start"),
153            Self::AwaitFileCreate { .. } => f.write_str("await file create reply"),
154            Self::AwaitRename => f.write_str("await rename reply"),
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn writes_temp_files_then_renames() {
165        let collection = VdirCollection {
166            path: VdirPath::from("root/contacts"),
167            display_name: Some("Contacts".into()),
168            description: None,
169            color: None,
170        };
171        let mut cor = VdirCollectionUpdate::new(collection, VdirCollectionUpdateOptions::default());
172
173        let files = match cor.resume(None) {
174            VdirCoroutineState::Yielded(VdirYield::WantsFileCreate(files)) => files,
175            state => panic!("expected WantsFileCreate, got {state:?}"),
176        };
177        let tmp = VdirPath::from("root/contacts/displayname.tmp");
178        assert!(files.contains_key(&tmp));
179
180        let pairs = match cor.resume(Some(VdirReply::FileCreate)) {
181            VdirCoroutineState::Yielded(VdirYield::WantsRename(pairs)) => pairs,
182            state => panic!("expected WantsRename, got {state:?}"),
183        };
184        assert_eq!(
185            pairs,
186            vec![(tmp, VdirPath::from("root/contacts/displayname"))]
187        );
188
189        match cor.resume(Some(VdirReply::Rename)) {
190            VdirCoroutineState::Complete(Ok(())) => {}
191            state => panic!("expected Complete(Ok), got {state:?}"),
192        }
193    }
194
195    #[test]
196    fn no_metadata_completes_immediately() {
197        let mut cor = VdirCollectionUpdate::new(
198            VdirCollection::from_path("root/contacts"),
199            VdirCollectionUpdateOptions::default(),
200        );
201
202        match cor.resume(None) {
203            VdirCoroutineState::Complete(Ok(())) => {}
204            state => panic!("expected Complete(Ok), got {state:?}"),
205        }
206    }
207
208    #[test]
209    fn unexpected_reply_returns_error() {
210        let collection = VdirCollection {
211            path: VdirPath::from("root/contacts"),
212            display_name: Some("Contacts".into()),
213            description: None,
214            color: None,
215        };
216        let mut cor = VdirCollectionUpdate::new(collection, VdirCollectionUpdateOptions::default());
217        let _ = cor.resume(None);
218
219        let err = match cor.resume(Some(VdirReply::DirExists(BTreeMap::new()))) {
220            VdirCoroutineState::Complete(Err(err)) => err,
221            state => panic!("expected Complete(Err), got {state:?}"),
222        };
223        assert!(matches!(err, VdirCollectionUpdateError::UnexpectedArg(_)));
224    }
225}