Skip to main content

io_m2dir/entry/
list.rs

1//! I/O-free coroutine to list every entry inside an m2dir.
2//!
3//! Dotfiles, sub-directories and filenames that do not match the
4//! m2dir specification (no `,` separator) are skipped. Returned
5//! entries are not checksum-verified; use [`M2dirEntryGet`] when
6//! validation is required.
7//!
8//! [`M2dirEntryGet`]: crate::entry::get::M2dirEntryGet
9//!
10//! # Example
11//!
12//! ```rust,no_run
13//! use std::{collections::BTreeMap, fs};
14//!
15//! use io_m2dir::{
16//!     coroutine::{M2dirArg, M2dirCoroutine, M2dirCoroutineState, M2dirYield},
17//!     m2dir::types::M2dir,
18//!     entry::list::{M2dirEntryList, M2dirEntryListOptions},
19//! };
20//!
21//! let m2dir = M2dir::from_path("/tmp/inbox");
22//! let opts = M2dirEntryListOptions::default();
23//! let mut coroutine = M2dirEntryList::new(m2dir, opts);
24//! let mut arg = None;
25//!
26//! let entries = loop {
27//!     match coroutine.resume(arg.take()) {
28//!         M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => {
29//!             let mut out = BTreeMap::new();
30//!             for path in paths {
31//!                 let mut names = Default::default();
32//!                 if let Ok(rd) = fs::read_dir(path.as_str()) {
33//!                     names = rd.flatten().map(|e| e.path().into()).collect();
34//!                 }
35//!                 out.insert(path, names);
36//!             }
37//!             arg = Some(M2dirArg::DirRead(out));
38//!         }
39//!         M2dirCoroutineState::Yielded(M2dirYield::WantsFileExists(probes)) => {
40//!             let map = probes
41//!                 .into_iter()
42//!                 .map(|p| {
43//!                     let exists = fs::metadata(p.as_str())
44//!                         .map_or(false, |m| m.is_file());
45//!                     (p, exists)
46//!                 })
47//!                 .collect();
48//!             arg = Some(M2dirArg::FileExists(map));
49//!         }
50//!         M2dirCoroutineState::Complete(Ok(entries)) => break entries,
51//!         M2dirCoroutineState::Complete(Err(err)) => panic!("{err}"),
52//!         state => panic!("unexpected state {state:?}"),
53//!     }
54//! };
55//!
56//! println!("{} entries", entries.len());
57//! ```
58
59use core::{fmt, mem};
60
61use alloc::{
62    collections::{BTreeMap, BTreeSet},
63    string::{String, ToString},
64    vec::Vec,
65};
66
67use log::trace;
68use thiserror::Error;
69
70use crate::{coroutine::*, entry::types::M2dirEntry, m2dir::types::M2dir, path::M2dirPath};
71
72/// Failure causes during the m2dir LIST flow.
73#[derive(Clone, Debug, Error)]
74pub enum M2dirEntryListError {
75    #[error("M2DIR LIST failed: unexpected coroutine arg")]
76    UnexpectedArg,
77    #[error("M2DIR LIST failed: missing coroutine arg")]
78    MissingArg,
79}
80
81/// Options for [`M2dirEntryList::new`].
82#[derive(Clone, Debug, Default, Eq, PartialEq)]
83pub struct M2dirEntryListOptions {}
84
85/// I/O-free m2dir entry LIST coroutine.
86pub struct M2dirEntryList {
87    m2dir: M2dir,
88    state: State,
89    #[allow(dead_code)]
90    opts: M2dirEntryListOptions,
91}
92
93impl M2dirEntryList {
94    /// Creates a new coroutine that will list every entry inside
95    /// `m2dir`.
96    pub fn new(m2dir: M2dir, opts: M2dirEntryListOptions) -> Self {
97        Self {
98            m2dir,
99            state: State::Start,
100            opts,
101        }
102    }
103}
104
105impl M2dirCoroutine for M2dirEntryList {
106    type Yield = M2dirYield;
107    type Return = Result<Vec<M2dirEntry>, M2dirEntryListError>;
108
109    fn resume(&mut self, arg: Option<M2dirArg>) -> M2dirCoroutineState<Self::Yield, Self::Return> {
110        trace!("list entries: {}", self.state);
111
112        match (&mut self.state, arg) {
113            (State::Start, None) => {
114                trace!("wants directory read of {}", self.m2dir.path());
115                let paths = BTreeSet::from_iter([self.m2dir.path().clone()]);
116                self.state = State::Reading;
117                M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths))
118            }
119            (State::Reading, Some(M2dirArg::DirRead(entries))) => {
120                let mut candidates = BTreeMap::new();
121
122                for (_dir, names) in entries {
123                    for path in names {
124                        let Some(name) = path.file_name() else {
125                            continue;
126                        };
127
128                        if name.starts_with('.') {
129                            continue;
130                        }
131
132                        let Some(id) = M2dir::parse_filename_id(name) else {
133                            trace!("skipping unparseable entry filename: {name}");
134                            continue;
135                        };
136
137                        candidates.insert(path.clone(), id.to_string());
138                    }
139                }
140
141                if candidates.is_empty() {
142                    trace!("no candidate entries");
143                    return M2dirCoroutineState::Complete(Ok(Vec::new()));
144                }
145
146                let probes: BTreeSet<M2dirPath> = candidates.keys().cloned().collect();
147                trace!("wants existence check for {} candidates", probes.len());
148
149                self.state = State::Checking { candidates };
150                M2dirCoroutineState::Yielded(M2dirYield::WantsFileExists(probes))
151            }
152            (State::Checking { candidates }, Some(M2dirArg::FileExists(probes))) => {
153                let candidates = mem::take(candidates);
154                let mut found = Vec::new();
155
156                for (path, id) in candidates {
157                    if probes.get(&path).copied().unwrap_or(false) {
158                        found.push(M2dirEntry::from_parts(id, path));
159                    }
160                }
161
162                trace!("found {} entries", found.len());
163                M2dirCoroutineState::Complete(Ok(found))
164            }
165            (_, Some(_)) => {
166                let err = M2dirEntryListError::UnexpectedArg;
167                M2dirCoroutineState::Complete(Err(err))
168            }
169            (_, None) => {
170                let err = M2dirEntryListError::MissingArg;
171                M2dirCoroutineState::Complete(Err(err))
172            }
173        }
174    }
175}
176
177enum State {
178    Start,
179    Reading,
180    Checking {
181        candidates: BTreeMap<M2dirPath, String>,
182    },
183}
184
185impl fmt::Display for State {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        match self {
188            Self::Start => f.write_str("start"),
189            Self::Reading => f.write_str("reading directory"),
190            Self::Checking { .. } => f.write_str("checking candidates"),
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn empty_directory_returns_empty_list() {
201        let m2dir = M2dir::from_path("/tmp/inbox");
202        let mut list = M2dirEntryList::new(m2dir, M2dirEntryListOptions::default());
203
204        let probes = expect_wants_dir_read(&mut list, None);
205        let dir = probes.into_iter().next().unwrap();
206        let mut reply = BTreeMap::new();
207        reply.insert(dir, BTreeSet::new());
208
209        let entries = match list.resume(Some(M2dirArg::DirRead(reply))) {
210            M2dirCoroutineState::Complete(Ok(entries)) => entries,
211            state => panic!("expected Complete(Ok), got {state:?}"),
212        };
213        assert!(entries.is_empty());
214    }
215
216    #[test]
217    fn skips_unparseable_filenames_and_dotfiles() {
218        let m2dir = M2dir::from_path("/tmp/inbox");
219        let mut list = M2dirEntryList::new(m2dir, M2dirEntryListOptions::default());
220
221        let probes = expect_wants_dir_read(&mut list, None);
222        let dir = probes.into_iter().next().unwrap();
223
224        let mut names = BTreeSet::new();
225        names.insert(M2dirPath::from("/tmp/inbox/.meta"));
226        names.insert(M2dirPath::from("/tmp/inbox/garbage"));
227
228        let mut reply = BTreeMap::new();
229        reply.insert(dir, names);
230
231        let entries = match list.resume(Some(M2dirArg::DirRead(reply))) {
232            M2dirCoroutineState::Complete(Ok(entries)) => entries,
233            state => panic!("expected Complete(Ok), got {state:?}"),
234        };
235        assert!(entries.is_empty());
236    }
237
238    #[test]
239    fn missing_arg_at_reading_returns_missing_arg_error() {
240        let m2dir = M2dir::from_path("/tmp/inbox");
241        let mut list = M2dirEntryList::new(m2dir, M2dirEntryListOptions::default());
242        let _ = expect_wants_dir_read(&mut list, None);
243
244        let err = expect_complete_err(&mut list, None);
245        assert!(matches!(err, M2dirEntryListError::MissingArg));
246    }
247
248    #[test]
249    fn unexpected_arg_at_start_returns_unexpected_arg_error() {
250        let m2dir = M2dir::from_path("/tmp/inbox");
251        let mut list = M2dirEntryList::new(m2dir, M2dirEntryListOptions::default());
252
253        let err = expect_complete_err(&mut list, Some(M2dirArg::DirCreate));
254        assert!(matches!(err, M2dirEntryListError::UnexpectedArg));
255    }
256
257    #[test]
258    fn unexpected_arg_kind_at_reading_returns_unexpected_arg_error() {
259        let m2dir = M2dir::from_path("/tmp/inbox");
260        let mut list = M2dirEntryList::new(m2dir, M2dirEntryListOptions::default());
261        let _ = expect_wants_dir_read(&mut list, None);
262
263        let err = expect_complete_err(&mut list, Some(M2dirArg::FileCreate));
264        assert!(matches!(err, M2dirEntryListError::UnexpectedArg));
265    }
266
267    // --- utils
268
269    fn expect_wants_dir_read(
270        cor: &mut M2dirEntryList,
271        arg: Option<M2dirArg>,
272    ) -> BTreeSet<M2dirPath> {
273        match cor.resume(arg) {
274            M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => paths,
275            state => panic!("expected WantsDirRead, got {state:?}"),
276        }
277    }
278
279    fn expect_complete_err(cor: &mut M2dirEntryList, arg: Option<M2dirArg>) -> M2dirEntryListError {
280        match cor.resume(arg) {
281            M2dirCoroutineState::Complete(Err(err)) => err,
282            state => panic!("expected Complete(Err), got {state:?}"),
283        }
284    }
285}