1use 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#[derive(Clone, Debug, Error)]
91pub enum VdirCollectionListError {
92 #[error("Vdir collection list failed: unexpected arg {0:?}")]
95 UnexpectedArg(Option<VdirReply>),
96}
97
98#[derive(Clone, Debug, Default, Eq, PartialEq)]
100pub struct VdirCollectionListOptions {}
101
102#[derive(Debug)]
104pub struct VdirCollectionList {
105 state: State,
106 #[allow(dead_code)]
107 opts: VdirCollectionListOptions,
108}
109
110impl VdirCollectionList {
111 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}