vomit-m2dir 0.3.1

Library for the m2dir email storage format
Documentation
use std::ffi::OsStr;
use std::fs::{self, create_dir_all, remove_dir_all};
use std::path::{Path, PathBuf};

use walkdir::WalkDir;

use crate::util::{decode_folder_name, encode_folder_name};
use crate::{Error, M2dir};

/// An m2store as defined in the m2dir spec.
///
/// Any instance created by this implementation is guaranteed to be an existing
/// directory with a `.m2dir.root` marker file.
#[derive(Clone, Debug)]
pub struct M2store {
    root: PathBuf,
}

impl TryFrom<&Path> for M2store {
    type Error = Error;

    fn try_from(path: &Path) -> Result<Self, Error> {
        let path = path.canonicalize()?;
        let marker = PathBuf::from_iter([path.as_os_str(), OsStr::new(".m2store")]);
        if !marker.is_file() {
            return Err(Error::FolderNotFound);
        }
        Ok(M2store { root: path })
    }
}

impl M2store {
    /// Create a new m2store.
    pub fn create(root: impl AsRef<Path>) -> Result<M2store, Error> {
        if !root.as_ref().exists() {
            create_dir_all(&root)?;
        }
        let marker = PathBuf::from_iter([root.as_ref().as_os_str(), OsStr::new(".m2store")]);
        let _ = fs::File::create(marker)?;
        M2store::open(&root)
    }

    /// Open an existing m2store.
    pub fn open(path: impl AsRef<Path>) -> Result<M2store, Error> {
        M2store::try_from(path.as_ref())
    }

    /// Get the path of this m2store's root.
    ///
    /// The path is guaranteed to be [canonical].
    ///
    /// [canonical]: https://doc.rust-lang.org/std/path/struct.Path.html#method.canonicalize
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// Get an iterator over the folders in this m2store.
    ///
    /// The returned iterator will only include folders that are direct children
    /// of this m2store, suitable for tree-style folder browsing. For a flat
    /// list that recursively includes all folders, see
    /// [all_folders](M2store::all_folders).
    pub fn folders(&self) -> Folders {
        Folders::new(self.root.clone(), false)
    }

    /// Get an iterator over _all_ folders in this m2store.
    ///
    /// The returned iterator will recursively include nested folders, suitable
    /// for a flat list of folders. For more tree-like folder browsing, see
    /// [folders](M2store::folders).
    pub fn all_folders(&self) -> Folders {
        Folders::new(self.root.clone(), true)
    }

    /// Find a specific folder in this m2store.
    ///
    /// This is just a convenience function for `self.folders().find()`, so it
    /// does not search recursively.
    pub fn folder(&self, name: &str) -> Result<Option<Folder>, Error> {
        self.folders()
            .find(|f| f.as_ref().is_ok_and(|f| f.name() == name))
            .transpose()
    }

    /// Create a new folder in this m2store.
    ///
    /// This always creates a single folder. If the folder name contains a path
    /// separator, it will be percent-encoded.
    pub fn create_folder(&self, folder: &str) -> Result<Folder, Error> {
        if folder.starts_with('.') || folder.chars().any(|c| c.is_ascii_control()) {
            return Err(Error::InvalidFolderName(folder.to_string()));
        }
        let path = encode_folder_name(folder);
        let mut full_path = self.root.clone();
        full_path.push(path.as_ref());
        let m2dir = M2dir::create(&full_path)?;
        Ok(Folder::new(m2dir, PathBuf::from(path.as_ref())))
    }

    /// Create a new folder hierarchy in this m2store.
    pub fn create_folders(&self, folder: &[impl AsRef<str>]) -> Result<Folder, Error> {
        let mut r: Option<Folder> = None;
        for f in folder {
            if let Some(r_) = r {
                r = Some(r_.create_subfolder(f.as_ref())?);
            } else {
                r = Some(self.create_folder(f.as_ref())?);
            }
        }
        r.ok_or(Error::InvalidFolderName("no folders provided".to_string()))
    }

    /// Delete the given folder and all its contents.
    pub fn delete_folder(&self, folder: Folder) -> Result<(), Error> {
        remove_dir_all(folder.abs_path())?;
        Ok(())
    }
}

/// An iterator over [`Folder`]s in an [`M2store`].
///
/// The order of subdirectories in the iterator is not specified, and is not
/// guaranteed to be stable over multiple invocations of this method. However,
/// child directories are guaranteed to be listed after their parent
/// directories.
pub struct Folders {
    name: PathBuf,
    path: PathBuf,
    walkdir: Option<walkdir::IntoIter>,
    include_self: bool,
    recurse: bool,
}

impl Folders {
    fn new(path: PathBuf, recurse: bool) -> Folders {
        Folders {
            name: PathBuf::from(""),
            path,
            walkdir: None,
            include_self: true,
            recurse,
        }
    }

    fn new_sub(name: PathBuf, path: PathBuf, recurse: bool) -> Folders {
        Folders {
            name,
            path,
            walkdir: None,
            include_self: false,
            recurse,
        }
    }
}

impl AsRef<M2dir> for Folder {
    fn as_ref(&self) -> &M2dir {
        &self.m2dir
    }
}

impl Iterator for Folders {
    type Item = Result<Folder, Error>;

    fn next(&mut self) -> Option<Result<Folder, Error>> {
        if self.walkdir.is_none() {
            let max_depth = if self.recurse { usize::MAX } else { 1 };
            let min_depth = if self.include_self { 0 } else { 1 };
            self.walkdir = Some(
                WalkDir::new(&self.path)
                    .min_depth(min_depth)
                    .max_depth(max_depth)
                    .into_iter(),
            );
        }

        loop {
            let dir_entry = self.walkdir.as_mut().unwrap().next();
            let result = dir_entry.map(|e| {
                let entry = e?;
                let dot = b'.';
                let first_char = entry.file_name().as_encoded_bytes().first().unwrap_or(&dot);
                // The root directory (self.path) is an exception insofar that
                // it may begin with a '.' - think ~/.mail - but must be included (if it is an m2dir)
                if *first_char == dot && entry.path() != self.path {
                    return Ok(None);
                }

                // the entry must be a directory
                let is_dir = entry.metadata().map(|m| m.is_dir()).unwrap_or(false);
                if !is_dir {
                    return Ok(None);
                }

                let name = if entry.path() != self.path {
                    let rel = entry.path().strip_prefix(&self.path).unwrap();
                    PathBuf::from_iter([self.name.as_path(), rel])
                } else {
                    PathBuf::from(".")
                };

                let m2dir = match M2dir::try_from(self.path.join(entry.path()).as_ref()) {
                    Ok(m2dir) => m2dir,
                    Err(_) => return Ok(None),
                };

                Ok(Some(Folder::new(m2dir, name)))
            });

            return match result {
                None => None,
                Some(Err(e)) => Some(Err(e)),
                Some(Ok(None)) => continue,
                Some(Ok(Some(v))) => Some(Ok(v)),
            };
        }
    }
}

/// A folder is an [`M2dir`] embedded in the context of an [`M2store`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Folder {
    m2dir: M2dir,
    path: PathBuf,
    names: Vec<String>,
}

impl Folder {
    fn new(m2dir: M2dir, path: PathBuf) -> Self {
        let names = path
            .components()
            .map(|c| decode_folder_name(&c.as_os_str().to_string_lossy()).to_string())
            .collect();
        Folder { m2dir, path, names }
    }

    /// The absolute path to this folder.
    pub fn abs_path(&self) -> &PathBuf {
        &self.m2dir.path
    }

    /// The path to this folder relative to the m2store root.
    pub fn path(&self) -> &PathBuf {
        &self.path
    }

    /// The (base-)name of this folder.
    ///
    /// A folder name is for display purposes only and can contain unexpected
    /// characters, such as `/`, as the name on disk is potentially encoded. Use
    /// [path](Self::path) instead to access the directory on disk.
    pub fn name(&self) -> &str {
        self.names.last().expect("folder has no names")
    }

    /// The names of this folder and all its ancestors.
    ///
    /// The names are in hierarchical order, so this folder's [name](Self::name)
    /// is the last element.
    pub fn names(&self) -> &[String] {
        &self.names
    }

    /// Returns an iterator over subfolders in this folder.
    ///
    /// The returned iterator will only include folders that are direct children
    /// of this folder, suitable for tree-style folder browsing. For a flat list
    /// that recursively includes all folders, see
    /// [all_subfolders](Folder::all_subfolders).
    pub fn subfolders(&self) -> Folders {
        Folders::new_sub(self.path.clone(), self.m2dir.path.clone(), false)
    }

    /// Returns an iterator over _all_ subfolders in this folder.
    ///
    /// The returned iterator will recursively include nested folders, suitable
    /// for a flat list of folders. For more tree-like folder browsing, see
    /// [subfolders](Folder::subfolders).
    pub fn all_subfolders(&self) -> Folders {
        Folders::new_sub(self.path.clone(), self.m2dir.path.clone(), true)
    }

    /// Find a specific subfolder in this folder.
    ///
    /// This is just convenience for `self.subfolders().find()`, so it does not
    /// search recursively.
    pub fn subfolder(&self, name: &str) -> Result<Option<Folder>, Error> {
        self.subfolders()
            .find(|f| f.as_ref().is_ok_and(|f| f.name() == name))
            .transpose()
    }

    /// Creates a new subfolder in this folder.
    pub fn create_subfolder(&self, folder: &str) -> Result<Folder, Error> {
        if folder.starts_with('.') {
            return Err(Error::InvalidFolderName(folder.to_string()));
        }
        let name = encode_folder_name(folder);
        let mut path = self.path().to_path_buf();
        path.push(name.as_ref());
        let mut full_path = self.m2dir.path().to_path_buf();
        full_path.push(name.as_ref());
        let m2dir = M2dir::create(&full_path)?;
        Ok(Folder::new(m2dir, path))
    }
}

#[cfg(test)]
mod tests {
    use tempfile::{tempdir, TempDir};

    use super::*;

    fn setup(dir: &TempDir) {
        crate::tests::generate_test_data(dir.path());
    }

    #[test]
    fn test_load() {
        let tmpdir = tempdir().unwrap();
        setup(&tmpdir);

        let m2store = M2store::create(tmpdir.path()).unwrap();

        // flat list
        assert_eq!(m2store.all_folders().count(), 5);
        let mut names: Vec<String> = m2store
            .all_folders()
            .map(|f| f.unwrap().name().to_string())
            .collect();
        names.sort();
        assert_eq!(
            names,
            vec![
                "INBOX",
                "brokenflags",
                "folder",
                "lists/m2dir-dev",
                "subfölder",
            ]
        );

        // tree
        assert_eq!(m2store.folders().count(), 4);
        let mut names: Vec<String> = m2store
            .folders()
            .map(|f| f.unwrap().name().to_string())
            .collect();
        names.sort();
        assert_eq!(
            names,
            vec!["INBOX", "brokenflags", "folder", "lists/m2dir-dev"]
        );

        let folder = m2store.folder("INBOX").unwrap().unwrap();
        assert_eq!(folder.name(), "INBOX".to_string());
        assert_eq!(folder.names(), ["INBOX".to_string()]);
        let m2dir: &M2dir = folder.as_ref();
        assert_eq!(m2dir.count(), 5);

        let folder = m2store.folder("folder").unwrap().unwrap();
        assert_eq!(folder.name(), "folder".to_string());
        assert_eq!(folder.names(), ["folder".to_string()]);
        let m2dir: &M2dir = folder.as_ref();
        assert_eq!(m2dir.count(), 1);

        assert_eq!(folder.subfolders().count(), 1);

        let sub = folder.subfolder("subfölder").unwrap().unwrap();
        assert_eq!(sub.name(), "subfölder".to_string());
        assert_eq!(sub.names(), ["folder".to_string(), "subfölder".to_string()]);
        assert_eq!(sub.path().to_str(), Some("folder/subfölder"));
        let m2dir: &M2dir = sub.as_ref();
        assert_eq!(m2dir.count(), 1);

        let new = folder.create_subfolder("new").unwrap();
        assert_eq!(new.name(), "new".to_string());
        assert_eq!(new.names(), ["folder".to_string(), "new".to_string()]);
        assert_eq!(new.path().to_str(), Some("folder/new"));
        let m2dir: &M2dir = new.as_ref();
        assert_eq!(m2dir.count(), 0);

        let folder = m2store.folder("lists/m2dir-dev").unwrap().unwrap();
        assert_eq!(folder.name(), "lists/m2dir-dev".to_string());
        assert_eq!(folder.names(), ["lists/m2dir-dev".to_string()]);
        assert_eq!(folder.path().to_str(), Some("lists%2Fm2dir-dev"));
    }
}