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::{
47    collections::{BTreeMap, BTreeSet},
48    format,
49    string::String,
50    vec::Vec,
51};
52
53use thiserror::Error;
54
55use crate::{
56    collection::{COLOR, DESCRIPTION, DISPLAYNAME, VdirCollection},
57    coroutine::*,
58    item::TMP,
59    path::VdirPath,
60};
61
62/// Failure causes during a [`VdirCollectionUpdate`] step.
63#[derive(Clone, Debug, Error)]
64pub enum VdirCollectionUpdateError {
65    /// The driver fed back a reply that does not match the pending
66    /// request.
67    #[error("Vdir collection update failed: unexpected arg {0:?}")]
68    UnexpectedArg(Option<VdirReply>),
69}
70
71/// Options for [`VdirCollectionUpdate::new`].
72#[derive(Clone, Debug, Default, Eq, PartialEq)]
73pub struct VdirCollectionUpdateOptions {}
74
75/// Rewrites Vdir collection metadata atomically via temp files.
76#[derive(Debug)]
77pub struct VdirCollectionUpdate {
78    state: State,
79    #[allow(dead_code)]
80    opts: VdirCollectionUpdateOptions,
81}
82
83impl VdirCollectionUpdate {
84    /// Creates a new coroutine that will write the metadata of
85    /// `collection` to disk.
86    pub fn new(collection: VdirCollection, opts: VdirCollectionUpdateOptions) -> Self {
87        Self {
88            opts,
89            state: State::Start(collection),
90        }
91    }
92}
93
94impl VdirCoroutine for VdirCollectionUpdate {
95    type Yield = VdirYield;
96    type Return = Result<(), VdirCollectionUpdateError>;
97
98    fn resume(&mut self, arg: Option<VdirReply>) -> VdirCoroutineState<Self::Yield, Self::Return> {
99        match (&mut self.state, arg) {
100            (State::Start(collection), None) => {
101                let collection = mem::take(collection);
102                let mut files = BTreeMap::new();
103                let mut renames = Vec::new();
104                let mut removals = BTreeSet::new();
105
106                // NOTE: the collection is a desired state, not a patch, so a
107                // field it does not carry is one the collection should not
108                // have: its file goes. Writing only what is set would leave a
109                // cleared description on disk and report success.
110                let mut field = |name: &str, value: Option<String>| {
111                    let final_path = collection.path.join(name);
112                    match value.filter(|value| !value.is_empty()) {
113                        Some(value) => {
114                            let tmp_path = final_path.with_file_name(&format!("{name}.{TMP}"));
115                            files.insert(tmp_path.clone(), value.into_bytes());
116                            renames.push((tmp_path, final_path));
117                        }
118                        None => {
119                            removals.insert(final_path);
120                        }
121                    }
122                };
123
124                field(DISPLAYNAME, collection.display_name.clone());
125                field(DESCRIPTION, collection.description.clone());
126                field(COLOR, collection.color.clone());
127
128                if files.is_empty() {
129                    self.state = State::RemoveFiles;
130                    return VdirCoroutineState::Yielded(VdirYield::WantsFileRemove(removals));
131                }
132
133                self.state = State::CreateFile { renames, removals };
134                VdirCoroutineState::Yielded(VdirYield::WantsFileCreate(files))
135            }
136            (State::CreateFile { renames, removals }, Some(VdirReply::FileCreate)) => {
137                let renames = mem::take(renames);
138                let removals = mem::take(removals);
139                self.state = State::Rename { removals };
140                VdirCoroutineState::Yielded(VdirYield::WantsRename(renames))
141            }
142            (State::Rename { removals }, Some(VdirReply::Rename)) => {
143                let removals = mem::take(removals);
144                if removals.is_empty() {
145                    return VdirCoroutineState::Complete(Ok(()));
146                }
147
148                self.state = State::RemoveFiles;
149                VdirCoroutineState::Yielded(VdirYield::WantsFileRemove(removals))
150            }
151            (State::RemoveFiles, Some(VdirReply::FileRemove)) => {
152                VdirCoroutineState::Complete(Ok(()))
153            }
154            (_, arg) => {
155                let err = VdirCollectionUpdateError::UnexpectedArg(arg);
156                VdirCoroutineState::Complete(Err(err))
157            }
158        }
159    }
160}
161
162#[derive(Debug)]
163enum State {
164    Start(VdirCollection),
165    CreateFile {
166        renames: Vec<(VdirPath, VdirPath)>,
167        removals: BTreeSet<VdirPath>,
168    },
169    Rename {
170        removals: BTreeSet<VdirPath>,
171    },
172    RemoveFiles,
173}
174
175impl fmt::Display for State {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        match self {
178            Self::Start(_) => f.write_str("start"),
179            Self::CreateFile { .. } => f.write_str("write metadata into tmp"),
180            Self::Rename { .. } => f.write_str("rename into place"),
181            Self::RemoveFiles => f.write_str("remove cleared metadata"),
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn writes_temp_files_then_renames() {
192        let collection = VdirCollection {
193            path: VdirPath::from("root/contacts"),
194            display_name: Some("Contacts".into()),
195            description: None,
196            color: None,
197        };
198        let mut cor = VdirCollectionUpdate::new(collection, VdirCollectionUpdateOptions::default());
199
200        let files = match cor.resume(None) {
201            VdirCoroutineState::Yielded(VdirYield::WantsFileCreate(files)) => files,
202            state => panic!("expected WantsFileCreate, got {state:?}"),
203        };
204        let tmp = VdirPath::from("root/contacts/displayname.tmp");
205        assert!(files.contains_key(&tmp));
206
207        let pairs = match cor.resume(Some(VdirReply::FileCreate)) {
208            VdirCoroutineState::Yielded(VdirYield::WantsRename(pairs)) => pairs,
209            state => panic!("expected WantsRename, got {state:?}"),
210        };
211        assert_eq!(
212            pairs,
213            vec![(tmp, VdirPath::from("root/contacts/displayname"))]
214        );
215
216        // NOTE: the two fields the collection does not carry are cleared,
217        // so their files are removed after the rename.
218        let removals = match cor.resume(Some(VdirReply::Rename)) {
219            VdirCoroutineState::Yielded(VdirYield::WantsFileRemove(removals)) => removals,
220            state => panic!("expected WantsFileRemove, got {state:?}"),
221        };
222        assert!(removals.contains(&VdirPath::from("root/contacts/description")));
223        assert!(removals.contains(&VdirPath::from("root/contacts/color")));
224        assert!(!removals.contains(&VdirPath::from("root/contacts/displayname")));
225
226        match cor.resume(Some(VdirReply::FileRemove)) {
227            VdirCoroutineState::Complete(Ok(())) => {}
228            state => panic!("expected Complete(Ok), got {state:?}"),
229        }
230    }
231
232    #[test]
233    fn no_metadata_removes_every_file() {
234        let mut cor = VdirCollectionUpdate::new(
235            VdirCollection::from_path("root/contacts"),
236            VdirCollectionUpdateOptions::default(),
237        );
238
239        // NOTE: a collection carrying no metadata at all asks for nothing to
240        // be written and for all three files to go.
241        let removals = match cor.resume(None) {
242            VdirCoroutineState::Yielded(VdirYield::WantsFileRemove(removals)) => removals,
243            state => panic!("expected WantsFileRemove, got {state:?}"),
244        };
245        assert_eq!(removals.len(), 3);
246
247        match cor.resume(Some(VdirReply::FileRemove)) {
248            VdirCoroutineState::Complete(Ok(())) => {}
249            state => panic!("expected Complete(Ok), got {state:?}"),
250        }
251    }
252
253    #[test]
254    fn unexpected_reply_returns_error() {
255        let collection = VdirCollection {
256            path: VdirPath::from("root/contacts"),
257            display_name: Some("Contacts".into()),
258            description: None,
259            color: None,
260        };
261        let mut cor = VdirCollectionUpdate::new(collection, VdirCollectionUpdateOptions::default());
262        let _ = cor.resume(None);
263
264        let err = match cor.resume(Some(VdirReply::DirExists(BTreeMap::new()))) {
265            VdirCoroutineState::Complete(Err(err)) => err,
266            state => panic!("expected Complete(Err), got {state:?}"),
267        };
268        assert!(matches!(err, VdirCollectionUpdateError::UnexpectedArg(_)));
269    }
270}