Skip to main content

io_vdir/collection/
list.rs

1//! I/O-free coroutine listing every Vdir collection directly under a
2//! root directory.
3//!
4//! A collection is any subdirectory of the root; existing
5//! `displayname`, `description` and `color` marker files are loaded to
6//! populate the corresponding optional fields.
7//!
8//! # Example
9//!
10//! ```rust,no_run
11//! use std::{collections::{BTreeMap, BTreeSet}, fs};
12//!
13//! use io_vdir::{collection::list::*, coroutine::*, path::VdirPath};
14//!
15//! let opts = VdirCollectionListOptions::default();
16//! let mut coroutine = VdirCollectionList::new("/tmp/vdir", opts);
17//! let mut arg = None;
18//!
19//! let collections = loop {
20//!     match coroutine.resume(arg.take()) {
21//!         VdirCoroutineState::Yielded(VdirYield::WantsDirRead(paths)) => {
22//!             let mut out = BTreeMap::new();
23//!             for path in paths {
24//!                 let mut names = BTreeSet::new();
25//!                 if let Ok(rd) = fs::read_dir(path.as_str()) {
26//!                     for entry in rd.flatten() {
27//!                         names.insert(VdirPath::new(entry.path().to_string_lossy()));
28//!                     }
29//!                 }
30//!                 out.insert(path, names);
31//!             }
32//!             arg = Some(VdirReply::DirRead(out));
33//!         }
34//!         VdirCoroutineState::Yielded(VdirYield::WantsDirExists(paths)) => {
35//!             let map = paths
36//!                 .into_iter()
37//!                 .map(|p| {
38//!                     let ok = fs::metadata(p.as_str()).map(|m| m.is_dir()).unwrap_or(false);
39//!                     (p, ok)
40//!                 })
41//!                 .collect();
42//!             arg = Some(VdirReply::DirExists(map));
43//!         }
44//!         VdirCoroutineState::Yielded(VdirYield::WantsFileExists(paths)) => {
45//!             let map = paths
46//!                 .into_iter()
47//!                 .map(|p| {
48//!                     let ok = fs::metadata(p.as_str()).map(|m| m.is_file()).unwrap_or(false);
49//!                     (p, ok)
50//!                 })
51//!                 .collect();
52//!             arg = Some(VdirReply::FileExists(map));
53//!         }
54//!         VdirCoroutineState::Yielded(VdirYield::WantsFileRead(paths)) => {
55//!             let map = paths
56//!                 .into_iter()
57//!                 .map(|p| {
58//!                     let bytes = fs::read(p.as_str()).unwrap_or_default();
59//!                     (p, bytes)
60//!                 })
61//!                 .collect();
62//!             arg = Some(VdirReply::FileRead(map));
63//!         }
64//!         VdirCoroutineState::Complete(Ok(collections)) => break collections,
65//!         VdirCoroutineState::Complete(Err(err)) => panic!("{err}"),
66//!         state => panic!("unexpected state {state:?}"),
67//!     }
68//! };
69//!
70//! println!("found {} collections", collections.len());
71//! ```
72
73use core::{fmt, mem};
74
75use alloc::{
76    collections::{BTreeMap, BTreeSet},
77    string::{String, ToString},
78};
79
80use log::trace;
81use thiserror::Error;
82
83use crate::{
84    collection::{COLOR, DESCRIPTION, DISPLAYNAME, VdirCollection},
85    coroutine::*,
86    path::VdirPath,
87};
88
89/// Failure causes during a [`VdirCollectionList`] step.
90#[derive(Clone, Debug, Error)]
91pub enum VdirCollectionListError {
92    /// The driver fed back a reply that does not match the pending
93    /// request.
94    #[error("Vdir collection list failed: unexpected arg {0:?}")]
95    UnexpectedArg(Option<VdirReply>),
96}
97
98/// Options for [`VdirCollectionList::new`].
99#[derive(Clone, Debug, Default, Eq, PartialEq)]
100pub struct VdirCollectionListOptions {}
101
102/// Lists every Vdir collection directly under a root directory.
103#[derive(Debug)]
104pub struct VdirCollectionList {
105    state: State,
106    #[allow(dead_code)]
107    opts: VdirCollectionListOptions,
108}
109
110impl VdirCollectionList {
111    /// Creates a new coroutine that will list collections inside
112    /// `root`.
113    pub fn new(root: impl Into<VdirPath>, opts: VdirCollectionListOptions) -> Self {
114        Self {
115            opts,
116            state: State::Start(root.into()),
117        }
118    }
119}
120
121impl VdirCoroutine for VdirCollectionList {
122    type Yield = VdirYield;
123    type Return = Result<BTreeSet<VdirCollection>, VdirCollectionListError>;
124
125    fn resume(&mut self, arg: Option<VdirReply>) -> VdirCoroutineState<Self::Yield, Self::Return> {
126        match (&mut self.state, arg) {
127            (State::Start(root), None) => {
128                let paths = BTreeSet::from_iter([mem::take(root)]);
129                self.state = State::AwaitChildrenRead;
130                VdirCoroutineState::Yielded(VdirYield::WantsDirRead(paths))
131            }
132            (State::AwaitChildrenRead, Some(VdirReply::DirRead(entries))) => {
133                let mut candidates = BTreeSet::new();
134
135                for paths in entries.into_values() {
136                    for path in paths {
137                        let Some(name) = path.file_name() else {
138                            continue;
139                        };
140
141                        if name.starts_with('.') {
142                            continue;
143                        }
144
145                        candidates.insert(path);
146                    }
147                }
148
149                if candidates.is_empty() {
150                    return VdirCoroutineState::Complete(Ok(BTreeSet::new()));
151                }
152
153                let probes = candidates.clone();
154                self.state = State::AwaitDirExists { candidates };
155                VdirCoroutineState::Yielded(VdirYield::WantsDirExists(probes))
156            }
157            (State::AwaitDirExists { candidates }, Some(VdirReply::DirExists(probes))) => {
158                let collection_paths: BTreeSet<VdirPath> = mem::take(candidates)
159                    .into_iter()
160                    .filter(|p| probes.get(p).copied().unwrap_or(false))
161                    .collect();
162
163                if collection_paths.is_empty() {
164                    return VdirCoroutineState::Complete(Ok(BTreeSet::new()));
165                }
166
167                let mut probes = BTreeMap::new();
168
169                for path in &collection_paths {
170                    probes.insert(path.join(DISPLAYNAME), path.clone());
171                    probes.insert(path.join(DESCRIPTION), path.clone());
172                    probes.insert(path.join(COLOR), path.clone());
173                }
174
175                let probe_paths: BTreeSet<VdirPath> = probes.keys().cloned().collect();
176                self.state = State::AwaitMetadataExists {
177                    collection_paths,
178                    probes,
179                };
180                VdirCoroutineState::Yielded(VdirYield::WantsFileExists(probe_paths))
181            }
182            (
183                State::AwaitMetadataExists {
184                    collection_paths,
185                    probes,
186                },
187                Some(VdirReply::FileExists(exists)),
188            ) => {
189                let collection_paths = mem::take(collection_paths);
190                let probes: BTreeMap<VdirPath, VdirPath> = mem::take(probes)
191                    .into_iter()
192                    .filter(|(probe, _)| exists.get(probe).copied().unwrap_or(false))
193                    .collect();
194
195                if probes.is_empty() {
196                    let collections = collection_paths
197                        .into_iter()
198                        .map(VdirCollection::from_path)
199                        .collect();
200                    return VdirCoroutineState::Complete(Ok(collections));
201                }
202
203                let probe_paths: BTreeSet<VdirPath> = probes.keys().cloned().collect();
204                self.state = State::AwaitMetadataRead {
205                    collection_paths,
206                    probes,
207                };
208                VdirCoroutineState::Yielded(VdirYield::WantsFileRead(probe_paths))
209            }
210            (
211                State::AwaitMetadataRead {
212                    collection_paths,
213                    probes,
214                },
215                Some(VdirReply::FileRead(mut contents)),
216            ) => {
217                let mut by_path: BTreeMap<VdirPath, VdirCollection> = mem::take(collection_paths)
218                    .into_iter()
219                    .map(|path| (path.clone(), VdirCollection::from_path(path)))
220                    .collect();
221
222                for (probe, owner) in mem::take(probes) {
223                    let Some(bytes) = contents.remove(&probe) else {
224                        continue;
225                    };
226
227                    let Some(collection) = by_path.get_mut(&owner) else {
228                        continue;
229                    };
230
231                    let Some(name) = probe.file_name() else {
232                        continue;
233                    };
234
235                    let value = String::from_utf8_lossy(&bytes).trim().to_string();
236                    if value.is_empty() {
237                        continue;
238                    }
239
240                    match name {
241                        DISPLAYNAME => collection.display_name = Some(value),
242                        DESCRIPTION => collection.description = Some(value),
243                        COLOR => collection.color = Some(value),
244                        _ => {}
245                    }
246                }
247
248                let collections: BTreeSet<VdirCollection> = by_path.into_values().collect();
249                trace!("found {} collections", collections.len());
250                VdirCoroutineState::Complete(Ok(collections))
251            }
252            (_, arg) => {
253                let err = VdirCollectionListError::UnexpectedArg(arg);
254                VdirCoroutineState::Complete(Err(err))
255            }
256        }
257    }
258}
259
260#[derive(Debug)]
261enum State {
262    Start(VdirPath),
263    AwaitChildrenRead,
264    AwaitDirExists {
265        candidates: BTreeSet<VdirPath>,
266    },
267    AwaitMetadataExists {
268        collection_paths: BTreeSet<VdirPath>,
269        probes: BTreeMap<VdirPath, VdirPath>,
270    },
271    AwaitMetadataRead {
272        collection_paths: BTreeSet<VdirPath>,
273        probes: BTreeMap<VdirPath, VdirPath>,
274    },
275}
276
277impl fmt::Display for State {
278    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279        match self {
280            Self::Start(_) => f.write_str("start"),
281            Self::AwaitChildrenRead => f.write_str("await children read reply"),
282            Self::AwaitDirExists { .. } => f.write_str("await dir exists reply"),
283            Self::AwaitMetadataExists { .. } => f.write_str("await metadata exists reply"),
284            Self::AwaitMetadataRead { .. } => f.write_str("await metadata read reply"),
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn empty_root_yields_no_collections() {
295        let mut cor = VdirCollectionList::new("root", VdirCollectionListOptions::default());
296
297        let paths = expect_wants_dir_read(&mut cor);
298        assert!(paths.contains(&VdirPath::from("root")));
299
300        let mut entries = BTreeMap::new();
301        entries.insert(VdirPath::from("root"), BTreeSet::new());
302
303        let collections = expect_complete_ok(&mut cor, Some(VdirReply::DirRead(entries)));
304        assert!(collections.is_empty());
305    }
306
307    #[test]
308    fn bare_collection_without_metadata() {
309        let mut cor = VdirCollectionList::new("root", VdirCollectionListOptions::default());
310        let _ = expect_wants_dir_read(&mut cor);
311
312        let mut entries = BTreeMap::new();
313        let contacts = VdirPath::from("root/contacts");
314        entries.insert(
315            VdirPath::from("root"),
316            BTreeSet::from_iter([contacts.clone()]),
317        );
318
319        let probes = match cor.resume(Some(VdirReply::DirRead(entries))) {
320            VdirCoroutineState::Yielded(VdirYield::WantsDirExists(probes)) => probes,
321            state => panic!("expected WantsDirExists, got {state:?}"),
322        };
323        assert!(probes.contains(&contacts));
324
325        let mut exists = BTreeMap::new();
326        exists.insert(contacts.clone(), true);
327
328        let metadata = match cor.resume(Some(VdirReply::DirExists(exists))) {
329            VdirCoroutineState::Yielded(VdirYield::WantsFileExists(probes)) => probes,
330            state => panic!("expected WantsFileExists, got {state:?}"),
331        };
332        assert!(metadata.contains(&contacts.join(DISPLAYNAME)));
333
334        let absent: BTreeMap<VdirPath, bool> = metadata.into_iter().map(|p| (p, false)).collect();
335        let collections = expect_complete_ok(&mut cor, Some(VdirReply::FileExists(absent)));
336        assert_eq!(collections.len(), 1);
337        assert_eq!(collections.iter().next().unwrap().id(), "contacts");
338    }
339
340    #[test]
341    fn dotfiles_are_skipped() {
342        let mut cor = VdirCollectionList::new("root", VdirCollectionListOptions::default());
343        let _ = expect_wants_dir_read(&mut cor);
344
345        let mut entries = BTreeMap::new();
346        entries.insert(
347            VdirPath::from("root"),
348            BTreeSet::from_iter([VdirPath::from("root/.hidden")]),
349        );
350
351        let collections = expect_complete_ok(&mut cor, Some(VdirReply::DirRead(entries)));
352        assert!(collections.is_empty());
353    }
354
355    #[test]
356    fn unexpected_reply_returns_error() {
357        let mut cor = VdirCollectionList::new("root", VdirCollectionListOptions::default());
358        let _ = expect_wants_dir_read(&mut cor);
359
360        let err = match cor.resume(Some(VdirReply::DirCreate)) {
361            VdirCoroutineState::Complete(Err(err)) => err,
362            state => panic!("expected Complete(Err), got {state:?}"),
363        };
364        assert!(matches!(err, VdirCollectionListError::UnexpectedArg(_)));
365    }
366
367    fn expect_wants_dir_read(cor: &mut VdirCollectionList) -> BTreeSet<VdirPath> {
368        match cor.resume(None) {
369            VdirCoroutineState::Yielded(VdirYield::WantsDirRead(paths)) => paths,
370            state => panic!("expected WantsDirRead, got {state:?}"),
371        }
372    }
373
374    fn expect_complete_ok(
375        cor: &mut VdirCollectionList,
376        arg: Option<VdirReply>,
377    ) -> BTreeSet<VdirCollection> {
378        match cor.resume(arg) {
379            VdirCoroutineState::Complete(Ok(collections)) => collections,
380            state => panic!("expected Complete(Ok), got {state:?}"),
381        }
382    }
383}