tiempo 1.6.0

A command line time tracker
Documentation
use std::path::{Path, PathBuf};
use std::fs::{File, create_dir_all};
use std::io::{Read, Write};
use std::str::FromStr;
use std::collections::HashMap;

use directories::{UserDirs, ProjectDirs};
use serde::{Serialize, Deserialize};
use toml::to_string;
use chrono::Weekday;

use crate::{error::{Result, Error::{self, *}}, formatters::Formatter};

#[derive(Debug, Serialize, Deserialize, Copy, Clone, PartialEq, Eq)]
pub enum WeekDay {
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    Friday,
    Saturday,
    Sunday,
}

impl FromStr for WeekDay {
    type Err = Error;

    fn from_str(s: &str) -> Result<WeekDay> {
        Ok(match s.to_lowercase().as_str() {
            "monday" => WeekDay::Monday,
            "tuesday" => WeekDay::Tuesday,
            "wednesday" => WeekDay::Wednesday,
            "thursday" => WeekDay::Thursday,
            "friday" => WeekDay::Friday,
            "saturday" => WeekDay::Saturday,
            "sunday" => WeekDay::Sunday,
            x => return Err(InvalidWeekDaySpec(x.to_owned())),
        })
    }
}

impl From<Weekday> for WeekDay {
    fn from(wd: Weekday) -> WeekDay {
        match wd {
            Weekday::Mon => WeekDay::Monday,
            Weekday::Tue => WeekDay::Tuesday,
            Weekday::Wed => WeekDay::Wednesday,
            Weekday::Thu => WeekDay::Thursday,
            Weekday::Fri => WeekDay::Friday,
            Weekday::Sat => WeekDay::Saturday,
            Weekday::Sun => WeekDay::Sunday,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ChartFormatterSettings {
    /// This setting is used to highlight hours that go beyond the daily goal.
    /// If unset all hours will look the same.
    pub daily_goal_hours: u32,

    /// If set, weekly hour count will be highlighted in green if equal or
    /// higher than the weekly goal or in red if lower. If not set number will
    /// be displayed in the default color.
    pub weekly_goal_hours: u32,

    /// This is the amount of minutes that each character represents in the
    /// chart
    pub character_equals_minutes: usize,
}

impl Default for ChartFormatterSettings {
    fn default() -> Self {
        Self {
            daily_goal_hours: 0,
            weekly_goal_hours: 0,
            character_equals_minutes: 30,
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct FormattersSettings {
    pub chart: ChartFormatterSettings,

    #[serde(flatten)]
    pub extra: HashMap<String, serde_json::Value>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct BaseCommandSettings {
    pub default_formatter: Option<Formatter>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CommandsSettings {
    pub display: BaseCommandSettings,
    pub month: BaseCommandSettings,
    pub today: BaseCommandSettings,
    pub week: BaseCommandSettings,
    pub yesterday: BaseCommandSettings,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
    #[serde(skip)]
    pub path: Option<PathBuf>,

    /// Absolute path to the sqlite database
    pub database_file: PathBuf, // "/home/user/.timetrap.db"

    /// The duration of time to use for rounding with the -r flag
    pub round_in_seconds: u32, // 900

    /// delimiter used when appending notes via t edit --append
    pub append_notes_delimiter: String, //" "

    /// an array of directories to search for user defined fomatter classes
    pub formatter_search_paths: Vec<PathBuf>, //- "/home/user/.timetrap/formatters"

    /// The format to use when display is invoked without a --format option
    pub default_formatter: Formatter, //text

    /// Which auto sheet module to use.
    pub auto_sheet: String, //dotfiles

    /// an array of directories to search for user defined auto_sheet classes
    pub auto_sheet_search_paths: Vec<PathBuf>, // - "/home/user/.timetrap/auto_sheets"

    /// The default command to invoke when you call t
    pub default_command: Option<String>,

    /// Automatically check out of running entries when you check in or out
    pub auto_checkout: bool, // false

    /// Prompt for a note if one isn't provided when checking in
    pub require_note: bool, // true

    /// The command to start editing notes. Defaults to false which means no
    /// external editor is used. Please see the section below on Notes Editing
    /// for tips on using non-terminal based editors. Example: note_editor:
    /// "vim"
    pub note_editor: Option<String>, // nvim

    /// The day of the week to use as the start of the week for t week.
    pub week_start: WeekDay, // Monday

    /// How many unique entries to show when choosing interactively (for resume
    /// and kill)
    pub interactive_entries: usize,

    /// Individual settings for each formatter
    pub formatters: FormattersSettings,

    /// Settings for each command
    pub commands: CommandsSettings,

    #[serde(flatten)]
    pub extra: HashMap<String, serde_json::Value>,
}

impl Config {
    /// Tries as hard as possible to read the current configuration. Retrieving
    /// the path to it from the environment or common locations.
    pub fn read(timetrap_config_file: Option<&str>) -> Result<Config> {
        // first try from env variable TIMETRAP_CONFIG_FILE
        if let Some(value) = timetrap_config_file {
            return if value.ends_with(".toml") {
                let config_path = PathBuf::from(&value);

                if config_path.is_file() {
                    Self::read_from_toml(value)
                } else {
                    Self::create_and_return_config(config_path.parent().unwrap(), &config_path)
                }
            } else {
                Self::read_from_yaml(value)
            };
        }

        // Next try from some known directories
        if let Some(user_dirs) = UserDirs::new() {
            let old_location = {
                let mut p = user_dirs.home_dir().to_owned();
                p.push(".timetrap.yml");
                p
            };

            if old_location.is_file() {
                return Self::read_from_yaml(old_location);
            }

            if let Some(project_dirs) = ProjectDirs::from("tk", "categulario", "tiempo") {
                let config_filename = {
                    let mut conf = project_dirs.config_dir().to_owned();
                    conf.push("config.toml");
                    conf
                };

                if config_filename.is_file() {
                    Self::read_from_toml(config_filename)
                } else {
                    let config_dir = project_dirs.config_dir();

                    create_dir_all(config_dir).map_err(|e| CouldntCreateConfigDir {
                        path: config_dir.to_owned(),
                        error: e.to_string(),
                    })?;
                    Self::create_and_return_config(project_dirs.config_dir(), &config_filename)
                }
            } else {
                Err(NoHomeDir)
            }
        } else {
            Err(NoHomeDir)
        }
    }

    pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<String> {
        let path = path.as_ref();
        let ext = path.extension().and_then(|e| e.to_str());

        let output = match ext {
            Some("toml") => {
                toml::to_string(self).unwrap()
            },
            Some("yaml" | "yml") => {
                serde_yaml::to_string(self).unwrap()
            },
            Some(ext) => {
                return Err(Error::GenericFailure(format!("\
Your config file has '{}' extension which I don't understand, so I'd rather not
mess with its contents. If its formatted as toml use '.toml' extension. If it is
yaml use '.yml' or '.yaml'", ext)));
            },
            None => {
                return Err(Error::GenericFailure(format!("\
Your config file, located at {} has no extension so I'll not write to it. Please
set it an extension like '.toml' or '.yaml' (and ensure it matches the content's
format)", path.display())));
            },
        };

        let mut f = File::create(path)?;

        f.write_all(output.as_bytes())?;

        Ok(output)
    }

    fn read_from_yaml<P: AsRef<Path>>(path: P) -> Result<Config> {
        let path: PathBuf = path.as_ref().into();

        let mut contents = String::new();
        let mut file = File::open(&path).map_err(|e| CouldntReadConfigFile {
            path: path.clone(),
            error: e,
        })?;

        file.read_to_string(&mut contents).map_err(|e| CouldntReadConfigFile {
            path: path.clone(),
            error: e,
        })?;

        let mut config: Config = serde_yaml::from_str(&contents).map_err(|error| YamlError {
            path: path.clone(), error
        })?;

        config.path = Some(path);

        Ok(config)
    }

    fn read_from_toml<P: AsRef<Path>>(path: P) -> Result<Config> {
        let path: PathBuf = path.as_ref().into();

        let mut contents = String::new();
        let mut file = File::open(&path).map_err(|e| CouldntReadConfigFile {
            path: path.clone(),
            error: e,
        })?;

        file.read_to_string(&mut contents).map_err(|e| CouldntReadConfigFile {
            path: path.clone(),
            error: e,
        })?;

        let mut config: Config = toml::from_str(&contents).map_err(|error| TomlError {
            path: path.clone(), error
        })?;

        config.path = Some(path);

        Ok(config)
    }

    /// Assume the configuration file does not exist, create a default one and
    /// return it.
    fn create_and_return_config(project_dir: &Path, config_filename: &Path) -> Result<Config> {
        let database_filename = {
            let mut p = project_dir.to_owned();
            p.push("database.sqlite3");
            p
        };
        let formatter_search_paths = {
            let mut p = project_dir.to_owned();
            p.push("formatters");
            p
        };
        let auto_sheet_search_paths = {
            let mut p = project_dir.to_owned();
            p.push("auto_sheets");
            p
        };

        let config = Config {
            path: Some(config_filename.to_owned()),
            database_file: database_filename,
            formatter_search_paths: vec![formatter_search_paths],
            auto_sheet_search_paths: vec![auto_sheet_search_paths],
            ..Default::default()
        };

        let mut config_file = File::create(config_filename).map_err(|e| CouldntEditConfigFile {
            path: config_filename.to_owned(),
            error: e.to_string(),
        })?;

        config_file.write_all(to_string(&config).unwrap().as_bytes()).map_err(|e| CouldntEditConfigFile {
            path: config_filename.to_owned(),
            error: e.to_string(),
        })?;

        Ok(config)
    }
}

impl Default for Config {
    fn default() -> Config {
        Config {
            path: None,
            extra: HashMap::new(),

            database_file: PathBuf::new(),
            round_in_seconds: 900,
            append_notes_delimiter: " ".into(),
            formatter_search_paths: Vec::new(),
            default_formatter: Formatter::Text,
            auto_sheet: "dotfiles".into(),
            auto_sheet_search_paths: Vec::new(),
            default_command: None,
            auto_checkout: false,
            require_note: true,
            note_editor: None,
            week_start: WeekDay::Monday,
            interactive_entries: 5,
            formatters: Default::default(),
            commands: Default::default(),
        }
    }
}