diff --git a/src/commands.rs b/src/commands.rs index 8dce952..744c6c7 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -23,6 +23,7 @@ pub mod kill; pub mod now; pub mod edit; pub mod archive; +pub mod configure; pub trait Command<'a> { type Args: TryFrom<&'a ArgMatches<'a>>; diff --git a/src/commands/configure.rs b/src/commands/configure.rs new file mode 100644 index 0000000..2bc395b --- /dev/null +++ b/src/commands/configure.rs @@ -0,0 +1,166 @@ +use std::convert::TryFrom; +use std::io::Write; +use std::path::PathBuf; + +use clap::ArgMatches; +use chrono::{DateTime, Utc}; + +use crate::database::Database; +use crate::error::{Error, Result}; +use crate::commands::Command; +use crate::config::{Config, WeekDay}; +use crate::formatters::Formatter; + +#[derive(Default)] +pub struct Args { + database_file: Option, + round_in_seconds: Option, + append_notes_delimiter: Option, + formatter_search_paths: Option>, + default_formatter: Option, + auto_sheet: Option, + auto_sheet_search_paths: Option>, + default_command: Option, + auto_checkout: Option, + require_note: Option, + note_editor: Option, + week_start: Option, +} + +impl Args { + /// returns true only if no argument was passed other that possibly --id. + /// This means that an edit was requested without specifying what to edit, + /// therefore let's edit the note because why not + fn none_given(&self) -> bool { + !( + self.database_file.is_some() || + self.round_in_seconds.is_some() || + self.append_notes_delimiter.is_some() || + self.formatter_search_paths.is_some() || + self.default_formatter.is_some() || + self.auto_sheet.is_some() || + self.auto_sheet_search_paths.is_some() || + self.default_command.is_some() || + self.auto_checkout.is_some() || + self.require_note.is_some() || + self.note_editor.is_some() || + self.week_start.is_some() + ) + } +} + +fn yes_no_none(matches: &ArgMatches, opt: &str) -> Option { + if matches.is_present(opt) { + Some(true) + } else if matches.is_present(&format!("no_{}", opt)) { + Some(false) + } else { + None + } +} + +impl<'a> TryFrom<&'a ArgMatches<'a>> for Args { + type Error = Error; + + fn try_from(matches: &'a ArgMatches) -> Result { + Ok(Args { + database_file: matches.value_of("database_file").map(|e| e.into()), + round_in_seconds: matches.value_of("round_in_seconds").map(|v| v.parse().unwrap()), + append_notes_delimiter: matches.value_of("append_notes_delimiter").map(|v| v.to_owned()), + formatter_search_paths: matches.values_of("formatter_search_paths").map(|v| v.map(|a| a.into()).collect()), + default_formatter: matches.value_of("default_formatter").map(|v| v.parse().unwrap()), + auto_sheet: matches.value_of("auto_sheet").map(|v| v.to_owned()), + auto_sheet_search_paths: matches.values_of("auto_sheet_search_paths").map(|v| v.map(|a| a.into()).collect()), + default_command: matches.value_of("default_command").map(|v| v.to_owned()), + auto_checkout: yes_no_none(matches, "auto_checkout"), + require_note: yes_no_none(matches, "require_note"), + note_editor: matches.value_of("note_editor").map(|v| v.to_owned()), + week_start: matches.value_of("week_start").map(|w| w.parse()).transpose()?, + }) + } +} + +pub struct ConfigureCommand {} + +impl<'a> Command<'a> for ConfigureCommand { + type Args = Args; + + fn handle(args: Args, _db: &mut D, out: &mut O, _err: &mut E, config: &Config, _now: DateTime) -> Result<()> + where + D: Database, + O: Write, + E: Write, + { + if args.none_given() { + if let Some(path) = config.path.as_deref() { + writeln!(out, "{}", path.display())?; + } else { + writeln!(out, "Config file is in memory")?; + } + + Ok(()) + } else { + let mut new_config = config.clone(); + + if let Some(path) = args.database_file { + new_config.database_file = path; + } + + if let Some(val) = args.round_in_seconds { + new_config.round_in_seconds = val; + } + + if let Some(val) = args.append_notes_delimiter { + new_config.append_notes_delimiter = val; + } + + if let Some(val) = args.formatter_search_paths { + new_config.formatter_search_paths = val; + } + + if let Some(val) = args.default_formatter { + new_config.default_formatter = val; + } + + if let Some(val) = args.auto_sheet { + new_config.auto_sheet = val; + } + + if let Some(val) = args.auto_sheet_search_paths { + new_config.auto_sheet_search_paths = val; + } + + if let Some(val) = args.default_command { + new_config.default_command = Some(val); + } + + if let Some(val) = args.auto_checkout { + new_config.auto_checkout = val; + } + + if let Some(val) = args.require_note { + new_config.require_note = val; + } + + if let Some(val) = args.note_editor { + new_config.note_editor = Some(val); + } + + if let Some(val) = args.week_start { + new_config.week_start = val; + } + + if let Some(path) = config.path.as_deref() { + let output = new_config.write(path)?; + + writeln!(out, "Your new config:\n")?; + + out.write_all(output.as_bytes())?; + } else { + writeln!(out, "Your config file is in memory and cannot be written to")?; + } + + Ok(()) + } + } +} diff --git a/src/config.rs b/src/config.rs index a78e763..ae96bcf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,12 +2,14 @@ use std::env; 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 crate::{error::{Result, Error::*}, formatters::Formatter}; +use crate::{error::{Result, Error::{self, *}}, formatters::Formatter}; #[derive(Debug, Serialize, Deserialize, Copy, Clone)] pub enum WeekDay { @@ -20,8 +22,31 @@ pub enum WeekDay { Sunday, } -#[derive(Debug, Serialize, Deserialize)] +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())), + }) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct Config { + #[serde(skip)] + pub path: Option, + + #[serde(flatten)] + pub extra: HashMap, + /// Absolute path to the sqlite database pub database_file: PathBuf, // "/home/user/.timetrap.db" @@ -119,6 +144,38 @@ impl Config { } } + pub fn write>(&self, path: P) -> Result { + let path = path.as_ref(); + let ext = path.extension().map(|e| e.to_str()).flatten(); + + 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(); @@ -133,9 +190,13 @@ impl Config { error: e, })?; - serde_yaml::from_str(&contents).map_err(|error| YamlError { - path, error - }) + 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 { @@ -152,9 +213,13 @@ impl Config { error: e, })?; - toml::from_str(&contents).map_err(|error| TomlError { - path, error - }) + 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 @@ -177,6 +242,7 @@ impl Config { }; 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], @@ -200,6 +266,9 @@ impl Config { impl Default for Config { fn default() -> Config { Config { + path: None, + extra: HashMap::new(), + database_file: PathBuf::new(), // see above for definition round_in_seconds: 900, append_notes_delimiter: " ".into(), diff --git a/src/error.rs b/src/error.rs index 0587805..b8d23ae 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,6 +10,12 @@ pub enum Error { #[error("The subcommand '{0}' is not implemented")] UnimplementedCommand(String), + /// Sometimes a specific variant for an error is not necessary if the error + /// can only happen in one place in the code. This is that the generic error + /// is for and nothing else. + #[error("Error: {0}")] + GenericFailure(String), + #[error("Sqlite error: {0}")] Sqlite(#[from] rusqlite::Error), @@ -97,6 +103,9 @@ query it using t backend or the sqlite3 command provided by your system.")] #[error("Could not understand '{0}' as a month specifier. Try 'this', 'last', or any month name like 'january' or 'nov'")] InvalidMonthSpec(String), + #[error("Could not understand '{0}' as a week day. Try 'monday' or 'TuesDay'")] + InvalidWeekDaySpec(String), + #[error("An error ocurred while trying to read entries from the database. In the row with id {id} the data at column '{col}' has a value that is not a diff --git a/src/formatters.rs b/src/formatters.rs index 4fd11c8..99a3942 100644 --- a/src/formatters.rs +++ b/src/formatters.rs @@ -13,7 +13,7 @@ pub mod json; pub mod ids; pub mod ical; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Formatter { Text, @@ -57,9 +57,7 @@ impl FromStr for Formatter { type Err = error::Error; fn from_str(s: &str) -> error::Result { - let lower = s.to_lowercase(); - - Ok(match &*lower { + Ok(match s.to_lowercase().as_str() { "text" => Formatter::Text, "csv" => Formatter::Csv, "json" => Formatter::Json, diff --git a/src/main.rs b/src/main.rs index 3ce77d8..dd4e186 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ use tiempo::commands::{ month::MonthCommand, list::ListCommand, out::OutCommand, resume::ResumeCommand, backend::BackendCommand, kill::KillCommand, now::NowCommand, edit::EditCommand, archive::ArchiveCommand, + configure::ConfigureCommand, }; fn error_trap(matches: ArgMatches) -> error::Result<()> { @@ -46,6 +47,7 @@ fn error_trap(matches: ArgMatches) -> error::Result<()> { ("now", Some(matches)) => NowCommand::handle(matches.try_into()?, &mut conn, &mut out, &mut err, &config, now), ("edit", Some(matches)) => EditCommand::handle(matches.try_into()?, &mut conn, &mut out, &mut err, &config, now), ("archive", Some(matches)) => ArchiveCommand::handle(matches.try_into()?, &mut conn, &mut out, &mut err, &config, now), + ("configure", Some(matches)) => ConfigureCommand::handle(matches.try_into()?, &mut conn, &mut out, &mut err, &config, now), (cmd, _) => Err(error::Error::UnimplementedCommand(cmd.into())), } @@ -94,15 +96,16 @@ fn main() { .help("Use this time instead of now"); let num_re = Regex::new(r"^\d+$").unwrap(); + let is_number = move |v: String| if num_re.is_match(&v) { + Ok(()) + } else { + Err(format!("the --id arg must be a number. '{}' is not a valid number", v)) + }; let id_arg = Arg::with_name("id") .long("id") .takes_value(true).value_name("ID") - .validator(move |v| if num_re.is_match(&v) { - Ok(()) - } else { - Err(format!("the --id arg must be a number. '{}' is not a valid number", v)) - }); + .validator(is_number); // Now declar this app's cli let matches = App::new("Tiempo") @@ -132,12 +135,12 @@ fn main() { .subcommand(SubCommand::with_name("configure") .visible_alias("c") - .about("Configure tiempo. Print path to config file.") + .about("Configure tiempo in-place. If no arguments are given it just prints the path to the config file in use.") .arg(Arg::with_name("round_in_seconds") .long("round-in-seconds") .takes_value(true) .value_name("SECONDS") - .help("The duration of time to use for rounding with the -r flag")) + .help("The duration of time to use for rounding with the -r flag. Default: 900 (15 m)")) .arg(Arg::with_name("database_file") .long("database-file") .takes_value(true) @@ -147,33 +150,41 @@ fn main() { .long("append-notes-delimiter") .takes_value(true) .value_name("DELIMITER") - .help("delimiter used when appending notes via t edit --append")) + .help("delimiter used when appending notes via t edit --append. Default: ' ' (space)")) .arg(Arg::with_name("formatter_search_paths") .long("formatter-search-paths") .takes_value(true) + .multiple(true) .value_name("PATHS") .help("comma separated directories to search for user defined fomatter classes")) .arg(Arg::with_name("default_formatter") .long("default-formatter") .takes_value(true) .value_name("FORMATTER") - .help("The format to use when display is invoked without a `--format` option")) + .help("The format to use when display is invoked without a `--format` option. Default 'text'")) .arg(Arg::with_name("require_note") .long("require-note") - .help("Prompt for a note if one isn't provided when checking in")) + .help("Prompt for a note if one isn't provided when checking in (default)")) .arg(Arg::with_name("no_require_note") .long("no-require-note") - .help("Prompt for a note if one isn't provided when checking in")) + .help("Entries can be created without notes")) + .arg(Arg::with_name("auto_checkout") + .long("auto-checkout") + .help("Checkout of current running entry when starting a new one")) + .arg(Arg::with_name("no_auto_checkout") + .long("no-auto-checkout") + .help("Starting a new entry fails if one is running (default)")) .arg(Arg::with_name("note_editor") .long("note-editor") .takes_value(true) .value_name("EDITOR") - .help("Command to launch notes editor or false if no editor use.")) + .help("Command to launch notes editor. Default: $EDITOR")) .arg(Arg::with_name("week_start") .long("week-start") .takes_value(true) .value_name("DAY") - .help("The day of the week to use as the start of the week for t week.")) + .possible_values(&["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]) + .help("The day of the week to use as the start of the week for t week. Default: monday")) ) .subcommand(SubCommand::with_name("display")