Skip to main content

io_vdir/item/
list.rs

1//! I/O-free coroutine listing every item inside a Vdir collection.
2//!
3//! Entries with a `.vcf` or `.ics` extension are considered items;
4//! anything else (metadata files, dotfiles, leftover temporaries) is
5//! skipped.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! use std::{collections::{BTreeMap, BTreeSet}, fs};
11//!
12//! use io_vdir::{coroutine::*, item::list::*, path::VdirPath};
13//!
14//! let opts = VdirItemListOptions::default();
15//! let mut coroutine = VdirItemList::new("/tmp/vdir/contacts", opts);
16//! let mut arg = None;
17//!
18//! let items = loop {
19//!     match coroutine.resume(arg.take()) {
20//!         VdirCoroutineState::Yielded(VdirYield::WantsDirRead(paths)) => {
21//!             let mut out = BTreeMap::new();
22//!             for path in paths {
23//!                 let mut names = BTreeSet::new();
24//!                 if let Ok(rd) = fs::read_dir(path.as_str()) {
25//!                     for entry in rd.flatten() {
26//!                         names.insert(VdirPath::new(entry.path().to_string_lossy()));
27//!                     }
28//!                 }
29//!                 out.insert(path, names);
30//!             }
31//!             arg = Some(VdirReply::DirRead(out));
32//!         }
33//!         VdirCoroutineState::Yielded(VdirYield::WantsFileRead(paths)) => {
34//!             let map = paths
35//!                 .into_iter()
36//!                 .map(|p| {
37//!                     let bytes = fs::read(p.as_str()).unwrap_or_default();
38//!                     (p, bytes)
39//!                 })
40//!                 .collect();
41//!             arg = Some(VdirReply::FileRead(map));
42//!         }
43//!         VdirCoroutineState::Complete(Ok(items)) => break items,
44//!         VdirCoroutineState::Complete(Err(err)) => panic!("{err}"),
45//!         state => panic!("unexpected state {state:?}"),
46//!     }
47//! };
48//!
49//! println!("found {} items", items.len());
50//! ```
51
52use core::{fmt, mem};
53
54use alloc::collections::{BTreeMap, BTreeSet};
55
56use log::trace;
57use thiserror::Error;
58
59use crate::{
60    coroutine::*,
61    item::{VdirItem, VdirItemKind},
62    path::VdirPath,
63};
64
65/// Failure causes during a [`VdirItemList`] step.
66#[derive(Clone, Debug, Error)]
67pub enum VdirItemListError {
68    /// The driver fed back a reply that does not match the pending
69    /// request.
70    #[error("Vdir item list failed: unexpected arg {0:?}")]
71    UnexpectedArg(Option<VdirReply>),
72}
73
74/// Options for [`VdirItemList::new`].
75#[derive(Clone, Debug, Default, Eq, PartialEq)]
76pub struct VdirItemListOptions {}
77
78/// Lists every `.vcf` / `.ics` item inside a Vdir collection.
79#[derive(Debug)]
80pub struct VdirItemList {
81    state: State,
82    #[allow(dead_code)]
83    opts: VdirItemListOptions,
84}
85
86impl VdirItemList {
87    /// Creates a new coroutine that will list every item inside
88    /// `collection`.
89    pub fn new(collection: impl Into<VdirPath>, opts: VdirItemListOptions) -> Self {
90        Self {
91            opts,
92            state: State::Start(collection.into()),
93        }
94    }
95}
96
97impl VdirCoroutine for VdirItemList {
98    type Yield = VdirYield;
99    type Return = Result<BTreeSet<VdirItem>, VdirItemListError>;
100
101    fn resume(&mut self, arg: Option<VdirReply>) -> VdirCoroutineState<Self::Yield, Self::Return> {
102        match (&mut self.state, arg) {
103            (State::Start(collection), None) => {
104                let paths = BTreeSet::from_iter([mem::take(collection)]);
105                self.state = State::AwaitDirRead;
106                VdirCoroutineState::Yielded(VdirYield::WantsDirRead(paths))
107            }
108            (State::AwaitDirRead, Some(VdirReply::DirRead(entries))) => {
109                let mut kinds: BTreeMap<VdirPath, VdirItemKind> = BTreeMap::new();
110
111                for paths in entries.into_values() {
112                    for path in paths {
113                        let Some(name) = path.file_name() else {
114                            continue;
115                        };
116
117                        let Some((_, ext)) = name.rsplit_once('.') else {
118                            continue;
119                        };
120
121                        let Some(kind) = VdirItemKind::from_extension(ext) else {
122                            continue;
123                        };
124
125                        kinds.insert(path, kind);
126                    }
127                }
128
129                if kinds.is_empty() {
130                    return VdirCoroutineState::Complete(Ok(BTreeSet::new()));
131                }
132
133                let probes: BTreeSet<VdirPath> = kinds.keys().cloned().collect();
134                self.state = State::AwaitFileRead { kinds };
135                VdirCoroutineState::Yielded(VdirYield::WantsFileRead(probes))
136            }
137            (State::AwaitFileRead { kinds }, Some(VdirReply::FileRead(mut contents))) => {
138                let mut items = BTreeSet::new();
139
140                for (path, kind) in mem::take(kinds) {
141                    let bytes = contents.remove(&path).unwrap_or_default();
142                    items.insert(VdirItem {
143                        path,
144                        kind,
145                        contents: bytes,
146                    });
147                }
148
149                trace!("listed {} items", items.len());
150                VdirCoroutineState::Complete(Ok(items))
151            }
152            (_, arg) => {
153                let err = VdirItemListError::UnexpectedArg(arg);
154                VdirCoroutineState::Complete(Err(err))
155            }
156        }
157    }
158}
159
160#[derive(Debug)]
161enum State {
162    Start(VdirPath),
163    AwaitDirRead,
164    AwaitFileRead {
165        kinds: BTreeMap<VdirPath, VdirItemKind>,
166    },
167}
168
169impl fmt::Display for State {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        match self {
172            Self::Start(_) => f.write_str("start"),
173            Self::AwaitDirRead => f.write_str("await dir read reply"),
174            Self::AwaitFileRead { .. } => f.write_str("await file read reply"),
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn reads_only_vcf_and_ics_entries() {
185        let mut cor = VdirItemList::new("root/contacts", VdirItemListOptions::default());
186
187        let paths = expect_wants_dir_read(&mut cor);
188        assert!(paths.contains(&VdirPath::from("root/contacts")));
189
190        let vcf = VdirPath::from("root/contacts/alice.vcf");
191        let mut entries = BTreeMap::new();
192        entries.insert(
193            VdirPath::from("root/contacts"),
194            BTreeSet::from_iter([
195                vcf.clone(),
196                VdirPath::from("root/contacts/displayname"),
197                VdirPath::from("root/contacts/alice.vcf.tmp"),
198            ]),
199        );
200
201        let probes = match cor.resume(Some(VdirReply::DirRead(entries))) {
202            VdirCoroutineState::Yielded(VdirYield::WantsFileRead(probes)) => probes,
203            state => panic!("expected WantsFileRead, got {state:?}"),
204        };
205        assert_eq!(probes.len(), 1);
206        assert!(probes.contains(&vcf));
207
208        let mut contents = BTreeMap::new();
209        contents.insert(vcf.clone(), b"BEGIN:VCARD".to_vec());
210        let items = match cor.resume(Some(VdirReply::FileRead(contents))) {
211            VdirCoroutineState::Complete(Ok(items)) => items,
212            state => panic!("expected Complete(Ok), got {state:?}"),
213        };
214        assert_eq!(items.len(), 1);
215        assert_eq!(items.iter().next().unwrap().kind, VdirItemKind::Vcard);
216    }
217
218    #[test]
219    fn empty_collection_yields_no_items() {
220        let mut cor = VdirItemList::new("root/contacts", VdirItemListOptions::default());
221        let _ = expect_wants_dir_read(&mut cor);
222
223        let mut entries = BTreeMap::new();
224        entries.insert(VdirPath::from("root/contacts"), BTreeSet::new());
225        let items = match cor.resume(Some(VdirReply::DirRead(entries))) {
226            VdirCoroutineState::Complete(Ok(items)) => items,
227            state => panic!("expected Complete(Ok), got {state:?}"),
228        };
229        assert!(items.is_empty());
230    }
231
232    #[test]
233    fn unexpected_reply_returns_error() {
234        let mut cor = VdirItemList::new("root/contacts", VdirItemListOptions::default());
235        let _ = expect_wants_dir_read(&mut cor);
236
237        let err = match cor.resume(Some(VdirReply::DirCreate)) {
238            VdirCoroutineState::Complete(Err(err)) => err,
239            state => panic!("expected Complete(Err), got {state:?}"),
240        };
241        assert!(matches!(err, VdirItemListError::UnexpectedArg(_)));
242    }
243
244    fn expect_wants_dir_read(cor: &mut VdirItemList) -> BTreeSet<VdirPath> {
245        match cor.resume(None) {
246            VdirCoroutineState::Yielded(VdirYield::WantsDirRead(paths)) => paths,
247            state => panic!("expected WantsDirRead, got {state:?}"),
248        }
249    }
250}