taskfinder 2.15.0

A terminal user interface that extracts and displays tasks from plain text files, hooking into your default terminal-based editor for editing.
#![forbid(unsafe_code)]
//! Handle app configuration.

use std::fs::{self, File};
use std::io;
use std::path::{Path, PathBuf};

use ratatui::{text::Text, widgets::Row};
use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::modes::Mode;

pub static DEFAULT_INCLUDE_COMPLETED: bool = false;
pub static DEFAULT_DAYS_TO_STALE: u64 = 365;
pub static DEFAULT_OVERDUE_BLINK: bool = true;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("Cannot locate user's configuration directory.")]
    LocateConfigDir,
    #[error("Cannot locate user's data directory.")]
    LocateDataDir,
    #[error("Cannot locate user's home directory.")]
    LocateHomeDir,
    #[error("Cannot create directory to contain log file.")]
    CreateDataDir { error: io::Error },
    #[error("Cannot create log file: {error}")]
    CreateLogFile { error: io::Error },
    #[error("Cannot create directory to contain files with tasks.")]
    CreateTaskFilesDir { error: io::Error },
    #[error("Cannot read/open configuration file: {error}")]
    ReadConfigFile { error: Box<dyn std::error::Error> },
    #[error("Cannot write to configuration file: {error}")]
    WriteConfigFile { error: Box<dyn std::error::Error> },
    #[error("Cannot create configuration directory: {error}")]
    CreateConfigDir { error: io::Error },
    #[error("Cannot create configuration file: {error}")]
    CreateConfigFile { error: io::Error },
    #[error("There must be 5 priority markers in the configuration file.")]
    IncorrectNumberOfPriorityMarkers,
}

#[derive(Deserialize, Serialize, Clone)]
pub struct Config {
    pub path: PathBuf,
    pub num_tasks_log: PathBuf,
    pub file_extensions: Vec<String>,
    pub days_to_stale: u64,
    pub include_completed: bool,
    pub priority_markers: Vec<String>,
    pub evergreen_file: PathBuf,
    pub start_mode: Mode,
    pub overdue_blink: bool,
}

impl Config {
    /// Read config file and deserialize into Config.
    pub fn from_file() -> Result<Self, ConfigError> {
        let mut config = match fs::read_to_string(Config::path()?) {
            Ok(v) => v,
            Err(e) => return Err(ConfigError::ReadConfigFile { error: Box::new(e) }),
        };

        // `start_mode` was introduced as a configuration setting after v2.7; add a default value
        // for it if not in user's config file.
        let mut save = false;
        if !config.contains("start_mode") {
            config.push_str("\nstart_mode = \"Files\"");
            save = true;
        }

        // `overdue_blink` was introduced as a configuration setting after v2.9; add a default
        // value for it if not in user's config file.
        if !config.contains("overdue_blink") {
            config.push_str("\noverdue_blink = true");
            save = true;
        }

        let config: Config = match toml::from_str(&config) {
            Ok(v) => v,
            Err(e) => return Err(ConfigError::ReadConfigFile { error: Box::new(e) }),
        };

        // Save the config we just read if a new configuration setting had been added above.
        if save {
            config.save()?;
        }

        Ok(config)
    }

    /// Serialize and write Config to file.
    pub fn save(&self) -> Result<(), ConfigError> {
        let contents = match toml::to_string(&self) {
            Ok(v) => v,
            Err(e) => return Err(ConfigError::WriteConfigFile { error: Box::new(e) }),
        };
        if let Err(e) = fs::write(Config::path()?, contents) {
            return Err(ConfigError::WriteConfigFile { error: Box::new(e) });
        }
        Ok(())
    }

    /// Get the path to the config file.
    fn path() -> Result<PathBuf, ConfigError> {
        let mut config_dir = match dirs::config_dir() {
            Some(v) => v,
            None => return Err(ConfigError::LocateConfigDir),
        };
        config_dir.push("taskfinder");

        if !config_dir.exists()
            && let Err(e) = fs::create_dir_all(&config_dir)
        {
            return Err(ConfigError::CreateConfigDir { error: e });
        }
        let mut config_path = config_dir;
        config_path.push("config.toml");
        Ok(config_path)
    }

    /// Default files directory.
    pub fn default_files_path() -> Result<PathBuf, ConfigError> {
        let mut home_dir = match dirs::home_dir() {
            Some(v) => v,
            None => return Err(ConfigError::LocateHomeDir),
        };
        home_dir.push("taskfinder");
        Ok(home_dir.to_path_buf())
    }

    /// Default path to tasks log, created if it doesn't exist.
    pub fn default_num_tasks_log() -> Result<PathBuf, ConfigError> {
        let mut data_dir = match dirs::data_dir() {
            Some(v) => v,
            None => return Err(ConfigError::LocateDataDir),
        };
        data_dir.push("taskfinder");
        if !data_dir.exists()
            && let Err(e) = fs::create_dir_all(&data_dir)
        {
            return Err(ConfigError::CreateDataDir { error: e });
        }
        let mut num_tasks_log = data_dir;
        num_tasks_log.push("num_tasks_log");
        if !num_tasks_log.exists()
            && let Err(e) = File::create(&num_tasks_log)
        {
            return Err(ConfigError::CreateLogFile { error: e });
        }
        Ok(num_tasks_log)
    }

    /// Default extensions of files to look for tasks in.
    pub fn default_file_extensions() -> Vec<String> {
        vec!["txt".to_string(), "md".to_string()]
    }

    /// Default priority markers.
    pub fn default_priority_markers() -> Vec<String> {
        vec![
            "pri@1".to_string(),
            "pri@2".to_string(),
            "pri@3".to_string(),
            "pri@4".to_string(),
            "pri@5".to_string(),
        ]
    }

    /// Default path to evergreen file.
    pub fn default_evergreen_file() -> PathBuf {
        Path::new("").to_path_buf()
    }

    /// Create (first time app is run) or get configuration.
    pub fn create_or_get() -> Result<Self, ConfigError> {
        let config_path = Config::path()?;
        let home_dir = Config::default_files_path()?;
        let config = if !config_path.exists() {
            if let Err(e) = File::create(&config_path) {
                return Err(ConfigError::CreateConfigFile { error: e });
            }

            let config = Config {
                path: home_dir.clone(),
                num_tasks_log: Config::default_num_tasks_log()?,
                file_extensions: Config::default_file_extensions(),
                days_to_stale: DEFAULT_DAYS_TO_STALE,
                include_completed: DEFAULT_INCLUDE_COMPLETED,
                priority_markers: Config::default_priority_markers(),
                evergreen_file: Config::default_evergreen_file(),
                start_mode: Mode::Files,
                overdue_blink: DEFAULT_OVERDUE_BLINK,
            };

            if let Err(e) = File::options()
                .write(true)
                .truncate(true)
                .open(&config_path)
            {
                return Err(ConfigError::ReadConfigFile { error: Box::new(e) });
            }

            config.save()?;
            config
        } else {
            Config::from_file()?
        };

        // Create ~/taskfinder if no path for data files is configured and it doesn't already exist.
        if !home_dir.exists()
            && config.path == home_dir.to_path_buf()
            && let Err(e) = fs::create_dir_all(&home_dir)
        {
            return Err(ConfigError::CreateTaskFilesDir { error: e });
        }
        Ok(config)
    }

    /// Create table for the setting-change UI.
    pub fn table_rows(&self) -> Vec<Row<'static>> {
        vec![
            Row::new(vec![
                Text::from("path"),
                Text::from(format!("{}", self.path.to_string_lossy())),
            ]),
            Row::new(vec![
                Text::from("file extensions"),
                Text::from(self.file_extensions.join(",")),
            ]),
            Row::new(vec![
                Text::from("days to stale"),
                Text::from(format!("{:?}", self.days_to_stale)),
            ]),
            Row::new(vec![
                Text::from("include completed?"),
                Text::from(format!("{:?}", self.include_completed)),
            ]),
            Row::new(vec![
                Text::from("priority markers"),
                Text::from(self.priority_markers.join(",")),
            ]),
            Row::new(vec![
                Text::from("evergreen file"),
                Text::from(format!("{}", self.evergreen_file.to_string_lossy())),
            ]),
            Row::new(vec![
                Text::from("starting mode"),
                Text::from(format!("{:?}", self.start_mode)),
            ]),
            Row::new(vec![
                Text::from("overdue blink"),
                Text::from(format!("{:?}", self.overdue_blink)),
            ]),
        ]
    }
}