use core::{fmt, mem};
use alloc::{
collections::{BTreeMap, BTreeSet},
string::{String, ToString},
};
use log::trace;
use thiserror::Error;
use crate::{
collection::{COLOR, DESCRIPTION, DISPLAYNAME, VdirCollection},
coroutine::*,
path::VdirPath,
};
#[derive(Clone, Debug, Error)]
pub enum VdirCollectionListError {
#[error("Vdir collection list failed: unexpected arg {0:?}")]
UnexpectedArg(Option<VdirReply>),
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct VdirCollectionListOptions {}
#[derive(Debug)]
pub struct VdirCollectionList {
state: State,
#[allow(dead_code)]
opts: VdirCollectionListOptions,
}
impl VdirCollectionList {
pub fn new(root: impl Into<VdirPath>, opts: VdirCollectionListOptions) -> Self {
Self {
opts,
state: State::Start(root.into()),
}
}
}
impl VdirCoroutine for VdirCollectionList {
type Yield = VdirYield;
type Return = Result<BTreeSet<VdirCollection>, VdirCollectionListError>;
fn resume(&mut self, arg: Option<VdirReply>) -> VdirCoroutineState<Self::Yield, Self::Return> {
match (&mut self.state, arg) {
(State::Start(root), None) => {
let paths = BTreeSet::from_iter([mem::take(root)]);
self.state = State::AwaitChildrenRead;
VdirCoroutineState::Yielded(VdirYield::WantsDirRead(paths))
}
(State::AwaitChildrenRead, Some(VdirReply::DirRead(entries))) => {
let mut candidates = BTreeSet::new();
for paths in entries.into_values() {
for path in paths {
let Some(name) = path.file_name() else {
continue;
};
if name.starts_with('.') {
continue;
}
candidates.insert(path);
}
}
if candidates.is_empty() {
return VdirCoroutineState::Complete(Ok(BTreeSet::new()));
}
let probes = candidates.clone();
self.state = State::AwaitDirExists { candidates };
VdirCoroutineState::Yielded(VdirYield::WantsDirExists(probes))
}
(State::AwaitDirExists { candidates }, Some(VdirReply::DirExists(probes))) => {
let collection_paths: BTreeSet<VdirPath> = mem::take(candidates)
.into_iter()
.filter(|p| probes.get(p).copied().unwrap_or(false))
.collect();
if collection_paths.is_empty() {
return VdirCoroutineState::Complete(Ok(BTreeSet::new()));
}
let mut probes = BTreeMap::new();
for path in &collection_paths {
probes.insert(path.join(DISPLAYNAME), path.clone());
probes.insert(path.join(DESCRIPTION), path.clone());
probes.insert(path.join(COLOR), path.clone());
}
let probe_paths: BTreeSet<VdirPath> = probes.keys().cloned().collect();
self.state = State::AwaitMetadataExists {
collection_paths,
probes,
};
VdirCoroutineState::Yielded(VdirYield::WantsFileExists(probe_paths))
}
(
State::AwaitMetadataExists {
collection_paths,
probes,
},
Some(VdirReply::FileExists(exists)),
) => {
let collection_paths = mem::take(collection_paths);
let probes: BTreeMap<VdirPath, VdirPath> = mem::take(probes)
.into_iter()
.filter(|(probe, _)| exists.get(probe).copied().unwrap_or(false))
.collect();
if probes.is_empty() {
let collections = collection_paths
.into_iter()
.map(VdirCollection::from_path)
.collect();
return VdirCoroutineState::Complete(Ok(collections));
}
let probe_paths: BTreeSet<VdirPath> = probes.keys().cloned().collect();
self.state = State::AwaitMetadataRead {
collection_paths,
probes,
};
VdirCoroutineState::Yielded(VdirYield::WantsFileRead(probe_paths))
}
(
State::AwaitMetadataRead {
collection_paths,
probes,
},
Some(VdirReply::FileRead(mut contents)),
) => {
let mut by_path: BTreeMap<VdirPath, VdirCollection> = mem::take(collection_paths)
.into_iter()
.map(|path| (path.clone(), VdirCollection::from_path(path)))
.collect();
for (probe, owner) in mem::take(probes) {
let Some(bytes) = contents.remove(&probe) else {
continue;
};
let Some(collection) = by_path.get_mut(&owner) else {
continue;
};
let Some(name) = probe.file_name() else {
continue;
};
let value = String::from_utf8_lossy(&bytes).trim().to_string();
if value.is_empty() {
continue;
}
match name {
DISPLAYNAME => collection.display_name = Some(value),
DESCRIPTION => collection.description = Some(value),
COLOR => collection.color = Some(value),
_ => {}
}
}
let collections: BTreeSet<VdirCollection> = by_path.into_values().collect();
trace!("found {} collections", collections.len());
VdirCoroutineState::Complete(Ok(collections))
}
(_, arg) => {
let err = VdirCollectionListError::UnexpectedArg(arg);
VdirCoroutineState::Complete(Err(err))
}
}
}
}
#[derive(Debug)]
enum State {
Start(VdirPath),
AwaitChildrenRead,
AwaitDirExists {
candidates: BTreeSet<VdirPath>,
},
AwaitMetadataExists {
collection_paths: BTreeSet<VdirPath>,
probes: BTreeMap<VdirPath, VdirPath>,
},
AwaitMetadataRead {
collection_paths: BTreeSet<VdirPath>,
probes: BTreeMap<VdirPath, VdirPath>,
},
}
impl fmt::Display for State {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Start(_) => f.write_str("start"),
Self::AwaitChildrenRead => f.write_str("await children read reply"),
Self::AwaitDirExists { .. } => f.write_str("await dir exists reply"),
Self::AwaitMetadataExists { .. } => f.write_str("await metadata exists reply"),
Self::AwaitMetadataRead { .. } => f.write_str("await metadata read reply"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_root_yields_no_collections() {
let mut cor = VdirCollectionList::new("root", VdirCollectionListOptions::default());
let paths = expect_wants_dir_read(&mut cor);
assert!(paths.contains(&VdirPath::from("root")));
let mut entries = BTreeMap::new();
entries.insert(VdirPath::from("root"), BTreeSet::new());
let collections = expect_complete_ok(&mut cor, Some(VdirReply::DirRead(entries)));
assert!(collections.is_empty());
}
#[test]
fn bare_collection_without_metadata() {
let mut cor = VdirCollectionList::new("root", VdirCollectionListOptions::default());
let _ = expect_wants_dir_read(&mut cor);
let mut entries = BTreeMap::new();
let contacts = VdirPath::from("root/contacts");
entries.insert(
VdirPath::from("root"),
BTreeSet::from_iter([contacts.clone()]),
);
let probes = match cor.resume(Some(VdirReply::DirRead(entries))) {
VdirCoroutineState::Yielded(VdirYield::WantsDirExists(probes)) => probes,
state => panic!("expected WantsDirExists, got {state:?}"),
};
assert!(probes.contains(&contacts));
let mut exists = BTreeMap::new();
exists.insert(contacts.clone(), true);
let metadata = match cor.resume(Some(VdirReply::DirExists(exists))) {
VdirCoroutineState::Yielded(VdirYield::WantsFileExists(probes)) => probes,
state => panic!("expected WantsFileExists, got {state:?}"),
};
assert!(metadata.contains(&contacts.join(DISPLAYNAME)));
let absent: BTreeMap<VdirPath, bool> = metadata.into_iter().map(|p| (p, false)).collect();
let collections = expect_complete_ok(&mut cor, Some(VdirReply::FileExists(absent)));
assert_eq!(collections.len(), 1);
assert_eq!(collections.iter().next().unwrap().id(), "contacts");
}
#[test]
fn dotfiles_are_skipped() {
let mut cor = VdirCollectionList::new("root", VdirCollectionListOptions::default());
let _ = expect_wants_dir_read(&mut cor);
let mut entries = BTreeMap::new();
entries.insert(
VdirPath::from("root"),
BTreeSet::from_iter([VdirPath::from("root/.hidden")]),
);
let collections = expect_complete_ok(&mut cor, Some(VdirReply::DirRead(entries)));
assert!(collections.is_empty());
}
#[test]
fn unexpected_reply_returns_error() {
let mut cor = VdirCollectionList::new("root", VdirCollectionListOptions::default());
let _ = expect_wants_dir_read(&mut cor);
let err = match cor.resume(Some(VdirReply::DirCreate)) {
VdirCoroutineState::Complete(Err(err)) => err,
state => panic!("expected Complete(Err), got {state:?}"),
};
assert!(matches!(err, VdirCollectionListError::UnexpectedArg(_)));
}
fn expect_wants_dir_read(cor: &mut VdirCollectionList) -> BTreeSet<VdirPath> {
match cor.resume(None) {
VdirCoroutineState::Yielded(VdirYield::WantsDirRead(paths)) => paths,
state => panic!("expected WantsDirRead, got {state:?}"),
}
}
fn expect_complete_ok(
cor: &mut VdirCollectionList,
arg: Option<VdirReply>,
) -> BTreeSet<VdirCollection> {
match cor.resume(arg) {
VdirCoroutineState::Complete(Ok(collections)) => collections,
state => panic!("expected Complete(Ok), got {state:?}"),
}
}
}