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 { 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 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, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct BaseCommandSettings { pub default_formatter: Option, } #[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, /// 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, //- "/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, // - "/home/user/.timetrap/auto_sheets" /// The default command to invoke when you call t pub default_command: Option, /// 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, // 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, } 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 { // 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>(&self, path: P) -> Result { 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>(path: P) -> Result { 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>(path: P) -> Result { 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 { 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(), } } }