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::types::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::*, entry::list::*, entry::types::M2dirEntry, m2dir::types::M2dir, path::M2dirPath,
67};
68
69/// Failure causes during the m2dir DELETE-ENTRY flow.
70#[derive(Clone, Debug, Error)]
71pub enum M2dirEntryDeleteError {
72    #[error("M2DIR DELETE failed: unexpected coroutine arg")]
73    UnexpectedArg,
74    #[error("M2DIR DELETE failed: missing coroutine arg")]
75    MissingArg,
76    #[error("M2DIR DELETE failed: entry {0} not found")]
77    NotFound(String),
78    #[error("M2DIR DELETE failed: {0}")]
79    List(#[from] M2dirEntryListError),
80}
81
82/// Options for [`M2dirEntryDelete::new`].
83#[derive(Clone, Debug, Default, Eq, PartialEq)]
84pub struct M2dirEntryDeleteOptions {}
85
86/// I/O-free m2dir entry DELETE coroutine.
87pub struct M2dirEntryDelete {
88    id: String,
89    meta_dir: M2dirPath,
90    state: State,
91    #[allow(dead_code)]
92    opts: M2dirEntryDeleteOptions,
93}
94
95impl M2dirEntryDelete {
96    /// Creates a new coroutine that will delete entry `id` from
97    /// `m2dir`.
98    pub fn new(m2dir: M2dir, id: impl ToString, opts: M2dirEntryDeleteOptions) -> Self {
99        let meta_dir = m2dir.meta_dir();
100        Self {
101            id: id.to_string(),
102            meta_dir,
103            state: State::List(M2dirEntryList::new(m2dir, M2dirEntryListOptions::default())),
104            opts,
105        }
106    }
107}
108
109impl M2dirCoroutine for M2dirEntryDelete {
110    type Yield = M2dirYield;
111    type Return = Result<(), M2dirEntryDeleteError>;
112
113    fn resume(&mut self, arg: Option<M2dirArg>) -> M2dirCoroutineState<Self::Yield, Self::Return> {
114        trace!("delete entry: {}", self.state);
115
116        match (&mut self.state, arg) {
117            (State::List(list), arg) => match list.resume(arg) {
118                M2dirCoroutineState::Yielded(yld) => M2dirCoroutineState::Yielded(yld),
119                M2dirCoroutineState::Complete(Ok(entries)) => {
120                    let Some(entry) = entries.into_iter().find(|e| e.id() == self.id) else {
121                        let err = M2dirEntryDeleteError::NotFound(self.id.clone());
122                        return M2dirCoroutineState::Complete(Err(err));
123                    };
124
125                    trace!("located entry at {}", entry.path());
126
127                    let paths = BTreeSet::from_iter([self.meta_dir.clone()]);
128                    self.state = State::ReadMeta(entry);
129                    M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths))
130                }
131                M2dirCoroutineState::Complete(Err(err)) => {
132                    M2dirCoroutineState::Complete(Err(err.into()))
133                }
134            },
135            (State::ReadMeta(entry), Some(M2dirArg::DirRead(entries))) => {
136                let meta_names = entries.into_values().next().unwrap_or_default();
137                let entry = mem::replace(
138                    entry,
139                    M2dirEntry::from_parts(String::new(), M2dirPath::default()),
140                );
141
142                let mut to_remove: Vec<M2dirPath> = Vec::new();
143                to_remove.push(entry.path().clone());
144
145                for path in meta_names {
146                    let Some(name) = path.file_name() else {
147                        continue;
148                    };
149                    if name.starts_with(&self.id) {
150                        to_remove.push(path);
151                    }
152                }
153
154                trace!("wants removal of {} files", to_remove.len());
155                self.state = State::Removing;
156                M2dirCoroutineState::Yielded(M2dirYield::WantsFileRemove(BTreeSet::from_iter(
157                    to_remove,
158                )))
159            }
160            (State::Removing, Some(M2dirArg::FileRemove)) => {
161                trace!("entry deleted");
162                M2dirCoroutineState::Complete(Ok(()))
163            }
164            (_, Some(_)) => {
165                let err = M2dirEntryDeleteError::UnexpectedArg;
166                M2dirCoroutineState::Complete(Err(err))
167            }
168            (_, None) => {
169                let err = M2dirEntryDeleteError::MissingArg;
170                M2dirCoroutineState::Complete(Err(err))
171            }
172        }
173    }
174}
175
176enum State {
177    List(M2dirEntryList),
178    ReadMeta(M2dirEntry),
179    Removing,
180}
181
182impl fmt::Display for State {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        match self {
185            Self::List(_) => f.write_str("locate entry"),
186            Self::ReadMeta(_) => f.write_str("read .meta directory"),
187            Self::Removing => f.write_str("removing files"),
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use alloc::collections::BTreeMap;
195
196    use super::*;
197
198    #[test]
199    fn missing_entry_returns_not_found_error() {
200        let m2dir = M2dir::from_path("/tmp/inbox");
201        let mut delete =
202            M2dirEntryDelete::new(m2dir, "missing", M2dirEntryDeleteOptions::default());
203
204        let probes = match delete.resume(None) {
205            M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => paths,
206            state => panic!("expected WantsDirRead, got {state:?}"),
207        };
208        let dir = probes.into_iter().next().unwrap();
209        let mut reply = BTreeMap::new();
210        reply.insert(dir, BTreeSet::new());
211
212        let err = match delete.resume(Some(M2dirArg::DirRead(reply))) {
213            M2dirCoroutineState::Complete(Err(err)) => err,
214            state => panic!("expected Complete(Err), got {state:?}"),
215        };
216        assert!(matches!(err, M2dirEntryDeleteError::NotFound(id) if id == "missing"));
217    }
218
219    #[test]
220    fn list_error_propagates_via_from() {
221        let m2dir = M2dir::from_path("/tmp/inbox");
222        let mut delete = M2dirEntryDelete::new(m2dir, "x", M2dirEntryDeleteOptions::default());
223
224        let _ = delete.resume(None);
225        let err = match delete.resume(Some(M2dirArg::FileCreate)) {
226            M2dirCoroutineState::Complete(Err(err)) => err,
227            state => panic!("expected Complete(Err), got {state:?}"),
228        };
229        assert!(matches!(err, M2dirEntryDeleteError::List(_)));
230    }
231
232    #[test]
233    fn unexpected_arg_at_list_initial_returns_list_error() {
234        let m2dir = M2dir::from_path("/tmp/inbox");
235        let mut delete = M2dirEntryDelete::new(m2dir, "x", M2dirEntryDeleteOptions::default());
236
237        let err = match delete.resume(Some(M2dirArg::FileRead(BTreeMap::new()))) {
238            M2dirCoroutineState::Complete(Err(err)) => err,
239            state => panic!("expected Complete(Err), got {state:?}"),
240        };
241        assert!(matches!(err, M2dirEntryDeleteError::List(_)));
242    }
243
244    #[test]
245    fn missing_arg_at_list_propagates_via_list_error() {
246        let m2dir = M2dir::from_path("/tmp/inbox");
247        let mut delete = M2dirEntryDelete::new(m2dir, "x", M2dirEntryDeleteOptions::default());
248        let _ = delete.resume(None);
249
250        let err = match delete.resume(None) {
251            M2dirCoroutineState::Complete(Err(err)) => err,
252            state => panic!("expected Complete(Err), got {state:?}"),
253        };
254        assert!(matches!(err, M2dirEntryDeleteError::List(_)));
255    }
256
257    #[test]
258    fn read_meta_unexpected_arg_returns_unexpected_arg_error() {
259        let m2dir = M2dir::from_path("/tmp/inbox");
260        let mut delete = M2dirEntryDelete::new(m2dir, "any", M2dirEntryDeleteOptions::default());
261
262        let probes = match delete.resume(None) {
263            M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => paths,
264            state => panic!("expected WantsDirRead, got {state:?}"),
265        };
266        let dir = probes.into_iter().next().unwrap();
267        let mut names = BTreeSet::new();
268        let entry_path = M2dirPath::from("/tmp/inbox/123,checksum.nonce");
269        names.insert(entry_path.clone());
270        let mut reply = BTreeMap::new();
271        reply.insert(dir, names);
272
273        let probes = match delete.resume(Some(M2dirArg::DirRead(reply))) {
274            M2dirCoroutineState::Yielded(M2dirYield::WantsFileExists(probes)) => probes,
275            state => panic!("expected WantsFileExists, got {state:?}"),
276        };
277        let exists: BTreeMap<M2dirPath, bool> = probes.into_iter().map(|p| (p, true)).collect();
278
279        delete.id = "checksum.nonce".into();
280
281        let _ = match delete.resume(Some(M2dirArg::FileExists(exists))) {
282            M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => paths,
283            state => panic!("expected WantsDirRead, got {state:?}"),
284        };
285
286        let err = match delete.resume(Some(M2dirArg::FileCreate)) {
287            M2dirCoroutineState::Complete(Err(err)) => err,
288            state => panic!("expected Complete(Err), got {state:?}"),
289        };
290        assert!(matches!(err, M2dirEntryDeleteError::UnexpectedArg));
291    }
292}