Skip to main content

io_m2dir/flag/
add.rs

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