notmuch-tagrewriter 0.1.0

Retag notmuch mails
Documentation
use std::collections::BTreeSet;
use std::fs::File;
use std::fs::read_to_string;
use std::io::BufReader;
use std::io::Write;
use std::path::PathBuf;

use derive_more::derive::{AsRef, Deref, DerefMut, From, IntoIterator};
use directories::BaseDirs;
use directories::ProjectDirs;
use itertools::Either;
use itertools::Itertools;
use log::*;
use serde::{Deserialize, Serialize};
use serde_with::DisplayFromStr;
use serde_with::serde_as;

use crate::common::Arrow;
use crate::common::LabelledArrow;
use crate::common::Quiver;
use crate::common::Tag;
use crate::common::Vector;
use crate::dnf::DisjunctiveNormalForm;
use crate::parser::BooleanQuery;
use crate::state::EffectiveState;
use crate::state::EffectiveStateError;

#[derive(Debug, Clone, Deserialize)]
pub struct Config {
    /// If true, track the last modification commit on each run,
    /// and on future runs, only retag messages that have been modified.
    pub track_lastmod: Option<bool>,
    pub notmuch_config: Option<PathBuf>,
    pub rules: Vec<ConfigRule>,
}

#[derive(From, Debug)]
pub enum ConfigError {
    #[from]
    ConfigFileError(std::io::Error),
    #[from]
    ConfigParseError(serde_yml::Error),
    #[from]
    NotmuchError(notmuch::Error),
    #[from]
    EffectiveStateError(EffectiveStateError),
    WriteLastModError(Box<dyn std::error::Error>),
}

type ResultConfig = Result<Config, ConfigError>;

impl Config {
    fn get_project_dirs() -> ProjectDirs {
        ProjectDirs::from("dev", "xaltsc", "notmuch-tagrewriter")
            .expect("Error expanding project dir")
    }
    /// Get the default config path.
    fn get_defaut_config_path() -> PathBuf {
        let project_dirs = Self::get_project_dirs();
        project_dirs
            .config_dir()
            .join("config")
            .with_extension("yaml")
    }

    /// Get the config path based on values.
    fn find_config_path(path: Option<PathBuf>) -> PathBuf {
        if let Some(cfg_path) = path {
            cfg_path
        } else {
            Self::get_defaut_config_path()
        }
    }

    pub fn get_rules(&self) -> Rules {
        self.rules
            .iter()
            .flat_map(|r| r.clone().as_rules())
            .collect_vec()
            .into()
    }

    /// Get notmuch config file as specified by rules in notmuch-config(1)
    fn get_notmuch_config_path(&self) -> PathBuf {
        self.notmuch_config.clone().unwrap_or_else(|| {
            std::env::var("NOTMUCH_CONFIG")
                .map(|x| x.into())
                .unwrap_or_else(|_| {
                    let base_dirs = BaseDirs::new().expect("Couldn't get homedir.");
                    let notmuch_dir = base_dirs.config_dir().join("notmuch");
                    let profile_var = std::env::var("NOTMUCH_PROFILE");
                    let xdg_file = match profile_var.clone() {
                        Ok(profile) => notmuch_dir.join(profile).join("config"),
                        Err(_) => notmuch_dir.join("default").join("config"),
                    };
                    if xdg_file.exists() {
                        xdg_file
                    } else {
                        base_dirs.home_dir().join(match profile_var.clone() {
                            Ok(profile) => format!(".notmuch-config.{}", profile),
                            Err(_) => ".notmuch.config".to_string(),
                        })
                    }
                })
        })
    }

    /// Open the notmuch database R/W
    pub fn open_database(&self, dry_run: bool) -> Result<notmuch::Database, notmuch::Error> {
        let config_path = self.get_notmuch_config_path();
        debug!("Configpath: {:?}", &config_path);
        debug!("Opening database…");
        notmuch::Database::open_with_config::<PathBuf, PathBuf>(
            None,
            if dry_run {
                notmuch::DatabaseMode::ReadOnly
            } else {
                notmuch::DatabaseMode::ReadWrite
            },
            Some(config_path),
            None,
        )
    }

    fn get_lastmod(&self) -> Option<u64> {
        if self.track_lastmod.is_some_and(|x| x) {
            let project_dirs = Self::get_project_dirs();
            let filepath = project_dirs.state_dir()?.join("last_revision");
            if filepath.exists() {
                let contents = read_to_string(filepath).ok()?;
                let num = contents.parse().ok()?;
                Some(num)
            } else {
                None
            }
        } else {
            None
        }
    }

    fn set_lastmod(db: &notmuch::Database) -> Result<(), Box<dyn std::error::Error>> {
        let revision = db.revision().revision;
        let project_dirs = Self::get_project_dirs();
        let statedir = project_dirs.state_dir().expect("Can't find state dir.");
        std::fs::create_dir_all(statedir)?;
        let filepath = statedir.join("last_revision");
        let mut file = File::create(filepath)?;
        file.write_all(revision.to_string().as_bytes())?;
        Ok(())
    }

    // TODO: move elsewhere, in app
    pub fn execute(&self, dry_run: bool) -> Result<(), ConfigError> {
        let db = self.open_database(dry_run)?;
        let quiver: Quiver = self.get_rules().try_into()?;
        quiver.execute(&db, dry_run, self.get_lastmod())?;
        if self.track_lastmod.is_some_and(|x| x) {
            Self::set_lastmod(&db).map_err(|x| ConfigError::WriteLastModError(x))?;
        }
        Ok(())
    }

    /// Read the configuration.
    pub fn read(path: Option<PathBuf>) -> ResultConfig {
        let config_path = Self::find_config_path(path);
        let config_contents = File::open(config_path)?;
        let reader = BufReader::new(config_contents);
        let read: Config = serde_yml::from_reader(reader)?;
        Ok(read)
    }
}

/// Wrapper around a vector of `Rule's
#[derive(Debug, From, Clone, Deref, DerefMut, AsRef, IntoIterator)]
pub struct Rules(Vec<Rule>);

#[derive(Debug, Clone, Deserialize)]
#[serde(transparent)]
struct ConfigMultiQuery {
    #[serde(with = "either::serde_untagged")]
    query: Either<ConfigSingleQuery, Vec<ConfigSingleQuery>>,
}

#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ConfigSingleQuery {
    #[serde_as(as = "DisplayFromStr")]
    query: BooleanQuery,
}

#[derive(Debug, Clone, Deserialize)]
pub struct ConfigRule {
    query: ConfigMultiQuery,
    rewrite: Vector,
    name: Option<String>,
}

impl ConfigRule {
    fn as_rules(self) -> Vec<Rule> {
        match self.query.query {
            Either::Left(s) => vec![Rule {
                query: s.query,
                rewrite: self.rewrite,
                name: self.name,
            }],
            Either::Right(ss) => ss
                .iter()
                .map(|s| Rule {
                    query: s.query.clone(),
                    rewrite: self.rewrite.clone(),
                    name: self.name.clone(),
                })
                .collect(),
        }
    }
}

#[derive(Debug, Clone)]
pub struct Rule {
    pub query: BooleanQuery,
    pub rewrite: Vector,
    pub name: Option<String>,
}

impl TryFrom<Rule> for Vec<LabelledArrow> {
    type Error = EffectiveStateError;
    fn try_from(value: Rule) -> Result<Self, Self::Error> {
        let label = value.name.unwrap_or_else(|| value.rewrite.to_string());
        let dnf: DisjunctiveNormalForm<Tag> = value.query.into();
        let states: Vec<EffectiveState> = dnf
            .iter()
            .map(|x| EffectiveState::try_from_conjunction(x.clone()))
            .try_collect()?;
        let arrows = states
            .iter()
            .map(|s| LabelledArrow {
                name: label.clone(),
                arrow: Arrow {
                    source: s.clone(),
                    vector: value.rewrite.clone(),
                },
            })
            .collect();
        Ok(arrows)
    }
}

impl TryFrom<Rules> for Quiver {
    type Error = EffectiveStateError;
    fn try_from(value: Rules) -> Result<Self, Self::Error> {
        let arrows: Vec<Vec<LabelledArrow>> = value
            .iter()
            .map(|x| <Vec<LabelledArrow>>::try_from(x.clone()))
            .try_collect()?;
        Ok(arrows
            .iter()
            .flatten()
            .map(Clone::clone)
            .collect::<BTreeSet<_>>()
            .into())
    }
}