Skip to main content

io_m2dir/flag/
remove.rs

1//! I/O-free coroutine to remove flags from an m2dir entry's flags
2//! metadata file.
3//!
4//! Reads the existing `.flags` payload, subtracts the caller-supplied
5//! flags, and writes the remaining set back. If the result is empty
6//! the file is removed.
7//!
8//! # Example
9//!
10//! ```rust,no_run
11//! use std::{collections::BTreeMap, fs};
12//!
13//! use io_m2dir::{
14//!     coroutine::{M2dirArg, M2dirCoroutine, M2dirCoroutineState, M2dirYield},
15//!     flag::{
16//!         remove::{M2dirFlagRemove, M2dirFlagRemoveOptions},
17//!         types::M2dirFlags,
18//!     },
19//!     m2dir::types::M2dir,
20//! };
21//!
22//! let m2dir = M2dir::from_path("/tmp/inbox");
23//! let mut flags = M2dirFlags::default();
24//! flags.insert("$seen");
25//! let opts = M2dirFlagRemoveOptions::default();
26//! let mut coroutine = M2dirFlagRemove::new(&m2dir, "entry-id", flags, opts);
27//! let mut arg = None;
28//!
29//! loop {
30//!     match coroutine.resume(arg.take()) {
31//!         M2dirCoroutineState::Yielded(M2dirYield::WantsFileRead(paths)) => {
32//!             let mut out = BTreeMap::new();
33//!             for path in paths {
34//!                 let bytes = fs::read(path.as_str()).unwrap_or_default();
35//!                 out.insert(path, bytes);
36//!             }
37//!             arg = Some(M2dirArg::FileRead(out));
38//!         }
39//!         M2dirCoroutineState::Yielded(M2dirYield::WantsFileCreate(files)) => {
40//!             for (path, bytes) in files {
41//!                 fs::write(path.as_str(), bytes).unwrap();
42//!             }
43//!             arg = Some(M2dirArg::FileCreate);
44//!         }
45//!         M2dirCoroutineState::Yielded(M2dirYield::WantsFileRemove(paths)) => {
46//!             for path in paths {
47//!                 let _ = fs::remove_file(path.as_str());
48//!             }
49//!             arg = Some(M2dirArg::FileRemove);
50//!         }
51//!         M2dirCoroutineState::Complete(Ok(())) => break,
52//!         M2dirCoroutineState::Complete(Err(err)) => panic!("{err}"),
53//!         state => panic!("unexpected state {state:?}"),
54//!     }
55//! }
56//! ```
57
58use core::{fmt, str};
59
60use alloc::collections::{BTreeMap, BTreeSet};
61
62use log::trace;
63use thiserror::Error;
64
65use crate::{coroutine::*, flag::types::M2dirFlags, m2dir::types::M2dir, path::M2dirPath};
66
67/// Failure causes during the m2dir flag REMOVE flow.
68#[derive(Clone, Debug, Error)]
69pub enum M2dirFlagRemoveError {
70    #[error("M2DIR REMOVE FLAGS failed: unexpected coroutine arg")]
71    UnexpectedArg,
72    #[error("M2DIR REMOVE FLAGS failed: missing coroutine arg")]
73    MissingArg,
74}
75
76/// Options for [`M2dirFlagRemove::new`].
77#[derive(Clone, Debug, Default, Eq, PartialEq)]
78pub struct M2dirFlagRemoveOptions {}
79
80/// I/O-free m2dir flag REMOVE coroutine.
81pub struct M2dirFlagRemove {
82    flags_path: M2dirPath,
83    flags: M2dirFlags,
84    state: State,
85    #[allow(dead_code)]
86    opts: M2dirFlagRemoveOptions,
87}
88
89impl M2dirFlagRemove {
90    /// Creates a new coroutine that will remove `flags` from the
91    /// flags metadata file for entry `id` inside `m2dir`.
92    pub fn new(
93        m2dir: &M2dir,
94        id: impl AsRef<str>,
95        flags: M2dirFlags,
96        opts: M2dirFlagRemoveOptions,
97    ) -> Self {
98        Self {
99            flags_path: m2dir.flags_path(id.as_ref()),
100            flags,
101            state: State::Start,
102            opts,
103        }
104    }
105}
106
107impl M2dirCoroutine for M2dirFlagRemove {
108    type Yield = M2dirYield;
109    type Return = Result<(), M2dirFlagRemoveError>;
110
111    fn resume(&mut self, arg: Option<M2dirArg>) -> M2dirCoroutineState<Self::Yield, Self::Return> {
112        trace!("remove flags: {}", self.state);
113
114        match (&self.state, arg) {
115            (State::Start, None) => {
116                trace!("wants existing flags read at {}", self.flags_path);
117                let paths = BTreeSet::from_iter([self.flags_path.clone()]);
118                self.state = State::Read;
119                M2dirCoroutineState::Yielded(M2dirYield::WantsFileRead(paths))
120            }
121            (State::Read, Some(M2dirArg::FileRead(contents))) => {
122                let bytes = contents.into_values().next().unwrap_or_default();
123                let existing = str::from_utf8(&bytes).unwrap_or("");
124
125                let mut remaining = M2dirFlags::from_meta(existing);
126                remaining.difference(&self.flags);
127
128                self.state = State::Done;
129
130                if remaining.is_empty() {
131                    trace!("wants flags remove at {}", self.flags_path);
132                    let paths = BTreeSet::from_iter([self.flags_path.clone()]);
133                    M2dirCoroutineState::Yielded(M2dirYield::WantsFileRemove(paths))
134                } else {
135                    trace!(
136                        "wants flags write at {} ({} flags)",
137                        self.flags_path,
138                        remaining.len(),
139                    );
140                    let serialized = remaining.to_meta().into_bytes();
141                    let files = BTreeMap::from_iter([(self.flags_path.clone(), serialized)]);
142                    M2dirCoroutineState::Yielded(M2dirYield::WantsFileCreate(files))
143                }
144            }
145            (State::Done, Some(M2dirArg::FileCreate | M2dirArg::FileRemove)) => {
146                trace!("flags removed from {}", self.flags_path);
147                M2dirCoroutineState::Complete(Ok(()))
148            }
149            (_, Some(_)) => {
150                let err = M2dirFlagRemoveError::UnexpectedArg;
151                M2dirCoroutineState::Complete(Err(err))
152            }
153            (_, None) => {
154                let err = M2dirFlagRemoveError::MissingArg;
155                M2dirCoroutineState::Complete(Err(err))
156            }
157        }
158    }
159}
160
161enum State {
162    Start,
163    Read,
164    Done,
165}
166
167impl fmt::Display for State {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        match self {
170            Self::Start => f.write_str("start"),
171            Self::Read => f.write_str("read existing flags"),
172            Self::Done => f.write_str("done"),
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use alloc::vec::Vec;
180
181    use super::*;
182
183    #[test]
184    fn subtracts_flags_and_writes_remainder() {
185        let m2dir = M2dir::from_path("/tmp/inbox");
186        let mut to_remove = M2dirFlags::default();
187        to_remove.insert("$seen");
188
189        let mut rm = M2dirFlagRemove::new(
190            &m2dir,
191            "entry",
192            to_remove,
193            M2dirFlagRemoveOptions::default(),
194        );
195
196        let probes = expect_wants_file_read(&mut rm, None);
197        let path = probes.into_iter().next().unwrap();
198        let reply = BTreeMap::from_iter([(path, b"$seen\n$forwarded\n".to_vec())]);
199
200        let files = expect_wants_file_create(&mut rm, Some(M2dirArg::FileRead(reply)));
201        let (_, bytes) = files.into_iter().next().unwrap();
202        let serialized = str::from_utf8(&bytes).unwrap();
203        assert!(!serialized.contains("$seen"));
204        assert!(serialized.contains("$forwarded"));
205
206        expect_complete_ok(&mut rm, Some(M2dirArg::FileCreate));
207    }
208
209    #[test]
210    fn empty_remainder_removes_the_flags_file() {
211        let m2dir = M2dir::from_path("/tmp/inbox");
212        let mut to_remove = M2dirFlags::default();
213        to_remove.insert("$seen");
214
215        let mut rm = M2dirFlagRemove::new(
216            &m2dir,
217            "entry",
218            to_remove,
219            M2dirFlagRemoveOptions::default(),
220        );
221
222        let probes = expect_wants_file_read(&mut rm, None);
223        let path = probes.into_iter().next().unwrap();
224        let reply = BTreeMap::from_iter([(path, b"$seen\n".to_vec())]);
225
226        let _ = expect_wants_file_remove(&mut rm, Some(M2dirArg::FileRead(reply)));
227
228        expect_complete_ok(&mut rm, Some(M2dirArg::FileRemove));
229    }
230
231    #[test]
232    fn unexpected_arg_at_start_returns_unexpected_arg_error() {
233        let m2dir = M2dir::from_path("/tmp/inbox");
234        let mut rm = M2dirFlagRemove::new(
235            &m2dir,
236            "entry",
237            M2dirFlags::default(),
238            M2dirFlagRemoveOptions::default(),
239        );
240
241        let err = expect_complete_err(&mut rm, Some(M2dirArg::FileCreate));
242        assert!(matches!(err, M2dirFlagRemoveError::UnexpectedArg));
243    }
244
245    #[test]
246    fn missing_arg_at_read_returns_missing_arg_error() {
247        let m2dir = M2dir::from_path("/tmp/inbox");
248        let mut rm = M2dirFlagRemove::new(
249            &m2dir,
250            "entry",
251            M2dirFlags::default(),
252            M2dirFlagRemoveOptions::default(),
253        );
254        let _ = expect_wants_file_read(&mut rm, None);
255
256        let err = expect_complete_err(&mut rm, None);
257        assert!(matches!(err, M2dirFlagRemoveError::MissingArg));
258    }
259
260    #[test]
261    fn unexpected_arg_kind_at_done_returns_unexpected_arg_error() {
262        let m2dir = M2dir::from_path("/tmp/inbox");
263        let mut to_remove = M2dirFlags::default();
264        to_remove.insert("$seen");
265
266        let mut rm = M2dirFlagRemove::new(
267            &m2dir,
268            "entry",
269            to_remove,
270            M2dirFlagRemoveOptions::default(),
271        );
272
273        let probes = expect_wants_file_read(&mut rm, None);
274        let path = probes.into_iter().next().unwrap();
275        let reply = BTreeMap::from_iter([(path, b"$seen\n".to_vec())]);
276        let _ = expect_wants_file_remove(&mut rm, Some(M2dirArg::FileRead(reply)));
277
278        let err = expect_complete_err(&mut rm, Some(M2dirArg::DirRemove));
279        assert!(matches!(err, M2dirFlagRemoveError::UnexpectedArg));
280    }
281
282    // --- utils
283
284    fn expect_wants_file_read(
285        cor: &mut M2dirFlagRemove,
286        arg: Option<M2dirArg>,
287    ) -> BTreeSet<M2dirPath> {
288        match cor.resume(arg) {
289            M2dirCoroutineState::Yielded(M2dirYield::WantsFileRead(paths)) => paths,
290            state => panic!("expected WantsFileRead, got {state:?}"),
291        }
292    }
293
294    fn expect_wants_file_create(
295        cor: &mut M2dirFlagRemove,
296        arg: Option<M2dirArg>,
297    ) -> BTreeMap<M2dirPath, Vec<u8>> {
298        match cor.resume(arg) {
299            M2dirCoroutineState::Yielded(M2dirYield::WantsFileCreate(files)) => files,
300            state => panic!("expected WantsFileCreate, got {state:?}"),
301        }
302    }
303
304    fn expect_wants_file_remove(
305        cor: &mut M2dirFlagRemove,
306        arg: Option<M2dirArg>,
307    ) -> BTreeSet<M2dirPath> {
308        match cor.resume(arg) {
309            M2dirCoroutineState::Yielded(M2dirYield::WantsFileRemove(paths)) => paths,
310            state => panic!("expected WantsFileRemove, got {state:?}"),
311        }
312    }
313
314    fn expect_complete_ok(cor: &mut M2dirFlagRemove, arg: Option<M2dirArg>) {
315        match cor.resume(arg) {
316            M2dirCoroutineState::Complete(Ok(())) => {}
317            state => panic!("expected Complete(Ok), got {state:?}"),
318        }
319    }
320
321    fn expect_complete_err(
322        cor: &mut M2dirFlagRemove,
323        arg: Option<M2dirArg>,
324    ) -> M2dirFlagRemoveError {
325        match cor.resume(arg) {
326            M2dirCoroutineState::Complete(Err(err)) => err,
327            state => panic!("expected Complete(Err), got {state:?}"),
328        }
329    }
330}