vomit-m2dir 0.1.4

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::{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 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> {
        let path = path.as_ref().canonicalize()?;
        let marker = PathBuf::from_iter([path.as_os_str(), OsStr::new(".m2store")]);
        // TODO check for marker file
        if !marker.is_file() {
            // TODO better error message
            return Err(Error::FolderNotFound);
        }
        Ok(M2store { root: path })
    }

    pub fn root(&self) -> &PathBuf {
        &self.root
    }

    pub fn folders(&self) -> Folders {
        Folders::new(self.root.clone(), true)
    }

    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()
    }

    pub fn create_folder(&self, folder: impl AsRef<Path>) -> Result<Folder, Error> {
        // TODO canonicalize or otherwise prevent "../"?
        let path = folder.as_ref();
        if path.is_absolute() {
            return Err(Error::InvalidFolderName(String::from(
                path.to_string_lossy(),
            )));
        }
        let mut full_path = self.root.clone();
        full_path.push(path);
        let m2dir = M2dir::create(&full_path)?;
        Ok(Folder {
            path: PathBuf::from(&path),
            m2dir,
        })
    }

    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 {
    // TODO: remove recurse?
    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 { path: name, m2dir }))
            });

            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,
}

impl Folder {
    pub fn abs_path(&self) -> &PathBuf {
        &self.m2dir.path
    }

    pub fn path(&self) -> &PathBuf {
        &self.path
    }

    pub fn name(&self) -> String {
        self.path.to_string_lossy().to_string()
    }

    pub fn virtual_name(&self, delimiter: &str) -> String {
        let v: Vec<_> = self
            .path
            .components()
            .map(|c| c.as_os_str().to_string_lossy().to_string())
            .collect();
        v.join(delimiter)
    }

    /// Returns an iterator over subdirectories.
    pub fn subfolders(&self) -> Folders {
        Folders::new_sub(self.path.clone(), self.m2dir.path.clone(), true)
    }

    pub fn subfolder(&self, name: &str) -> Result<Option<Folder>, Error> {
        self.subfolders()
            .find(|f| {
                f.as_ref()
                    .is_ok_and(|f| f.name() == format!("{}/{}", self.name(), name))
            })
            .transpose()
    }

    /// Creates all neccessary directories if they don't exist yet. It is the library user's
    /// responsibility to call this when e.g. creating a new folder.
    pub fn add_subfolder(&self, folder: impl AsRef<Path>) -> Result<Folder, Error> {
        let path = folder.as_ref();
        if path.is_absolute() {
            return Err(Error::InvalidFolderName(String::from(
                path.to_string_lossy(),
            )));
        }
        let mut abs_path = self.m2dir.path.clone();
        abs_path.push(path);
        let mut name = self.path.clone();
        name.push(path);
        fs::create_dir_all(&abs_path)?;
        Ok(Folder {
            m2dir: M2dir { path: abs_path },
            path: name,
        })
    }
}

#[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();
        assert_eq!(m2store.folders().count(), 5);

        let mut names: Vec<String> = m2store.folders().map(|f| f.unwrap().name()).collect();
        names.sort();
        // lists does not show up because it is not an m2dir itself
        assert_eq!(
            names,
            vec![
                "INBOX",
                "brokenflags",
                "folder",
                "folder/subfolder",
                "lists/m2dir-dev"
            ]
        );

        let folder = m2store.folder("INBOX").unwrap().unwrap();
        assert_eq!(folder.name(), "INBOX".to_string());
        assert_eq!(folder.virtual_name("."), "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.virtual_name("."), "folder".to_string());
        let m2dir: &M2dir = folder.as_ref();
        assert_eq!(m2dir.count(), 1);

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

        let sub = folder.subfolder("subfolder").unwrap().unwrap();
        assert_eq!(sub.name(), "folder/subfolder".to_string());
        assert_eq!(sub.virtual_name("."), "folder.subfolder".to_string());
        let m2dir: &M2dir = sub.as_ref();
        assert_eq!(m2dir.count(), 1);
    }
}