implement t configure

This commit is contained in:
Abraham Toriz 2021-08-14 11:09:37 -05:00
parent 4ebf0681c2
commit 23a3603628
6 changed files with 279 additions and 25 deletions

View File

@ -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>>;

166
src/commands/configure.rs Normal file
View File

@ -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<PathBuf>,
round_in_seconds: Option<u32>,
append_notes_delimiter: Option<String>,
formatter_search_paths: Option<Vec<PathBuf>>,
default_formatter: Option<Formatter>,
auto_sheet: Option<String>,
auto_sheet_search_paths: Option<Vec<PathBuf>>,
default_command: Option<String>,
auto_checkout: Option<bool>,
require_note: Option<bool>,
note_editor: Option<String>,
week_start: Option<WeekDay>,
}
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<bool> {
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<Self> {
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<D, O, E>(args: Args, _db: &mut D, out: &mut O, _err: &mut E, config: &Config, _now: DateTime<Utc>) -> 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(())
}
}
}

View File

@ -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<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())),
})
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
#[serde(skip)]
pub path: Option<PathBuf>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
/// Absolute path to the sqlite database
pub database_file: PathBuf, // "/home/user/.timetrap.db"
@ -119,6 +144,38 @@ impl Config {
}
}
pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<String> {
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<P: AsRef<Path>>(path: P) -> Result<Config> {
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<P: AsRef<Path>>(path: P) -> Result<Config> {
@ -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(),

View File

@ -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

View File

@ -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<Formatter> {
let lower = s.to_lowercase();
Ok(match &*lower {
Ok(match s.to_lowercase().as_str() {
"text" => Formatter::Text,
"csv" => Formatter::Csv,
"json" => Formatter::Json,

View File

@ -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")