Skip to main content

io_m2dir/
client.rs

1//! # Standard, blocking m2dir client
2//!
3//! Holds a single filesystem root and exposes one method per common
4//! coroutine. Every method runs its coroutine to completion through
5//! [`M2dirClient::run`] by servicing each [`M2dirYield`] request via
6//! [`std::fs`].
7//!
8//! [`M2dirYield`]: crate::coroutine::M2dirYield
9
10use std::{
11    collections::{BTreeMap, BTreeSet, hash_map::RandomState},
12    fs,
13    hash::{BuildHasher, Hasher},
14    io,
15    path::{Path, PathBuf},
16    process,
17    string::ToString,
18    thread,
19    vec::Vec,
20};
21
22use log::trace;
23use thiserror::Error;
24
25use crate::{
26    checksum::validate_checksum,
27    coroutine::*,
28    entry::{
29        M2dirEntry, M2dirEntryParseError, M2dirFullEntry, delete::*, get::*, list::*, store::*,
30    },
31    flag::{M2dirFlags, add::*, remove::*, set::*},
32    m2dir::{DOT_M2DIR, M2dir, M2dirLoadError, create::*, delete::*, list::*},
33    path::M2dirPath,
34    store::{DOT_M2STORE, M2dirStore, M2dirStoreError},
35};
36
37/// Errors returned by [`M2dirClient`].
38#[derive(Debug, Error)]
39pub enum M2dirClientError {
40    /// An m2store operation (open, resolve, decode) failed.
41    #[error(transparent)]
42    Store(#[from] M2dirStoreError),
43    /// Opening an existing m2dir failed.
44    #[error(transparent)]
45    LoadM2dir(#[from] M2dirLoadError),
46    /// Creating an m2dir failed.
47    #[error(transparent)]
48    CreateM2dir(#[from] M2dirCreateError),
49    /// Removing an m2dir failed.
50    #[error(transparent)]
51    DeleteM2dir(#[from] M2dirDeleteError),
52    /// Listing the m2dirs under the store failed.
53    #[error(transparent)]
54    ListM2dirs(#[from] M2dirListError),
55    /// Listing the entries of an m2dir failed.
56    #[error(transparent)]
57    ListEntries(#[from] M2dirEntryListError),
58    /// Getting an entry by id failed.
59    #[error(transparent)]
60    GetEntry(#[from] M2dirEntryGetError),
61    /// Storing a new entry failed.
62    #[error(transparent)]
63    StoreEntry(#[from] M2dirEntryStoreError),
64    /// Deleting an entry failed.
65    #[error(transparent)]
66    DeleteEntry(#[from] M2dirEntryDeleteError),
67    /// Adding flags to an entry failed.
68    #[error(transparent)]
69    AddFlags(#[from] M2dirFlagAddError),
70    /// Removing flags from an entry failed.
71    #[error(transparent)]
72    RemoveFlags(#[from] M2dirFlagRemoveError),
73    /// Replacing an entry's flags failed.
74    #[error(transparent)]
75    SetFlags(#[from] M2dirFlagSetError),
76    /// Parsing or checksum-validating an entry failed.
77    #[error(transparent)]
78    Parse(#[from] M2dirEntryParseError),
79    /// A raw filesystem operation failed.
80    #[error(transparent)]
81    Io(#[from] io::Error),
82}
83
84/// Std-blocking m2dir client wrapping a filesystem root.
85///
86/// The root must point to an m2store: a directory containing a
87/// `.m2store` marker. M2dir helpers resolve folder names against
88/// this root.
89#[derive(Debug)]
90pub struct M2dirClient {
91    root: M2dirPath,
92}
93
94/// Construction, the coroutine runner, and m2store / m2dir lifecycle.
95impl M2dirClient {
96    /// Builds a client rooted at `root`. No filesystem check is
97    /// performed at construction time.
98    pub fn new(root: impl Into<M2dirPath>) -> Self {
99        Self { root: root.into() }
100    }
101
102    /// Returns the filesystem root this client operates on.
103    pub fn root(&self) -> &M2dirPath {
104        &self.root
105    }
106
107    /// Runs any standard-shape coroutine (`Yield = M2dirYield`,
108    /// `Return = Result<Output, Error>`) against the local
109    /// filesystem until it terminates.
110    pub fn run<C, T, E>(&self, mut coroutine: C) -> Result<T, M2dirClientError>
111    where
112        C: M2dirCoroutine<Yield = M2dirYield, Return = Result<T, E>>,
113        M2dirClientError: From<E>,
114    {
115        let mut arg: Option<M2dirArg> = None;
116
117        loop {
118            match coroutine.resume(arg.take()) {
119                M2dirCoroutineState::Complete(Ok(out)) => return Ok(out),
120                M2dirCoroutineState::Complete(Err(err)) => return Err(err.into()),
121                M2dirCoroutineState::Yielded(M2dirYield::WantsPid) => {
122                    arg = Some(M2dirArg::Pid(process::id()));
123                }
124                M2dirCoroutineState::Yielded(M2dirYield::WantsRandom { len }) => {
125                    arg = Some(M2dirArg::Random(random_bytes(len)));
126                }
127                M2dirCoroutineState::Yielded(M2dirYield::WantsFileExists(paths)) => {
128                    arg = Some(M2dirArg::FileExists(file_exists(paths)));
129                }
130                M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => {
131                    arg = Some(M2dirArg::DirRead(read_dirs(paths)?));
132                }
133                M2dirCoroutineState::Yielded(M2dirYield::WantsDirCreate(paths)) => {
134                    create_dirs(paths)?;
135                    arg = Some(M2dirArg::DirCreate);
136                }
137                M2dirCoroutineState::Yielded(M2dirYield::WantsDirRemove(paths)) => {
138                    remove_dirs(paths)?;
139                    arg = Some(M2dirArg::DirRemove);
140                }
141                M2dirCoroutineState::Yielded(M2dirYield::WantsFileRead(paths)) => {
142                    arg = Some(M2dirArg::FileRead(read_files_tolerant(paths)?));
143                }
144                M2dirCoroutineState::Yielded(M2dirYield::WantsFileCreate(files)) => {
145                    write_files(files)?;
146                    arg = Some(M2dirArg::FileCreate);
147                }
148                M2dirCoroutineState::Yielded(M2dirYield::WantsFileRemove(paths)) => {
149                    remove_files_tolerant(paths)?;
150                    arg = Some(M2dirArg::FileRemove);
151                }
152                M2dirCoroutineState::Yielded(M2dirYield::WantsRename(pairs)) => {
153                    rename_paths(pairs)?;
154                    arg = Some(M2dirArg::Rename);
155                }
156            }
157        }
158    }
159
160    /// Opens the m2store at the client root, returning a typed
161    /// handle on success.
162    pub fn open_store(&self) -> Result<M2dirStore, M2dirClientError> {
163        load_store(self.root.clone()).map_err(Into::into)
164    }
165
166    /// Initialises a brand new m2store at the client root: creates
167    /// the directory if needed and writes the `.m2store` marker.
168    pub fn init_store(&self) -> Result<M2dirStore, M2dirClientError> {
169        trace!("init m2store at {}", self.root);
170
171        fs::create_dir_all(self.root.as_str())?;
172        let marker = self.root.join(DOT_M2STORE);
173        if !Path::new(marker.as_str()).exists() {
174            fs::write(marker.as_str(), b"")?;
175        }
176
177        Ok(M2dirStore::from_path(self.root.clone()))
178    }
179
180    /// Opens an existing m2dir at `path`, validating the `.m2dir`
181    /// marker.
182    pub fn open_m2dir(&self, path: impl Into<M2dirPath>) -> Result<M2dir, M2dirClientError> {
183        load_m2dir(path.into()).map_err(Into::into)
184    }
185
186    /// Creates the m2dir folder `name` and writes the `.m2dir`
187    /// marker.
188    pub fn create_m2dir(&self, name: &str) -> Result<M2dir, M2dirClientError> {
189        let store = self.open_store()?;
190        let coroutine = M2dirCreate::new(&store, name, M2dirCreateOptions::default())?;
191        self.run(coroutine)
192    }
193
194    /// Recursively removes the m2dir at `path`.
195    pub fn delete_m2dir(&self, path: impl Into<M2dirPath>) -> Result<(), M2dirClientError> {
196        self.run(M2dirDelete::new(path, M2dirDeleteOptions::default()))
197    }
198
199    /// Lists every m2dir under the store root.
200    pub fn list_m2dirs(&self) -> Result<BTreeSet<M2dir>, M2dirClientError> {
201        let store = self.open_store()?;
202        self.run(M2dirList::new(&store, M2dirListOptions::default()))
203    }
204}
205
206/// Entry operations.
207impl M2dirClient {
208    /// Lists every entry inside `m2dir`.
209    pub fn list_entries(&self, m2dir: M2dir) -> Result<Vec<M2dirEntry>, M2dirClientError> {
210        self.run(M2dirEntryList::new(m2dir, M2dirEntryListOptions::default()))
211    }
212
213    /// Reads the file backing `entry` and validates its checksum.
214    ///
215    /// Prefer this over [`Self::get`] when the entry is already known:
216    /// skips the directory scan used to resolve an id.
217    pub fn read_entry(&self, entry: &M2dirEntry) -> Result<Vec<u8>, M2dirClientError> {
218        let path = entry.path();
219        trace!("read entry at {path}");
220
221        let bytes = fs::read(path.as_str())?;
222        let checksum = entry.checksum();
223
224        if !validate_checksum(checksum, &bytes) {
225            return Err(M2dirEntryParseError::InvalidChecksum {
226                path: path.clone(),
227                expected: checksum.to_string(),
228                got: entry.id().to_string(),
229            }
230            .into());
231        }
232
233        Ok(bytes)
234    }
235
236    /// Reads the bytes and flags of every entry sequentially.
237    ///
238    /// Returns an unordered set: callers that need a specific order
239    /// must sort the collected entries themselves. Use
240    /// [`Self::read_entries_par`] for the parallel variant.
241    pub fn read_entries(
242        &self,
243        m2dir: &M2dir,
244        entries: &[M2dirEntry],
245    ) -> Result<BTreeSet<M2dirFullEntry>, M2dirClientError> {
246        entries
247            .iter()
248            .map(|entry| self.read_full_entry(m2dir, entry))
249            .collect()
250    }
251
252    /// Parallel variant of [`Self::read_entries`] backed by a
253    /// `std::thread::scope` worker pool sized to
254    /// [`thread::available_parallelism`].
255    pub fn read_entries_par(
256        &self,
257        m2dir: &M2dir,
258        entries: &[M2dirEntry],
259    ) -> Result<BTreeSet<M2dirFullEntry>, M2dirClientError> {
260        if entries.len() <= 1 {
261            return entries
262                .iter()
263                .map(|entry| self.read_full_entry(m2dir, entry))
264                .collect();
265        }
266
267        let n_threads = thread::available_parallelism()
268            .map(|n| n.get())
269            .unwrap_or(8)
270            .min(entries.len());
271
272        let chunk_size = entries.len().div_ceil(n_threads);
273
274        thread::scope(|s| -> Result<BTreeSet<M2dirFullEntry>, M2dirClientError> {
275            let mut handles = Vec::with_capacity(n_threads);
276
277            for chunk in entries.chunks(chunk_size) {
278                let this = self;
279
280                handles.push(s.spawn(move || {
281                    chunk
282                        .iter()
283                        .map(|entry| this.read_full_entry(m2dir, entry))
284                        .collect::<Result<Vec<_>, _>>()
285                }));
286            }
287
288            let mut out = BTreeSet::new();
289
290            for handle in handles {
291                for full in handle.join().expect("m2dir worker thread panicked")? {
292                    out.insert(full);
293                }
294            }
295
296            Ok(out)
297        })
298    }
299
300    fn read_full_entry(
301        &self,
302        m2dir: &M2dir,
303        entry: &M2dirEntry,
304    ) -> Result<M2dirFullEntry, M2dirClientError> {
305        let contents = self.read_entry(entry)?;
306        let flags = self.read_flags(m2dir, entry.id())?;
307
308        Ok(M2dirFullEntry::from_parts(entry.clone(), contents, flags))
309    }
310
311    /// Locates and reads entry `id` from `m2dir`, validating the
312    /// checksum embedded in the filename.
313    pub fn get(
314        &self,
315        m2dir: M2dir,
316        id: impl ToString,
317    ) -> Result<(M2dirEntry, Vec<u8>), M2dirClientError> {
318        let M2dirEntryGetOutput { entry, contents } = self.run(M2dirEntryGet::new(
319            m2dir,
320            id,
321            M2dirEntryGetOptions::default(),
322        ))?;
323        Ok((entry, contents))
324    }
325
326    /// Writes `bytes` to a temporary file inside `m2dir`, then
327    /// atomically renames it to its checksum-based final filename.
328    pub fn store(&self, m2dir: M2dir, bytes: Vec<u8>) -> Result<M2dirEntry, M2dirClientError> {
329        self.run(M2dirEntryStore::new(
330            m2dir,
331            bytes,
332            M2dirEntryStoreOptions::default(),
333        ))
334    }
335
336    /// Removes entry `id` and every matching `.meta/<id>*` file.
337    pub fn delete_entry(&self, m2dir: M2dir, id: impl ToString) -> Result<(), M2dirClientError> {
338        self.run(M2dirEntryDelete::new(
339            m2dir,
340            id,
341            M2dirEntryDeleteOptions::default(),
342        ))
343    }
344}
345
346/// Flag operations.
347impl M2dirClient {
348    /// Reads the `.flags` metadata file for entry `id` inside
349    /// `m2dir`, returning an empty set if the file is missing.
350    pub fn read_flags(
351        &self,
352        m2dir: &M2dir,
353        id: impl AsRef<str>,
354    ) -> Result<M2dirFlags, M2dirClientError> {
355        let path = m2dir.flags_path(id.as_ref());
356        trace!("read flags at {path}");
357
358        match fs::read_to_string(path.as_str()) {
359            Ok(text) => Ok(M2dirFlags::from_meta(&text)),
360            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(M2dirFlags::default()),
361            Err(err) => Err(err.into()),
362        }
363    }
364
365    /// Adds `flags` to entry `id`'s flags metadata file.
366    pub fn add_flags(
367        &self,
368        m2dir: &M2dir,
369        id: impl AsRef<str>,
370        flags: M2dirFlags,
371    ) -> Result<(), M2dirClientError> {
372        self.run(M2dirFlagAdd::new(
373            m2dir,
374            id,
375            flags,
376            M2dirFlagAddOptions::default(),
377        ))
378    }
379
380    /// Removes `flags` from entry `id`'s flags metadata file. When
381    /// the resulting set is empty the file is deleted.
382    pub fn remove_flags(
383        &self,
384        m2dir: &M2dir,
385        id: impl AsRef<str>,
386        flags: M2dirFlags,
387    ) -> Result<(), M2dirClientError> {
388        self.run(M2dirFlagRemove::new(
389            m2dir,
390            id,
391            flags,
392            M2dirFlagRemoveOptions::default(),
393        ))
394    }
395
396    /// Replaces entry `id`'s flags metadata file with `flags`,
397    /// deleting it when `flags` is empty.
398    pub fn set_flags(
399        &self,
400        m2dir: &M2dir,
401        id: impl AsRef<str>,
402        flags: M2dirFlags,
403    ) -> Result<(), M2dirClientError> {
404        self.run(M2dirFlagSet::new(
405            m2dir,
406            id,
407            flags,
408            M2dirFlagSetOptions::default(),
409        ))
410    }
411}
412
413/// Opens an m2store at `path`, checking the directory and its
414/// `.m2store` marker.
415fn load_store(path: M2dirPath) -> Result<M2dirStore, M2dirStoreError> {
416    if !Path::new(path.as_str()).is_dir() {
417        return Err(M2dirStoreError::NotDir(path));
418    }
419
420    let marker = path.join(DOT_M2STORE);
421    if !Path::new(marker.as_str()).exists() {
422        return Err(M2dirStoreError::NoDotM2store(path));
423    }
424
425    Ok(M2dirStore::from_path(path))
426}
427
428/// Opens an m2dir at `path`, checking the directory and its `.m2dir`
429/// marker.
430fn load_m2dir(path: M2dirPath) -> Result<M2dir, M2dirLoadError> {
431    if !Path::new(path.as_str()).is_dir() {
432        return Err(M2dirLoadError::NotDir(path));
433    }
434
435    let marker = path.join(DOT_M2DIR);
436    if !Path::new(marker.as_str()).exists() {
437        return Err(M2dirLoadError::NoDotM2dir(path));
438    }
439
440    Ok(M2dir::from_path(path))
441}
442
443/// Normalizes a [`PathBuf`] to an [`M2dirPath`], rewriting `\` to `/`
444/// on Windows so the crate stays `/`-separated end to end.
445fn normalize_path(path: PathBuf) -> M2dirPath {
446    let s = path.to_string_lossy().into_owned();
447    #[cfg(windows)]
448    let s = s.replace('\\', "/");
449    M2dirPath::new(s)
450}
451
452fn create_dirs(paths: BTreeSet<M2dirPath>) -> Result<(), io::Error> {
453    for path in paths {
454        trace!("create_dir_all {path}");
455        fs::create_dir_all(path.as_str())?;
456    }
457    Ok(())
458}
459
460fn remove_dirs(paths: BTreeSet<M2dirPath>) -> Result<(), io::Error> {
461    for path in paths {
462        trace!("remove_dir_all {path}");
463        fs::remove_dir_all(path.as_str())?;
464    }
465    Ok(())
466}
467
468fn write_files(files: BTreeMap<M2dirPath, Vec<u8>>) -> Result<(), io::Error> {
469    for (path, contents) in files {
470        trace!("write {path} ({} bytes)", contents.len());
471
472        if let Some(parent) = Path::new(path.as_str()).parent() {
473            fs::create_dir_all(parent)?;
474        }
475        fs::write(path.as_str(), &contents)?;
476    }
477    Ok(())
478}
479
480fn remove_files_tolerant(paths: BTreeSet<M2dirPath>) -> Result<(), io::Error> {
481    for path in paths {
482        trace!("remove_file (tolerant) {path}");
483        match fs::remove_file(path.as_str()) {
484            Ok(()) => {}
485            Err(err) if err.kind() == io::ErrorKind::NotFound => {}
486            Err(err) => return Err(err),
487        }
488    }
489    Ok(())
490}
491
492fn read_dirs(
493    paths: BTreeSet<M2dirPath>,
494) -> Result<BTreeMap<M2dirPath, BTreeSet<M2dirPath>>, io::Error> {
495    let mut entries = BTreeMap::new();
496
497    for path in paths {
498        trace!("read_dir {path}");
499
500        let mut names = BTreeSet::new();
501        match fs::read_dir(path.as_str()) {
502            Ok(iter) => {
503                for entry in iter {
504                    let entry = entry?;
505                    names.insert(normalize_path(entry.path()));
506                }
507            }
508            Err(err) if err.kind() == io::ErrorKind::NotFound => {}
509            Err(err) if err.kind() == io::ErrorKind::NotADirectory => {}
510            Err(err) => return Err(err),
511        }
512
513        entries.insert(path, names);
514    }
515
516    Ok(entries)
517}
518
519fn read_files_tolerant(
520    paths: BTreeSet<M2dirPath>,
521) -> Result<BTreeMap<M2dirPath, Vec<u8>>, io::Error> {
522    let mut contents = BTreeMap::new();
523
524    for path in paths {
525        trace!("read_file (tolerant) {path}");
526        match fs::read(path.as_str()) {
527            Ok(bytes) => {
528                contents.insert(path, bytes);
529            }
530            Err(err) if err.kind() == io::ErrorKind::NotFound => {
531                contents.insert(path, Vec::new());
532            }
533            Err(err) => return Err(err),
534        }
535    }
536
537    Ok(contents)
538}
539
540fn rename_paths(pairs: Vec<(M2dirPath, M2dirPath)>) -> Result<(), io::Error> {
541    for (from, to) in pairs {
542        trace!("rename {from} -> {to}");
543        fs::rename(from.as_str(), to.as_str())?;
544    }
545    Ok(())
546}
547
548fn file_exists(paths: BTreeSet<M2dirPath>) -> BTreeMap<M2dirPath, bool> {
549    let mut out = BTreeMap::new();
550    for path in paths {
551        let exists = fs::metadata(path.as_str())
552            .map(|m| m.is_file())
553            .unwrap_or(false);
554        trace!("file_exists {path}: {exists}");
555        out.insert(path, exists);
556    }
557    out
558}
559
560/// Generates `len` pseudo-random bytes seeded from [`RandomState`],
561/// iterated via xorshift64*.
562fn random_bytes(len: usize) -> Vec<u8> {
563    let mut state = RandomState::new().build_hasher().finish();
564    if state == 0 {
565        state = 0xdeadbeef;
566    }
567
568    let mut out = Vec::with_capacity(len);
569    let mut buf = 0u64;
570    let mut i = 8;
571
572    while out.len() < len {
573        if i == 8 {
574            state ^= state << 13;
575            state ^= state >> 7;
576            state ^= state << 17;
577            buf = state;
578            i = 0;
579        }
580        out.push(buf as u8);
581        buf >>= 8;
582        i += 1;
583    }
584
585    out
586}
587
588#[cfg(test)]
589mod tests {
590    use std::path::Path;
591
592    use tempfile::tempdir;
593
594    use crate::{client::*, flag::M2dirFlags};
595
596    fn client() -> (tempfile::TempDir, M2dirClient) {
597        let dir = tempdir().unwrap();
598        let root = dir.path().to_string_lossy().into_owned();
599        let client = M2dirClient::new(root);
600        client.init_store().unwrap();
601        (dir, client)
602    }
603
604    #[test]
605    fn init_store_writes_marker() {
606        let (dir, _client) = client();
607        assert!(dir.path().join(".m2store").exists());
608    }
609
610    #[test]
611    fn create_m2dir_writes_marker() {
612        let (_dir, client) = client();
613
614        let inbox = client.create_m2dir("inbox").unwrap();
615        assert!(Path::new(inbox.path().as_str()).is_dir());
616        assert!(Path::new(inbox.marker_path().as_str()).exists());
617        assert!(Path::new(inbox.meta_dir().as_str()).is_dir());
618    }
619
620    #[test]
621    fn list_m2dirs_finds_created_folder() {
622        let (_dir, client) = client();
623
624        client.create_m2dir("inbox").unwrap();
625        client.create_m2dir("sent").unwrap();
626
627        let m2dirs = client.list_m2dirs().unwrap();
628        assert_eq!(m2dirs.len(), 2);
629    }
630
631    #[test]
632    fn store_and_list_entries_round_trip() {
633        let (_dir, client) = client();
634
635        let inbox = client.create_m2dir("inbox").unwrap();
636        let msg = b"From: alice@example.org\r\nDate: Tue, 15 Apr 1994 08:12:31 GMT\r\nSubject: hi\r\n\r\nbody\r\n";
637
638        let entry = client.store(inbox.clone(), msg.to_vec()).unwrap();
639        assert!(Path::new(entry.path().as_str()).is_file());
640
641        let listed = client.list_entries(inbox.clone()).unwrap();
642        assert_eq!(listed.len(), 1);
643        assert_eq!(listed[0].id(), entry.id());
644
645        let (fetched, contents) = client.get(inbox, entry.id()).unwrap();
646        assert_eq!(fetched.id(), entry.id());
647        assert_eq!(contents, msg);
648    }
649
650    #[test]
651    fn flags_round_trip_via_meta() {
652        let (_dir, client) = client();
653
654        let inbox = client.create_m2dir("inbox").unwrap();
655        let msg = b"From: a\r\n\r\nbody\r\n";
656        let entry = client.store(inbox.clone(), msg.to_vec()).unwrap();
657
658        let initial = client.read_flags(&inbox, entry.id()).unwrap();
659        assert_eq!(initial.len(), 0);
660
661        let mut to_add = M2dirFlags::default();
662        to_add.insert("$seen");
663        to_add.insert("$forwarded");
664        client.add_flags(&inbox, entry.id(), to_add).unwrap();
665
666        let after_add = client.read_flags(&inbox, entry.id()).unwrap();
667        assert_eq!(after_add.len(), 2);
668        assert!(after_add.contains("$seen"));
669        assert!(after_add.contains("$forwarded"));
670
671        let mut to_remove = M2dirFlags::default();
672        to_remove.insert("$seen");
673        client.remove_flags(&inbox, entry.id(), to_remove).unwrap();
674
675        let after_remove = client.read_flags(&inbox, entry.id()).unwrap();
676        assert_eq!(after_remove.len(), 1);
677        assert!(after_remove.contains("$forwarded"));
678
679        let mut replacement = M2dirFlags::default();
680        replacement.insert("custom");
681        replacement.insert("$junk");
682        client.set_flags(&inbox, entry.id(), replacement).unwrap();
683
684        let after_set = client.read_flags(&inbox, entry.id()).unwrap();
685        assert_eq!(after_set.len(), 2);
686        assert!(after_set.contains("custom"));
687        assert!(after_set.contains("$junk"));
688
689        client
690            .set_flags(&inbox, entry.id(), M2dirFlags::default())
691            .unwrap();
692        let after_clear = client.read_flags(&inbox, entry.id()).unwrap();
693        assert!(after_clear.is_empty());
694        assert!(!Path::new(inbox.flags_path(entry.id()).as_str()).exists());
695    }
696
697    #[test]
698    fn delete_entry_removes_file_and_flags_meta() {
699        let (_dir, client) = client();
700
701        let inbox = client.create_m2dir("inbox").unwrap();
702        let entry = client.store(inbox.clone(), b"hello".to_vec()).unwrap();
703
704        let mut flags = M2dirFlags::default();
705        flags.insert("$seen");
706        client.add_flags(&inbox, entry.id(), flags).unwrap();
707        assert!(Path::new(inbox.flags_path(entry.id()).as_str()).exists());
708
709        client.delete_entry(inbox.clone(), entry.id()).unwrap();
710        assert!(!Path::new(entry.path().as_str()).exists());
711        assert!(!Path::new(inbox.flags_path(entry.id()).as_str()).exists());
712
713        let listed = client.list_entries(inbox).unwrap();
714        assert!(listed.is_empty());
715    }
716
717    #[test]
718    fn delete_m2dir_removes_tree() {
719        let (_dir, client) = client();
720
721        let inbox = client.create_m2dir("inbox").unwrap();
722        let path = inbox.path().clone();
723        assert!(Path::new(path.as_str()).is_dir());
724
725        client.delete_m2dir(path.clone()).unwrap();
726        assert!(!Path::new(path.as_str()).exists());
727    }
728}