Skip to main content

io_m2dir/m2dir/
list.rs

1//! I/O-free coroutine to list every valid m2dir inside an m2store.
2//!
3//! Walks the tree depth-first. Hidden entries (whose name starts
4//! with `.`) are skipped. A directory containing the `.m2dir`
5//! marker is reported as an m2dir; its sub-directories are still
6//! scanned because m2dirs can nest.
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//!     store::M2dirStore,
16//!     m2dir::list::{M2dirList, M2dirListOptions},
17//! };
18//!
19//! let store = M2dirStore::from_path("/tmp/store");
20//! let opts = M2dirListOptions::default();
21//! let mut coroutine = M2dirList::new(&store, opts);
22//! let mut arg = None;
23//!
24//! let mailboxes = loop {
25//!     match coroutine.resume(arg.take()) {
26//!         M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => {
27//!             let mut out = BTreeMap::new();
28//!             for path in paths {
29//!                 let names = fs::read_dir(path.as_str())
30//!                     .map(|rd| rd.flatten().map(|e| e.path().into()).collect())
31//!                     .unwrap_or_default();
32//!                 out.insert(path, names);
33//!             }
34//!             arg = Some(M2dirArg::DirRead(out));
35//!         }
36//!         M2dirCoroutineState::Yielded(M2dirYield::WantsFileExists(probes)) => {
37//!             let map = probes
38//!                 .into_iter()
39//!                 .map(|p| {
40//!                     let exists = fs::metadata(p.as_str())
41//!                         .map_or(false, |m| m.is_file());
42//!                     (p, exists)
43//!                 })
44//!                 .collect();
45//!             arg = Some(M2dirArg::FileExists(map));
46//!         }
47//!         M2dirCoroutineState::Complete(Ok(mboxes)) => break mboxes,
48//!         M2dirCoroutineState::Complete(Err(err)) => panic!("{err}"),
49//!         state => panic!("unexpected state {state:?}"),
50//!     }
51//! };
52//!
53//! println!("{} mailboxes", mailboxes.len());
54//! ```
55
56use core::{fmt, mem};
57
58use alloc::collections::{BTreeMap, BTreeSet};
59
60use log::trace;
61use thiserror::Error;
62
63use crate::{
64    coroutine::*,
65    m2dir::types::{DOT_M2DIR, M2dir},
66    path::M2dirPath,
67    store::M2dirStore,
68};
69
70/// Failure causes during the m2dir LIST-MAILBOXES flow.
71#[derive(Clone, Debug, Error)]
72pub enum M2dirListError {
73    #[error("M2DIR LIST failed: unexpected coroutine arg")]
74    UnexpectedArg,
75    #[error("M2DIR LIST failed: missing coroutine arg")]
76    MissingArg,
77}
78
79/// Options for [`M2dirList::new`].
80#[derive(Clone, Debug, Default, Eq, PartialEq)]
81pub struct M2dirListOptions {}
82
83/// I/O-free m2dir LIST coroutine.
84pub struct M2dirList {
85    state: State,
86    #[allow(dead_code)]
87    opts: M2dirListOptions,
88}
89
90impl M2dirList {
91    /// Creates a new coroutine that will list every m2dir inside
92    /// `store`.
93    pub fn new(store: &M2dirStore, opts: M2dirListOptions) -> Self {
94        let pending = BTreeSet::from_iter([store.path().clone()]);
95        Self {
96            state: State::Scanning {
97                pending,
98                found: BTreeSet::new(),
99            },
100            opts,
101        }
102    }
103}
104
105impl M2dirCoroutine for M2dirList {
106    type Yield = M2dirYield;
107    type Return = Result<BTreeSet<M2dir>, M2dirListError>;
108
109    fn resume(&mut self, arg: Option<M2dirArg>) -> M2dirCoroutineState<Self::Yield, Self::Return> {
110        trace!("list m2dirs: {}", self.state);
111
112        match (&mut self.state, arg) {
113            (State::Scanning { pending, .. }, None) => {
114                let batch = mem::take(pending);
115                trace!("wants read of {} directories", batch.len());
116                M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(batch))
117            }
118            (State::Scanning { pending, found }, Some(M2dirArg::DirRead(entries))) => {
119                trace!("scanned {} directories", entries.len());
120
121                let mut markers = BTreeMap::new();
122                let mut next_pending = mem::take(pending);
123                let found = mem::take(found);
124
125                for (_dir, names) in entries {
126                    for path in names {
127                        let Some(name) = path.file_name() else {
128                            continue;
129                        };
130
131                        if name.starts_with('.') {
132                            continue;
133                        }
134
135                        let marker = path.join(DOT_M2DIR);
136                        markers.insert(marker, path.clone());
137                        next_pending.insert(path);
138                    }
139                }
140
141                if markers.is_empty() {
142                    if next_pending.is_empty() {
143                        trace!("found {} m2dirs", found.len());
144                        return M2dirCoroutineState::Complete(Ok(found));
145                    }
146
147                    let batch = next_pending;
148                    self.state = State::Scanning {
149                        pending: BTreeSet::new(),
150                        found,
151                    };
152                    return M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(batch));
153                }
154
155                let probes: BTreeSet<M2dirPath> = markers.keys().cloned().collect();
156                trace!("wants existence check for {} markers", probes.len());
157
158                self.state = State::CheckingMarkers {
159                    next_pending,
160                    markers,
161                    found,
162                };
163                M2dirCoroutineState::Yielded(M2dirYield::WantsFileExists(probes))
164            }
165            (
166                State::CheckingMarkers {
167                    next_pending,
168                    markers,
169                    found,
170                },
171                Some(M2dirArg::FileExists(probes)),
172            ) => {
173                let next_pending = mem::take(next_pending);
174                let markers = mem::take(markers);
175                let mut found = mem::take(found);
176
177                for (marker, dir) in markers {
178                    if probes.get(&marker).copied().unwrap_or(false) {
179                        found.insert(M2dir::from_path(dir));
180                    }
181                }
182
183                if next_pending.is_empty() {
184                    trace!("found {} m2dirs", found.len());
185                    return M2dirCoroutineState::Complete(Ok(found));
186                }
187
188                let batch = next_pending;
189                trace!("wants read of {} directories", batch.len());
190
191                self.state = State::Scanning {
192                    pending: BTreeSet::new(),
193                    found,
194                };
195                M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(batch))
196            }
197            (_, Some(_)) => {
198                let err = M2dirListError::UnexpectedArg;
199                M2dirCoroutineState::Complete(Err(err))
200            }
201            (_, None) => {
202                let err = M2dirListError::MissingArg;
203                M2dirCoroutineState::Complete(Err(err))
204            }
205        }
206    }
207}
208
209enum State {
210    Scanning {
211        pending: BTreeSet<M2dirPath>,
212        found: BTreeSet<M2dir>,
213    },
214    CheckingMarkers {
215        next_pending: BTreeSet<M2dirPath>,
216        markers: BTreeMap<M2dirPath, M2dirPath>,
217        found: BTreeSet<M2dir>,
218    },
219}
220
221impl fmt::Display for State {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        match self {
224            Self::Scanning { .. } => f.write_str("scanning"),
225            Self::CheckingMarkers { .. } => f.write_str("checking markers"),
226        }
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn empty_store_returns_no_mailboxes() {
236        let store = M2dirStore::from_path("/tmp/store");
237        let mut list = M2dirList::new(&store, M2dirListOptions::default());
238
239        let batch = expect_wants_dir_read(&mut list, None);
240        let dir = batch.into_iter().next().unwrap();
241        let mut reply = BTreeMap::new();
242        reply.insert(dir, BTreeSet::new());
243
244        let mboxes = match list.resume(Some(M2dirArg::DirRead(reply))) {
245            M2dirCoroutineState::Complete(Ok(mboxes)) => mboxes,
246            state => panic!("expected Complete(Ok), got {state:?}"),
247        };
248        assert!(mboxes.is_empty());
249    }
250
251    #[test]
252    fn directory_without_marker_is_skipped() {
253        let store = M2dirStore::from_path("/tmp/store");
254        let mut list = M2dirList::new(&store, M2dirListOptions::default());
255
256        let _ = expect_wants_dir_read(&mut list, None);
257
258        let mut names = BTreeSet::new();
259        names.insert(M2dirPath::from("/tmp/store/maybe"));
260        let mut reply = BTreeMap::new();
261        reply.insert(M2dirPath::from("/tmp/store"), names);
262
263        let probes = expect_wants_file_exists(&mut list, Some(M2dirArg::DirRead(reply)));
264        let exists: BTreeMap<M2dirPath, bool> = probes.into_iter().map(|p| (p, false)).collect();
265
266        let _ = expect_wants_dir_read(&mut list, Some(M2dirArg::FileExists(exists)));
267    }
268
269    #[test]
270    fn directory_with_marker_is_reported_as_mailbox() {
271        let store = M2dirStore::from_path("/tmp/store");
272        let mut list = M2dirList::new(&store, M2dirListOptions::default());
273
274        let _ = expect_wants_dir_read(&mut list, None);
275
276        let mut names = BTreeSet::new();
277        names.insert(M2dirPath::from("/tmp/store/inbox"));
278        let mut reply = BTreeMap::new();
279        reply.insert(M2dirPath::from("/tmp/store"), names);
280
281        let probes = expect_wants_file_exists(&mut list, Some(M2dirArg::DirRead(reply)));
282        let exists: BTreeMap<M2dirPath, bool> = probes.into_iter().map(|p| (p, true)).collect();
283
284        let next_batch = expect_wants_dir_read(&mut list, Some(M2dirArg::FileExists(exists)));
285        let mut reply = BTreeMap::new();
286        for dir in next_batch {
287            reply.insert(dir, BTreeSet::new());
288        }
289        let mboxes = match list.resume(Some(M2dirArg::DirRead(reply))) {
290            M2dirCoroutineState::Complete(Ok(mboxes)) => mboxes,
291            state => panic!("expected Complete(Ok), got {state:?}"),
292        };
293        assert!(
294            mboxes
295                .iter()
296                .any(|m| m.path().as_str() == "/tmp/store/inbox")
297        );
298    }
299
300    #[test]
301    fn unexpected_arg_returns_unexpected_arg_error() {
302        let store = M2dirStore::from_path("/tmp/store");
303        let mut list = M2dirList::new(&store, M2dirListOptions::default());
304
305        let err = expect_complete_err(&mut list, Some(M2dirArg::FileCreate));
306        assert!(matches!(err, M2dirListError::UnexpectedArg));
307    }
308
309    #[test]
310    fn missing_arg_at_checking_markers_returns_missing_arg_error() {
311        let store = M2dirStore::from_path("/tmp/store");
312        let mut list = M2dirList::new(&store, M2dirListOptions::default());
313
314        let _ = expect_wants_dir_read(&mut list, None);
315
316        let mut names = BTreeSet::new();
317        names.insert(M2dirPath::from("/tmp/store/maybe"));
318        let mut reply = BTreeMap::new();
319        reply.insert(M2dirPath::from("/tmp/store"), names);
320
321        let _ = expect_wants_file_exists(&mut list, Some(M2dirArg::DirRead(reply)));
322
323        let err = expect_complete_err(&mut list, None);
324        assert!(matches!(err, M2dirListError::MissingArg));
325    }
326
327    // --- utils
328
329    fn expect_wants_dir_read(cor: &mut M2dirList, arg: Option<M2dirArg>) -> BTreeSet<M2dirPath> {
330        match cor.resume(arg) {
331            M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => paths,
332            state => panic!("expected WantsDirRead, got {state:?}"),
333        }
334    }
335
336    fn expect_wants_file_exists(cor: &mut M2dirList, arg: Option<M2dirArg>) -> BTreeSet<M2dirPath> {
337        match cor.resume(arg) {
338            M2dirCoroutineState::Yielded(M2dirYield::WantsFileExists(probes)) => probes,
339            state => panic!("expected WantsFileExists, got {state:?}"),
340        }
341    }
342
343    fn expect_complete_err(cor: &mut M2dirList, arg: Option<M2dirArg>) -> M2dirListError {
344        match cor.resume(arg) {
345            M2dirCoroutineState::Complete(Err(err)) => err,
346            state => panic!("expected Complete(Err), got {state:?}"),
347        }
348    }
349}