aba 0.8.0

An address book for aerc
// SPDX-FileCopyrightText: 2023 Gustavo Coutinho de Souza <dev@onemoresuza.mailer.me>
//
// SPDX-License-Identifier: ISC

use std::error::Error;
use std::io;
use std::{fs, path::PathBuf};

use indexmap::map::IndexMap;
use mailparse::MailHeaderMap;
use serde::{de, ser::SerializeSeq, Deserialize, Deserializer, Serialize, Serializer};

#[derive(Debug, Deserialize, Serialize)]
pub struct AddressBook {
    #[serde(skip)]
    path: PathBuf,
    #[serde(serialize_with = "AddressBook::serialize_entries")]
    #[serde(deserialize_with = "AddressBook::deserialize_entries")]
    #[serde(rename = "address")]
    entries: IndexMap<String, String>,
}

impl AddressBook {
    fn serialize_entries<S>(entries: &IndexMap<String, String>, s: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut ser = s.serialize_seq(Some(entries.len()))?;

        for (k, v) in entries {
            ser.serialize_element(&IndexMap::from([
                ("name", k.as_str()),
                ("email", v.as_str()),
            ]))?;
        }
        ser.end()
    }

    fn deserialize_entries<'de, D>(deserializer: D) -> Result<IndexMap<String, String>, D::Error>
    where
        D: Deserializer<'de>,
    {
        let res: Result<Vec<IndexMap<String, String>>, D::Error> = Vec::deserialize(deserializer);
        let mut indmap: IndexMap<String, String> = IndexMap::new();
        match res {
            Ok(n) => {
                for e in n.iter() {
                    let Some(name) = e.get("name") else {
                        return Err(de::Error::missing_field("name"));
                    };
                    let Some(email) = e.get("email") else {
                        return Err(de::Error::missing_field("email"));
                    };
                    indmap.insert(name.to_string(), email.to_string());
                }

                Ok(indmap)
            }
            Err(e) => Err(de::Error::custom(e.to_string())),
        }
    }

    fn write_to_file(&self) -> Result<(), Box<dyn Error>> {
        if self.entries.is_empty() {
            Ok(fs::write(&self.path, "")?)
        } else {
            Ok(fs::write(&self.path, toml::to_string(&self)?)?)
        }
    }

    pub fn new(path: Option<PathBuf>) -> Result<Self, Box<dyn Error>> {
        let path = match path.or_else(|| dirs::data_dir().map(|p| p.join("aba.toml"))) {
            Some(p) => p,
            None => return Err("Could not set address book path based on XDG_DATA_DIR".into()),
        };

        match fs::read_to_string(&path) {
            Ok(s) => {
                let mut addbook: Self = toml::from_str(&s)?;
                addbook.path = path;
                Ok(addbook)
            }
            Err(e) if e.kind() == io::ErrorKind::NotFound => {
                fs::OpenOptions::new()
                    .write(true)
                    .create_new(true)
                    .open(&path)?;
                Ok(AddressBook {
                    entries: IndexMap::new(),
                    path,
                })
            }
            Err(e) => Err(e.into()),
        }
    }

    pub fn add(&mut self, name: &str, email: &str, force: bool) -> Result<(), Box<dyn Error>> {
        if !self.entries.is_empty() && self.entries.contains_key(name) && !force {
            return Err(format!("Cannot add \"{}\": It already exists", name).into());
        }

        self.entries.insert(name.to_string(), email.to_string());

        self.write_to_file()?;

        println!("Add \"{}\" with email \"{}\"", name, email);

        Ok(())
    }

    pub fn list(&self, fuzzy: &str, number: usize) -> Result<(), Box<dyn Error>> {
        let names: Vec<&str> = self.entries.keys().map(String::as_ref).collect();
        let matches = rust_fuzzy_search::fuzzy_search_best_n(fuzzy, &names, number);
        for (m, _) in matches {
            // This cannot fail, thus, we unwrap
            println!("\"{}\" <{}>", m, self.entries.get(m).unwrap());
        }
        Ok(())
    }

    pub fn del(&mut self, match_str: &str, is_regex: bool) -> Result<(), Box<dyn Error>> {
        // TODO: Find a way to print removed entries
        if is_regex {
            let cmpregex = regex::Regex::new(match_str)?;
            self.entries.retain(|k, _| !cmpregex.is_match(k));
        } else {
            self.entries.remove(match_str);
        }

        self.write_to_file()?;

        Ok(())
    }

    pub fn parse(
        &mut self,
        filename: &Option<PathBuf>,
        force: bool,
        from: bool,
        cc: bool,
        to: bool,
        all: bool,
    ) -> Result<(), Box<dyn Error>> {
        let raw_mail = match filename {
            Some(p) if p.to_str() == Some("-") => io::read_to_string(io::stdin())?,
            Some(p) => fs::read_to_string(p)?,
            None => io::read_to_string(io::stdin())?,
        };

        let parsed_mail = mailparse::parse_mail(raw_mail.as_bytes())?;
        let regexes: (regex::Regex, regex::Regex) = (
            regex::Regex::new(r"(.*) <(.*)>")?,
            regex::Regex::new(r"(.*)(@.*)")?,
        );
        let possible_headers: Vec<(bool, &str)> = vec![
            // From as the default value
            (from || all || (!to && !cc), "From"),
            (to || all, "To"),
            (cc || all, "Cc"),
        ];
        let mut new_entries: Vec<(String, String)> = Vec::new();

        for (use_header, header) in possible_headers {
            if !use_header {
                continue;
            }

            let values = parsed_mail.headers.get_all_headers(header);

            for v in values {
                let full_address = v.get_value_utf8()?;
                match regexes.0.captures(&full_address).map(|c| c.extract()) {
                    Some((_, [name, email])) => {
                        if !force && self.entries.contains_key(name) {
                            return Err(
                                format!("Cannot add \"{}\": It already exists", name).into()
                            );
                        }

                        new_entries.push((name.to_string(), email.to_string()));
                        self.entries.insert(name.to_string(), email.to_string());
                    }
                    None => match regexes.1.captures(&full_address).map(|c| c.extract()) {
                        Some((email, [user, _])) => {
                            if !force && self.entries.contains_key(user) {
                                return Err(
                                    format!("Cannot add \"{}\": It already exists", user).into()
                                );
                            }

                            new_entries.push((user.to_string(), email.to_string()));
                            self.entries.insert(user.to_string(), email.to_string());
                        }
                        None => return Err(format!("Could not match \"{}\"", full_address).into()),
                    },
                }
            }
        }

        self.write_to_file()?;

        for e in new_entries {
            println!("Added: name \"{}\", email \"{}\"", e.0, e.1);
        }

        Ok(())
    }
}