vomit-m2dir 0.3.1

Library for the m2dir email storage format
Documentation
use std::collections::BTreeSet;
use std::ffi::OsString;
use std::fmt::{self, Display};
use std::fs::File;
use std::io::{self, Write};
use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};

/// Errors that can occur while handling flags.
#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error("invalid flag name: {0}")]
    InvalidFlagName(String),
    #[error("I/O error: {0}")]
    IOError(#[from] std::io::Error),
}

/// Represents a single flag.
///
/// Can be either one of a set of spec-defined flags, or a custom value.
///
/// Instances of custom flags should always be created via [`Flag::try_from`],
/// to ensure that the flag does not contain invalid characters (newline) and is
/// not the empty string.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Flag {
    Seen,
    Answered,
    Forwarded,
    Flagged,
    Deleted,
    Draft,
    Important,
    MDNSent,
    Junk,
    NotJunk,
    Phishing,
    Custom(String),
}

impl TryFrom<&str> for Flag {
    type Error = Error;

    fn try_from(flag: &str) -> Result<Self, Error> {
        Ok(match flag {
            "$seen" => Flag::Seen,
            "$answered" => Flag::Answered,
            "$Forwarded" => Flag::Forwarded,
            "$flagged" => Flag::Flagged,
            "$Deleted" => Flag::Deleted,
            "$draft" => Flag::Draft,
            "$Important" => Flag::Important,
            "$MDNSent" => Flag::MDNSent,
            "$Junk" => Flag::Junk,
            "$NotJunk" => Flag::NotJunk,
            "$Phishing" => Flag::Phishing,
            _ => {
                if flag.is_empty() || flag.contains('\n') {
                    return Err(Error::InvalidFlagName(flag.to_string()));
                }
                Flag::Custom(flag.to_string())
            }
        })
    }
}

impl Display for Flag {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = match self {
            Flag::Seen => "$seen",
            Flag::Answered => "$answered",
            Flag::Forwarded => "$Forwarded",
            Flag::Flagged => "$flagged",
            Flag::Deleted => "$Deleted",
            Flag::Draft => "$draft",
            Flag::Important => "$Important",
            Flag::MDNSent => "$MDNSent",
            Flag::Junk => "$Junk",
            Flag::NotJunk => "$NotJunk",
            Flag::Phishing => "$Phishing",
            Flag::Custom(c) => c,
        };
        write!(f, "{}", s)
    }
}

/// A set of [`Flag`]s.
#[derive(Debug, Eq, PartialEq, PartialOrd)]
pub struct Flags(BTreeSet<Flag>);

impl Flags {
    pub fn new() -> Self {
        Flags(BTreeSet::new())
    }

    /// Parse a set of flags from a string.
    ///
    /// The flags must be newline-separated.
    pub fn parse_string(s: &str) -> Result<Self, Error> {
        if s.is_empty() {
            return Ok(Flags::new());
        }
        s.trim_end().split('\n').map(Flag::try_from).collect()
    }

    /// Parse a set of flags from a file.
    ///
    /// The file must be a valid m2dir [flags] file.
    ///
    /// [flags]: https://man.sr.ht/~bitfehler/m2dir/#flags
    pub fn parse_file(f: File) -> Result<Self, Error> {
        // If read as stream, we'd have to deal with the trailing newline
        // It's just the flags, so read it all and have parse_string handle it
        let s = io::read_to_string(&f)?;
        Flags::parse_string(&s)
    }

    /// Write a set of flags to a string.
    ///
    /// The flags will be newline-separated, meaning the output is suitable to
    /// write a valid m2dir [flags] file. For display purposes, use the
    /// implementation of [`Display`] instead.
    ///
    /// [flags]: https://man.sr.ht/~bitfehler/m2dir/#flags
    pub fn write_string(&self) -> String {
        let mut result = String::new();
        for flag in &self.0 {
            result.push_str(&format!("{}\n", flag));
        }
        result
    }

    /// Write a set of flags to a file.
    ///
    /// The file will be a valid m2dir [flags] file.
    ///
    /// [flags]: https://man.sr.ht/~bitfehler/m2dir/#flags
    #[allow(clippy::write_with_newline)]
    pub fn write_file(&self, f: &mut impl Write) -> io::Result<()> {
        for flag in &self.0 {
            write!(f, "{}\n", flag)?;
        }
        f.flush()
    }
}

impl Default for Flags {
    fn default() -> Self {
        Self::new()
    }
}

impl Deref for Flags {
    type Target = BTreeSet<Flag>;
    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl DerefMut for Flags {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.0
    }
}

impl<const N: usize> std::convert::From<[Flag; N]> for Flags {
    fn from(arr: [Flag; N]) -> Self {
        Flags(BTreeSet::from(arr))
    }
}

impl std::convert::From<&BTreeSet<Flag>> for Flags {
    fn from(set: &BTreeSet<Flag>) -> Self {
        let mut c = BTreeSet::new();
        for i in set {
            c.insert(i.clone());
        }
        Flags(c)
    }
}

impl FromIterator<Flag> for Flags {
    fn from_iter<T: IntoIterator<Item = Flag>>(iter: T) -> Self {
        let mut set = BTreeSet::new();
        for i in iter {
            set.insert(i);
        }
        Flags(set)
    }
}

impl IntoIterator for Flags {
    type Item = Flag;
    type IntoIter = <BTreeSet<Flag> as IntoIterator>::IntoIter;
    fn into_iter(self) -> Self::IntoIter {
        self.0.into_iter()
    }
}

impl fmt::Display for Flags {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut first = true;

        write!(f, "(")?;
        for flag in &self.0 {
            if !first {
                write!(f, ", {}", flag)?;
            } else {
                write!(f, "{}", flag)?;
                first = false;
            }
        }
        write!(f, ")")?;
        Ok(())
    }
}

pub(crate) fn flags_path_for(dir: impl AsRef<Path>, id: &str) -> PathBuf {
    PathBuf::from_iter([
        dir.as_ref().as_os_str(),
        &OsString::from(".meta"),
        &OsString::from(format!("{}.flags", id)),
    ])
}

#[cfg(test)]
mod tests {
    use tempfile::NamedTempFile;

    use super::*;

    #[test]
    fn test_flag() {
        assert_eq!(Flag::try_from("$seen").unwrap(), Flag::Seen);
        assert_eq!(Flag::try_from("$Forwarded").unwrap(), Flag::Forwarded);
        assert_eq!(
            Flag::try_from("myflag").unwrap(),
            Flag::Custom(String::from("myflag"))
        );
        assert!(Flag::try_from("my\nflag").is_err());
        assert!(Flag::try_from("").is_err());
    }

    #[test]
    fn test_flags() {
        let set = Flags::from([Flag::Answered]);
        assert_eq!(set.to_string(), String::from("($answered)"));

        let set = Flags::from([Flag::Forwarded, Flag::Answered]);
        assert_eq!(set.to_string(), String::from("($answered, $Forwarded)"));

        let set = Flags::from([
            Flag::Custom(String::from("myflag")),
            Flag::Forwarded,
            Flag::Answered,
        ]);
        assert_eq!(
            set.to_string(),
            String::from("($answered, $Forwarded, myflag)")
        );
    }

    #[test]
    fn test_serialize_string() {
        let set = Flags::from([
            Flag::Custom(String::from("myflag")),
            Flag::Forwarded,
            Flag::Answered,
        ]);
        assert_eq!(
            set.to_string(),
            String::from("($answered, $Forwarded, myflag)")
        );

        let ser = set.write_string();
        let set2 = Flags::parse_string(&ser).unwrap();
        assert_eq!(set, set2);
    }

    #[test]
    fn test_serialize_file() {
        let set = Flags::from([
            Flag::Custom(String::from("myflag")),
            Flag::Forwarded,
            Flag::Answered,
        ]);
        assert_eq!(
            set.to_string(),
            String::from("($answered, $Forwarded, myflag)")
        );

        let mut f = NamedTempFile::new().unwrap();

        set.write_file(&mut f).unwrap();

        let r = File::open(f.path()).unwrap();

        let set2 = Flags::parse_file(r).unwrap();
        assert_eq!(set, set2);
    }
}