Skip to main content

io_m2dir/entry/
delete.rs

1//! I/O-free coroutine to delete an m2dir entry and every metadata
2//! sibling carrying the same id prefix (`.flags`, `.notes`, …).
3//!
4//! # Example
5//!
6//! ```rust,no_run
7//! use std::{collections::BTreeMap, fs};
8//!
9//! use io_m2dir::{
10//!     coroutine::{M2dirArg, M2dirCoroutine, M2dirCoroutineState, M2dirYield},
11//!     m2dir::M2dir,
12//!     entry::delete::{M2dirEntryDelete, M2dirEntryDeleteOptions},
13//! };
14//!
15//! let m2dir = M2dir::from_path("/tmp/inbox");
16//! let opts = M2dirEntryDeleteOptions::default();
17//! let mut coroutine = M2dirEntryDelete::new(m2dir, "id", opts);
18//! let mut arg = None;
19//!
20//! loop {
21//!     match coroutine.resume(arg.take()) {
22//!         M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => {
23//!             let mut out = BTreeMap::new();
24//!             for path in paths {
25//!                 let names = fs::read_dir(path.as_str())
26//!                     .map(|rd| rd.flatten().map(|e| e.path().into()).collect())
27//!                     .unwrap_or_default();
28//!                 out.insert(path, names);
29//!             }
30//!             arg = Some(M2dirArg::DirRead(out));
31//!         }
32//!         M2dirCoroutineState::Yielded(M2dirYield::WantsFileExists(probes)) => {
33//!             let map = probes
34//!                 .into_iter()
35//!                 .map(|p| {
36//!                     let exists = fs::metadata(p.as_str())
37//!                         .map_or(false, |m| m.is_file());
38//!                     (p, exists)
39//!                 })
40//!                 .collect();
41//!             arg = Some(M2dirArg::FileExists(map));
42//!         }
43//!         M2dirCoroutineState::Yielded(M2dirYield::WantsFileRemove(paths)) => {
44//!             for path in paths { let _ = fs::remove_file(path.as_str()); }
45//!             arg = Some(M2dirArg::FileRemove);
46//!         }
47//!         M2dirCoroutineState::Complete(Ok(())) => break,
48//!         M2dirCoroutineState::Complete(Err(err)) => panic!("{err}"),
49//!         state => panic!("unexpected state {state:?}"),
50//!     }
51//! }
52//! ```
53
54use core::{fmt, mem};
55
56use alloc::{
57    collections::BTreeSet,
58    string::{String, ToString},
59    vec::Vec,
60};
61
62use log::trace;
63use thiserror::Error;
64
65use crate::{
66    coroutine::*,
67    entry::{M2dirEntry, list::*},
68    flag::{M2dirFlags, add::*},
69    m2dir::M2dir,
70    path::M2dirPath,
71};
72
73/// Failure causes during the m2dir DELETE-ENTRY flow.
74#[derive(Clone, Debug, Error)]
75pub enum M2dirEntryDeleteError {
76    /// The resume arg did not match the pending filesystem request.
77    #[error("M2DIR DELETE failed: unexpected coroutine arg")]
78    UnexpectedArg,
79    /// The coroutine was resumed without the arg its request needs.
80    #[error("M2DIR DELETE failed: missing coroutine arg")]
81    MissingArg,
82    /// No entry with the requested id was found in the m2dir.
83    #[error("M2DIR DELETE failed: entry {0} not found")]
84    NotFound(String),
85    /// Listing the m2dir to resolve the id failed.
86    #[error("M2DIR DELETE failed: {0}")]
87    List(#[from] M2dirEntryListError),
88    /// Writing the soft-delete flags to the sidecar failed.
89    #[error("M2DIR DELETE failed: {0}")]
90    AddFlags(#[from] M2dirFlagAddError),
91}
92
93/// Options for [`M2dirEntryDelete::new`].
94#[derive(Clone, Debug, Default, Eq, PartialEq)]
95pub struct M2dirEntryDeleteOptions {
96    /// Soft-delete policy. `None` hard-deletes: the entry file and
97    /// every `.meta/<id>.*` sibling are removed. `Some(flags)` keeps
98    /// the entry on disk and adds `flags` to its `.meta/<id>.flags`
99    /// sidecar instead, so a caller-designated marker (e.g. `\Deleted`)
100    /// tombstones the message in place.
101    pub soft_delete: Option<M2dirFlags>,
102}
103
104/// I/O-free m2dir entry DELETE coroutine.
105pub struct M2dirEntryDelete {
106    id: String,
107    m2dir: M2dir,
108    state: State,
109    opts: M2dirEntryDeleteOptions,
110}
111
112impl M2dirEntryDelete {
113    /// Creates a new coroutine that will delete entry `id` from
114    /// `m2dir`, hard by default or soft when
115    /// [`M2dirEntryDeleteOptions::soft_delete`] is set.
116    pub fn new(m2dir: M2dir, id: impl ToString, opts: M2dirEntryDeleteOptions) -> Self {
117        Self {
118            id: id.to_string(),
119            state: State::List(M2dirEntryList::new(
120                m2dir.clone(),
121                M2dirEntryListOptions::default(),
122            )),
123            m2dir,
124            opts,
125        }
126    }
127}
128
129impl M2dirCoroutine for M2dirEntryDelete {
130    type Yield = M2dirYield;
131    type Return = Result<(), M2dirEntryDeleteError>;
132
133    fn resume(&mut self, arg: Option<M2dirArg>) -> M2dirCoroutineState<Self::Yield, Self::Return> {
134        match (&mut self.state, arg) {
135            (State::List(list), arg) => match list.resume(arg) {
136                M2dirCoroutineState::Yielded(yld) => M2dirCoroutineState::Yielded(yld),
137                M2dirCoroutineState::Complete(Ok(entries)) => {
138                    let Some(entry) = entries.into_iter().find(|e| e.id() == self.id) else {
139                        let err = M2dirEntryDeleteError::NotFound(self.id.clone());
140                        return M2dirCoroutineState::Complete(Err(err));
141                    };
142
143                    trace!("located entry at {}", entry.path());
144
145                    if let Some(flags) = self.opts.soft_delete.clone() {
146                        trace!("soft-deleting entry: adding flags instead of removing");
147                        let mut add = M2dirFlagAdd::new(
148                            &self.m2dir,
149                            &self.id,
150                            flags,
151                            M2dirFlagAddOptions::default(),
152                        );
153                        let first = add.resume(None);
154                        self.state = State::SoftDeleting(add);
155                        return match first {
156                            M2dirCoroutineState::Yielded(yld) => M2dirCoroutineState::Yielded(yld),
157                            M2dirCoroutineState::Complete(Ok(())) => {
158                                M2dirCoroutineState::Complete(Ok(()))
159                            }
160                            M2dirCoroutineState::Complete(Err(err)) => {
161                                M2dirCoroutineState::Complete(Err(err.into()))
162                            }
163                        };
164                    }
165
166                    let paths = BTreeSet::from_iter([self.m2dir.meta_dir()]);
167                    self.state = State::ReadMeta(entry);
168                    M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths))
169                }
170                M2dirCoroutineState::Complete(Err(err)) => {
171                    M2dirCoroutineState::Complete(Err(err.into()))
172                }
173            },
174            (State::ReadMeta(entry), Some(M2dirArg::DirRead(entries))) => {
175                let meta_names = entries.into_values().next().unwrap_or_default();
176                let entry = mem::replace(
177                    entry,
178                    M2dirEntry::from_parts(String::new(), M2dirPath::default()),
179                );
180
181                let mut to_remove: Vec<M2dirPath> = Vec::new();
182                to_remove.push(entry.path().clone());
183
184                for path in meta_names {
185                    let Some(name) = path.file_name() else {
186                        continue;
187                    };
188                    if name.starts_with(&self.id) {
189                        to_remove.push(path);
190                    }
191                }
192
193                trace!("wants removal of {} files", to_remove.len());
194                self.state = State::Removing;
195                M2dirCoroutineState::Yielded(M2dirYield::WantsFileRemove(BTreeSet::from_iter(
196                    to_remove,
197                )))
198            }
199            (State::Removing, Some(M2dirArg::FileRemove)) => {
200                trace!("entry deleted");
201                M2dirCoroutineState::Complete(Ok(()))
202            }
203            (State::SoftDeleting(add), arg) => match add.resume(arg) {
204                M2dirCoroutineState::Yielded(yld) => M2dirCoroutineState::Yielded(yld),
205                M2dirCoroutineState::Complete(Ok(())) => {
206                    trace!("entry soft-deleted");
207                    M2dirCoroutineState::Complete(Ok(()))
208                }
209                M2dirCoroutineState::Complete(Err(err)) => {
210                    M2dirCoroutineState::Complete(Err(err.into()))
211                }
212            },
213            (_, Some(_)) => {
214                let err = M2dirEntryDeleteError::UnexpectedArg;
215                M2dirCoroutineState::Complete(Err(err))
216            }
217            (_, None) => {
218                let err = M2dirEntryDeleteError::MissingArg;
219                M2dirCoroutineState::Complete(Err(err))
220            }
221        }
222    }
223}
224
225enum State {
226    List(M2dirEntryList),
227    ReadMeta(M2dirEntry),
228    Removing,
229    SoftDeleting(M2dirFlagAdd),
230}
231
232impl fmt::Display for State {
233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        match self {
235            Self::List(_) => f.write_str("locate entry"),
236            Self::ReadMeta(_) => f.write_str("read .meta directory"),
237            Self::Removing => f.write_str("removing files"),
238            Self::SoftDeleting(_) => f.write_str("soft-deleting (adding flags)"),
239        }
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use alloc::collections::BTreeMap;
246
247    use super::*;
248
249    #[test]
250    fn missing_entry_returns_not_found_error() {
251        let m2dir = M2dir::from_path("/tmp/inbox");
252        let mut delete =
253            M2dirEntryDelete::new(m2dir, "missing", M2dirEntryDeleteOptions::default());
254
255        let probes = match delete.resume(None) {
256            M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => paths,
257            state => panic!("expected WantsDirRead, got {state:?}"),
258        };
259        let dir = probes.into_iter().next().unwrap();
260        let mut reply = BTreeMap::new();
261        reply.insert(dir, BTreeSet::new());
262
263        let err = match delete.resume(Some(M2dirArg::DirRead(reply))) {
264            M2dirCoroutineState::Complete(Err(err)) => err,
265            state => panic!("expected Complete(Err), got {state:?}"),
266        };
267        assert!(matches!(err, M2dirEntryDeleteError::NotFound(id) if id == "missing"));
268    }
269
270    #[test]
271    fn list_error_propagates_via_from() {
272        let m2dir = M2dir::from_path("/tmp/inbox");
273        let mut delete = M2dirEntryDelete::new(m2dir, "x", M2dirEntryDeleteOptions::default());
274
275        let _ = delete.resume(None);
276        let err = match delete.resume(Some(M2dirArg::FileCreate)) {
277            M2dirCoroutineState::Complete(Err(err)) => err,
278            state => panic!("expected Complete(Err), got {state:?}"),
279        };
280        assert!(matches!(err, M2dirEntryDeleteError::List(_)));
281    }
282
283    #[test]
284    fn unexpected_arg_at_list_initial_returns_list_error() {
285        let m2dir = M2dir::from_path("/tmp/inbox");
286        let mut delete = M2dirEntryDelete::new(m2dir, "x", M2dirEntryDeleteOptions::default());
287
288        let err = match delete.resume(Some(M2dirArg::FileRead(BTreeMap::new()))) {
289            M2dirCoroutineState::Complete(Err(err)) => err,
290            state => panic!("expected Complete(Err), got {state:?}"),
291        };
292        assert!(matches!(err, M2dirEntryDeleteError::List(_)));
293    }
294
295    #[test]
296    fn missing_arg_at_list_propagates_via_list_error() {
297        let m2dir = M2dir::from_path("/tmp/inbox");
298        let mut delete = M2dirEntryDelete::new(m2dir, "x", M2dirEntryDeleteOptions::default());
299        let _ = delete.resume(None);
300
301        let err = match delete.resume(None) {
302            M2dirCoroutineState::Complete(Err(err)) => err,
303            state => panic!("expected Complete(Err), got {state:?}"),
304        };
305        assert!(matches!(err, M2dirEntryDeleteError::List(_)));
306    }
307
308    #[test]
309    fn read_meta_unexpected_arg_returns_unexpected_arg_error() {
310        let m2dir = M2dir::from_path("/tmp/inbox");
311        let mut delete = M2dirEntryDelete::new(m2dir, "any", M2dirEntryDeleteOptions::default());
312
313        let probes = match delete.resume(None) {
314            M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => paths,
315            state => panic!("expected WantsDirRead, got {state:?}"),
316        };
317        let dir = probes.into_iter().next().unwrap();
318        let mut names = BTreeSet::new();
319        let entry_path = M2dirPath::from("/tmp/inbox/123,checksum.nonce");
320        names.insert(entry_path.clone());
321        let mut reply = BTreeMap::new();
322        reply.insert(dir, names);
323
324        let probes = match delete.resume(Some(M2dirArg::DirRead(reply))) {
325            M2dirCoroutineState::Yielded(M2dirYield::WantsFileExists(probes)) => probes,
326            state => panic!("expected WantsFileExists, got {state:?}"),
327        };
328        let exists: BTreeMap<M2dirPath, bool> = probes.into_iter().map(|p| (p, true)).collect();
329
330        delete.id = "checksum.nonce".into();
331
332        let _ = match delete.resume(Some(M2dirArg::FileExists(exists))) {
333            M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => paths,
334            state => panic!("expected WantsDirRead, got {state:?}"),
335        };
336
337        let err = match delete.resume(Some(M2dirArg::FileCreate)) {
338            M2dirCoroutineState::Complete(Err(err)) => err,
339            state => panic!("expected Complete(Err), got {state:?}"),
340        };
341        assert!(matches!(err, M2dirEntryDeleteError::UnexpectedArg));
342    }
343
344    #[test]
345    fn soft_delete_adds_flags_instead_of_removing() {
346        let m2dir = M2dir::from_path("/tmp/inbox");
347        let mut flags = M2dirFlags::default();
348        flags.insert("\\Deleted");
349        let opts = M2dirEntryDeleteOptions {
350            soft_delete: Some(flags),
351        };
352        let mut delete = M2dirEntryDelete::new(m2dir, "checksum.nonce", opts);
353
354        // locate the entry: list reads the directory, then probes existence
355        let probes = match delete.resume(None) {
356            M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => paths,
357            state => panic!("expected WantsDirRead, got {state:?}"),
358        };
359        let dir = probes.into_iter().next().unwrap();
360        let mut names = BTreeSet::new();
361        names.insert(M2dirPath::from("/tmp/inbox/123,checksum.nonce"));
362        let mut reply = BTreeMap::new();
363        reply.insert(dir, names);
364
365        let probes = match delete.resume(Some(M2dirArg::DirRead(reply))) {
366            M2dirCoroutineState::Yielded(M2dirYield::WantsFileExists(probes)) => probes,
367            state => panic!("expected WantsFileExists, got {state:?}"),
368        };
369        let exists: BTreeMap<M2dirPath, bool> = probes.into_iter().map(|p| (p, true)).collect();
370
371        // soft delete branches into a flags read/write rather than a removal
372        let read = match delete.resume(Some(M2dirArg::FileExists(exists))) {
373            M2dirCoroutineState::Yielded(M2dirYield::WantsFileRead(paths)) => paths,
374            state => panic!("expected WantsFileRead, got {state:?}"),
375        };
376        let flags_path = read.into_iter().next().unwrap();
377        let reply = BTreeMap::from_iter([(flags_path, alloc::vec::Vec::<u8>::new())]);
378
379        let files = match delete.resume(Some(M2dirArg::FileRead(reply))) {
380            M2dirCoroutineState::Yielded(M2dirYield::WantsFileCreate(files)) => files,
381            state => panic!("expected WantsFileCreate, got {state:?}"),
382        };
383        let (_, bytes) = files.into_iter().next().unwrap();
384        let serialized = alloc::string::String::from_utf8(bytes).unwrap();
385        assert!(serialized.contains("\\Deleted"));
386
387        match delete.resume(Some(M2dirArg::FileCreate)) {
388            M2dirCoroutineState::Complete(Ok(())) => {}
389            state => panic!("expected Complete(Ok), got {state:?}"),
390        }
391    }
392}