diff --git a/Cargo.lock b/Cargo.lock index 3d695cbfe..3e2127449 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -573,18 +573,6 @@ dependencies = [ "vec_map", ] -[[package]] -name = "config" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1b9d958c2b1368a663f05538fc1b5975adce1e19f435acceae987aceeeb369" -dependencies = [ - "lazy_static", - "nom 5.1.2", - "serde", - "yaml-rust", -] - [[package]] name = "const_fn" version = "0.4.6" @@ -1363,17 +1351,6 @@ dependencies = [ "winapi 0.3.9", ] -[[package]] -name = "nom" -version = "5.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af" -dependencies = [ - "lexical-core", - "memchr", - "version_check", -] - [[package]] name = "nom" version = "6.1.2" @@ -2170,17 +2147,17 @@ dependencies = [ "anyhow", "assert_cmd", "atty", - "config", "dirs-next", "env_logger", "log", - "nom 6.1.2", + "nom", "predicates", "prettytable-rs", "taskchampion", "tempfile", "termcolor", "textwrap 0.13.4", + "toml", ] [[package]] @@ -2778,12 +2755,3 @@ name = "wyz" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" - -[[package]] -name = "yaml-rust" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 570d106da..8d07f7e21 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -14,11 +14,7 @@ prettytable-rs = "^0.8.0" textwrap = { version="^0.13.4", features=["terminal_size"] } termcolor = "^1.1.2" atty = "^0.2.14" - -[dependencies.config] -default-features = false -features = ["yaml"] -version = "^0.11.0" +toml = "^0.5.8" [dependencies.taskchampion] path = "../taskchampion" diff --git a/cli/src/bin/task.rs b/cli/src/bin/task.rs index 8d8a756cb..ecf529be3 100644 --- a/cli/src/bin/task.rs +++ b/cli/src/bin/task.rs @@ -2,7 +2,7 @@ use std::process::exit; pub fn main() { if let Err(err) = taskchampion_cli::main() { - eprintln!("{}", err); + eprintln!("{:?}", err); exit(1); } } diff --git a/cli/src/invocation/cmd/report.rs b/cli/src/invocation/cmd/report.rs index bcb258298..7123f0353 100644 --- a/cli/src/invocation/cmd/report.rs +++ b/cli/src/invocation/cmd/report.rs @@ -1,13 +1,13 @@ use crate::argparse::Filter; use crate::invocation::display_report; -use config::Config; +use crate::settings::Settings; use taskchampion::Replica; use termcolor::WriteColor; pub(crate) fn execute( w: &mut W, replica: &mut Replica, - settings: &Config, + settings: &Settings, report_name: String, filter: Filter, ) -> anyhow::Result<()> { @@ -30,7 +30,7 @@ mod test { // The function being tested is only one line long, so this is sort of an integration test // for display_report. - let settings = crate::settings::default_settings().unwrap(); + let settings = Default::default(); let report_name = "next".to_owned(); let filter = Filter { ..Default::default() diff --git a/cli/src/invocation/mod.rs b/cli/src/invocation/mod.rs index feff61c27..997440b2e 100644 --- a/cli/src/invocation/mod.rs +++ b/cli/src/invocation/mod.rs @@ -1,7 +1,7 @@ //! The invocation module handles invoking the commands parsed by the argparse module. use crate::argparse::{Command, Subcommand}; -use config::Config; +use crate::settings::Settings; use taskchampion::{Replica, Server, ServerConfig, StorageConfig, Uuid}; use termcolor::{ColorChoice, StandardStream}; @@ -19,7 +19,7 @@ use report::display_report; /// Invoke the given Command in the context of the given settings #[allow(clippy::needless_return)] -pub(crate) fn invoke(command: Command, settings: Config) -> anyhow::Result<()> { +pub(crate) fn invoke(command: Command, settings: Settings) -> anyhow::Result<()> { log::debug!("command: {:?}", command); log::debug!("settings: {:?}", settings); @@ -100,35 +100,33 @@ pub(crate) fn invoke(command: Command, settings: Config) -> anyhow::Result<()> { // utilities for invoke /// Get the replica for this invocation -fn get_replica(settings: &Config) -> anyhow::Result { - let taskdb_dir = settings.get_str("data_dir")?.into(); +fn get_replica(settings: &Settings) -> anyhow::Result { + let taskdb_dir = settings.data_dir.clone(); log::debug!("Replica data_dir: {:?}", taskdb_dir); let storage_config = StorageConfig::OnDisk { taskdb_dir }; Ok(Replica::new(storage_config.into_storage()?)) } /// Get the server for this invocation -fn get_server(settings: &Config) -> anyhow::Result> { +fn get_server(settings: &Settings) -> anyhow::Result> { // if server_client_key and server_origin are both set, use // the remote server - let config = if let (Ok(client_key), Ok(origin)) = ( - settings.get_str("server_client_key"), - settings.get_str("server_origin"), + let config = if let (Some(client_key), Some(origin), Some(encryption_secret)) = ( + settings.server_client_key.as_ref(), + settings.server_origin.as_ref(), + settings.encryption_secret.as_ref(), ) { let client_key = Uuid::parse_str(&client_key)?; - let encryption_secret = settings - .get_str("encryption_secret") - .map_err(|_| anyhow::anyhow!("Could not read `encryption_secret` configuration"))?; log::debug!("Using sync-server with origin {}", origin); log::debug!("Sync client ID: {}", client_key); ServerConfig::Remote { - origin, + origin: origin.clone(), client_key, encryption_secret: encryption_secret.as_bytes().to_vec(), } } else { - let server_dir = settings.get_str("server_dir")?.into(); + let server_dir = settings.server_dir.clone(); log::debug!("Using local sync-server at `{:?}`", server_dir); ServerConfig::Local { server_dir } }; diff --git a/cli/src/invocation/report.rs b/cli/src/invocation/report.rs index 0556d3b9d..48db2d4e6 100644 --- a/cli/src/invocation/report.rs +++ b/cli/src/invocation/report.rs @@ -1,8 +1,8 @@ use crate::argparse::Filter; use crate::invocation::filtered_tasks; -use crate::report::{Column, Property, Report, SortBy}; +use crate::settings::{Column, Property, Report, Settings, SortBy}; use crate::table; -use config::Config; +use anyhow::anyhow; use prettytable::{Row, Table}; use std::cmp::Ordering; use taskchampion::{Replica, Task, WorkingSet}; @@ -79,7 +79,7 @@ fn task_column(task: &Task, column: &Column, working_set: &WorkingSet) -> String pub(super) fn display_report( w: &mut W, replica: &mut Replica, - settings: &Config, + settings: &Settings, report_name: String, filter: Filter, ) -> anyhow::Result<()> { @@ -87,8 +87,11 @@ pub(super) fn display_report( let working_set = replica.working_set()?; // Get the report from settings - let mut report = Report::from_config(settings.get(&format!("reports.{}", report_name))?) - .map_err(|e| anyhow::anyhow!("report.{}{}", report_name, e))?; + let mut report = settings + .reports + .get(&report_name) + .ok_or_else(|| anyhow!("report `{}` not defined", report_name))? + .clone(); // include any user-supplied filter conditions report.filter = report.filter.intersect(filter); @@ -122,7 +125,7 @@ pub(super) fn display_report( mod test { use super::*; use crate::invocation::test::*; - use crate::report::Sort; + use crate::settings::Sort; use std::convert::TryInto; use taskchampion::{Status, Uuid}; diff --git a/cli/src/lib.rs b/cli/src/lib.rs index e71dcc024..a846fa9f1 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -38,11 +38,12 @@ mod macros; mod argparse; mod invocation; -mod report; mod settings; mod table; mod usage; +use settings::Settings; + /// The main entry point for the command-line interface. This builds an Invocation /// from the particulars of the operating-system interface, and then executes it. pub fn main() -> anyhow::Result<()> { @@ -59,7 +60,7 @@ pub fn main() -> anyhow::Result<()> { let command = argparse::Command::from_argv(&argv[..])?; // load the application settings - let settings = settings::read_settings()?; + let settings = Settings::read()?; invocation::invoke(command, settings)?; Ok(()) diff --git a/cli/src/report.rs b/cli/src/report.rs deleted file mode 100644 index e2cdddac4..000000000 --- a/cli/src/report.rs +++ /dev/null @@ -1,582 +0,0 @@ -//! This module contains the data structures used to define reports. - -use crate::argparse::{Condition, Filter}; -use anyhow::bail; - -/// A report specifies a filter as well as a sort order and information about which -/// task attributes to display -#[derive(Clone, Debug, PartialEq, Default)] -pub(crate) struct Report { - /// Columns to display in this report - pub columns: Vec, - /// Sort order for this report - pub sort: Vec, - /// Filter selecting tasks for this report - pub filter: Filter, -} - -/// A column to display in a report -#[derive(Clone, Debug, PartialEq)] -pub(crate) struct Column { - /// The label for this column - pub label: String, - - /// The property to display - pub property: Property, -} - -/// Task property to display in a report -#[derive(Clone, Debug, PartialEq)] -#[allow(dead_code)] -pub(crate) enum Property { - /// The task's ID, either working-set index or Uuid if not in the working set - Id, - - /// The task's full UUID - Uuid, - - /// Whether the task is active or not - Active, - - /// The task's description - Description, - - /// The task's tags - Tags, -} - -/// A sorting criterion for a sort operation. -#[derive(Clone, Debug, PartialEq)] -pub(crate) struct Sort { - /// True if the sort should be "ascending" (a -> z, 0 -> 9, etc.) - pub ascending: bool, - - /// The property to sort on - pub sort_by: SortBy, -} - -/// Task property to sort by -#[derive(Clone, Debug, PartialEq)] -#[allow(dead_code)] -pub(crate) enum SortBy { - /// The task's ID, either working-set index or a UUID prefix; working - /// set tasks sort before others. - Id, - - /// The task's full UUID - Uuid, - - /// The task's description - Description, -} - -// Conversions from config::Value. Note that these cannot ergonomically use TryFrom/TryInto; see -// https://github.com/mehcode/config-rs/issues/162 - -impl Report { - /// Create a Report from a config value. This should be the `report.` value. - /// The error message begins with any additional path information, e.g., `.sort[1].sort_by: - /// ..`. - pub(crate) fn from_config(cfg: config::Value) -> anyhow::Result { - let mut map = cfg.into_table().map_err(|e| anyhow::anyhow!(": {}", e))?; - let sort = if let Some(sort_array) = map.remove("sort") { - sort_array - .into_array() - .map_err(|e| anyhow::anyhow!(".sort: {}", e))? - .drain(..) - .enumerate() - .map(|(i, v)| { - Sort::from_config(v).map_err(|e| anyhow::anyhow!(".sort[{}]{}", i, e)) - }) - .collect::>>()? - } else { - vec![] - }; - - let columns = map - .remove("columns") - .ok_or_else(|| anyhow::anyhow!(": 'columns' property is required"))? - .into_array() - .map_err(|e| anyhow::anyhow!(".columns: {}", e))? - .drain(..) - .enumerate() - .map(|(i, v)| { - Column::from_config(v).map_err(|e| anyhow::anyhow!(".columns[{}]{}", i, e)) - }) - .collect::>>()?; - - let conditions = if let Some(conditions) = map.remove("filter") { - conditions - .into_array() - .map_err(|e| anyhow::anyhow!(".filter: {}", e))? - .drain(..) - .enumerate() - .map(|(i, v)| { - v.into_str() - .map_err(|e| e.into()) - .and_then(|s| Condition::parse_str(&s)) - .map_err(|e| anyhow::anyhow!(".filter[{}]: {}", i, e)) - }) - .collect::>>()? - } else { - vec![] - }; - - let filter = Filter { conditions }; - - if !map.is_empty() { - bail!(": unknown properties"); - } - - Ok(Report { - columns, - sort, - filter, - }) - } -} - -impl Column { - pub(crate) fn from_config(cfg: config::Value) -> anyhow::Result { - let mut map = cfg.into_table().map_err(|e| anyhow::anyhow!(": {}", e))?; - let label = map - .remove("label") - .ok_or_else(|| anyhow::anyhow!(": 'label' property is required"))? - .into_str() - .map_err(|e| anyhow::anyhow!(".label: {}", e))?; - let property: config::Value = map - .remove("property") - .ok_or_else(|| anyhow::anyhow!(": 'property' property is required"))?; - let property = - Property::from_config(property).map_err(|e| anyhow::anyhow!(".property{}", e))?; - - if !map.is_empty() { - bail!(": unknown properties"); - } - - Ok(Column { label, property }) - } -} - -impl Property { - pub(crate) fn from_config(cfg: config::Value) -> anyhow::Result { - let s = cfg.into_str().map_err(|e| anyhow::anyhow!(": {}", e))?; - Ok(match s.as_ref() { - "id" => Property::Id, - "uuid" => Property::Uuid, - "active" => Property::Active, - "description" => Property::Description, - "tags" => Property::Tags, - _ => bail!(": unknown property {}", s), - }) - } -} - -impl Sort { - pub(crate) fn from_config(cfg: config::Value) -> anyhow::Result { - let mut map = cfg.into_table().map_err(|e| anyhow::anyhow!(": {}", e))?; - let ascending = match map.remove("ascending") { - Some(v) => v - .into_bool() - .map_err(|e| anyhow::anyhow!(".ascending: {}", e))?, - None => true, // default - }; - let sort_by: config::Value = map - .remove("sort_by") - .ok_or_else(|| anyhow::anyhow!(": 'sort_by' property is required"))?; - let sort_by = SortBy::from_config(sort_by).map_err(|e| anyhow::anyhow!(".sort_by{}", e))?; - - if !map.is_empty() { - bail!(": unknown properties"); - } - - Ok(Sort { ascending, sort_by }) - } -} - -impl SortBy { - pub(crate) fn from_config(cfg: config::Value) -> anyhow::Result { - let s = cfg.into_str().map_err(|e| anyhow::anyhow!(": {}", e))?; - Ok(match s.as_ref() { - "id" => SortBy::Id, - "uuid" => SortBy::Uuid, - "description" => SortBy::Description, - _ => bail!(": unknown sort_by {}", s), - }) - } -} - -#[cfg(test)] -mod test { - use super::*; - use config::{Config, File, FileFormat, FileSourceString}; - use taskchampion::Status; - use textwrap::{dedent, indent}; - - fn config_from(cfg: &str) -> config::Value { - // wrap this in a "table" so that we can get any type of value at the top level. - let yaml = format!("val:\n{}", indent(&dedent(&cfg), " ")); - let mut settings = Config::new(); - let cfg_file: File = File::from_str(&yaml, FileFormat::Yaml); - settings.merge(cfg_file).unwrap(); - settings.cache.into_table().unwrap().remove("val").unwrap() - } - - #[test] - fn test_report_ok() { - let val = config_from( - " - filter: [] - sort: [] - columns: [] - filter: - - status:pending", - ); - let report = Report::from_config(val).unwrap(); - assert_eq!( - report.filter, - Filter { - conditions: vec![Condition::Status(Status::Pending),], - } - ); - assert_eq!(report.columns, vec![]); - assert_eq!(report.sort, vec![]); - } - - #[test] - fn test_report_no_sort() { - let val = config_from( - " - filter: [] - columns: []", - ); - let report = Report::from_config(val).unwrap(); - assert_eq!(report.sort, vec![]); - } - - #[test] - fn test_report_sort_not_array() { - let val = config_from( - " - filter: [] - sort: true - columns: []", - ); - assert_eq!( - &Report::from_config(val).unwrap_err().to_string(), - ".sort: invalid type: boolean `true`, expected an array" - ); - } - - #[test] - fn test_report_sort_error() { - let val = config_from( - " - filter: [] - sort: - - sort_by: id - - true - columns: []", - ); - assert!(&Report::from_config(val) - .unwrap_err() - .to_string() - .starts_with(".sort[1]")); - } - - #[test] - fn test_report_unknown_prop() { - let val = config_from( - " - columns: [] - filter: [] - sort: [] - nosuch: true - ", - ); - assert_eq!( - &Report::from_config(val).unwrap_err().to_string(), - ": unknown properties" - ); - } - - #[test] - fn test_report_no_columns() { - let val = config_from( - " - filter: [] - sort: []", - ); - assert_eq!( - &Report::from_config(val).unwrap_err().to_string(), - ": \'columns\' property is required" - ); - } - - #[test] - fn test_report_columns_not_array() { - let val = config_from( - " - filter: [] - sort: [] - columns: true", - ); - assert_eq!( - &Report::from_config(val).unwrap_err().to_string(), - ".columns: invalid type: boolean `true`, expected an array" - ); - } - - #[test] - fn test_report_column_error() { - let val = config_from( - " - filter: [] - sort: [] - columns: - - label: ID - property: id - - true", - ); - assert!(&Report::from_config(val) - .unwrap_err() - .to_string() - .starts_with(".columns[1]:")); - } - - #[test] - fn test_report_filter_not_array() { - let val = config_from( - " - filter: [] - sort: [] - columns: [] - filter: true", - ); - assert_eq!( - &Report::from_config(val).unwrap_err().to_string(), - ".filter: invalid type: boolean `true`, expected an array" - ); - } - - #[test] - fn test_report_filter_error() { - let val = config_from( - " - filter: [] - sort: [] - columns: [] - filter: - - nosuchfilter", - ); - assert!(&Report::from_config(val) - .unwrap_err() - .to_string() - .starts_with(".filter[0]: invalid filter condition:")); - } - - #[test] - fn test_column() { - let val = config_from( - " - label: ID - property: id", - ); - let column = Column::from_config(val).unwrap(); - assert_eq!( - column, - Column { - label: "ID".to_owned(), - property: Property::Id, - } - ); - } - - #[test] - fn test_column_unknown_prop() { - let val = config_from( - " - label: ID - property: id - nosuch: foo", - ); - assert_eq!( - &Column::from_config(val).unwrap_err().to_string(), - ": unknown properties" - ); - } - - #[test] - fn test_column_no_label() { - let val = config_from( - " - property: id", - ); - assert_eq!( - &Column::from_config(val).unwrap_err().to_string(), - ": 'label' property is required" - ); - } - - #[test] - fn test_column_invalid_label() { - let val = config_from( - " - label: [] - property: id", - ); - assert_eq!( - &Column::from_config(val).unwrap_err().to_string(), - ".label: invalid type: sequence, expected a string" - ); - } - - #[test] - fn test_column_no_property() { - let val = config_from( - " - label: ID", - ); - assert_eq!( - &Column::from_config(val).unwrap_err().to_string(), - ": 'property' property is required" - ); - } - - #[test] - fn test_column_invalid_property() { - let val = config_from( - " - label: ID - property: []", - ); - assert_eq!( - &Column::from_config(val).unwrap_err().to_string(), - ".property: invalid type: sequence, expected a string" - ); - } - - #[test] - fn test_property() { - let val = config_from("uuid"); - let prop = Property::from_config(val).unwrap(); - assert_eq!(prop, Property::Uuid); - } - - #[test] - fn test_property_invalid_type() { - let val = config_from("{}"); - assert_eq!( - &Property::from_config(val).unwrap_err().to_string(), - ": invalid type: map, expected a string" - ); - } - - #[test] - fn test_sort() { - let val = config_from( - " - ascending: false - sort_by: id", - ); - let sort = Sort::from_config(val).unwrap(); - assert_eq!( - sort, - Sort { - ascending: false, - sort_by: SortBy::Id, - } - ); - } - - #[test] - fn test_sort_no_ascending() { - let val = config_from( - " - sort_by: id", - ); - let sort = Sort::from_config(val).unwrap(); - assert_eq!( - sort, - Sort { - ascending: true, - sort_by: SortBy::Id, - } - ); - } - - #[test] - fn test_sort_unknown_prop() { - let val = config_from( - " - sort_by: id - nosuch: foo", - ); - assert_eq!( - &Sort::from_config(val).unwrap_err().to_string(), - ": unknown properties" - ); - } - - #[test] - fn test_sort_no_sort_by() { - let val = config_from( - " - ascending: true", - ); - assert_eq!( - &Sort::from_config(val).unwrap_err().to_string(), - ": 'sort_by' property is required" - ); - } - - #[test] - fn test_sort_invalid_ascending() { - let val = config_from( - " - sort_by: id - ascending: {}", - ); - assert_eq!( - &Sort::from_config(val).unwrap_err().to_string(), - ".ascending: invalid type: map, expected a boolean" - ); - } - - #[test] - fn test_sort_invalid_sort_by() { - let val = config_from( - " - sort_by: {}", - ); - assert_eq!( - &Sort::from_config(val).unwrap_err().to_string(), - ".sort_by: invalid type: map, expected a string" - ); - } - - #[test] - fn test_sort_by() { - let val = config_from("uuid"); - let prop = SortBy::from_config(val).unwrap(); - assert_eq!(prop, SortBy::Uuid); - } - - #[test] - fn test_sort_by_unknown() { - let val = config_from("nosuch"); - assert_eq!( - &SortBy::from_config(val).unwrap_err().to_string(), - ": unknown sort_by nosuch" - ); - } - - #[test] - fn test_sort_by_invalid_type() { - let val = config_from("{}"); - assert_eq!( - &SortBy::from_config(val).unwrap_err().to_string(), - ": invalid type: map, expected a string" - ); - } -} diff --git a/cli/src/settings.rs b/cli/src/settings.rs deleted file mode 100644 index de8c137f2..000000000 --- a/cli/src/settings.rs +++ /dev/null @@ -1,85 +0,0 @@ -use config::{Config, Environment, File, FileFormat, FileSourceFile, FileSourceString}; -use std::env; -use std::path::PathBuf; - -const DEFAULTS: &str = r#" -reports: - list: - sort: - - sort_by: uuid - columns: - - label: Id - property: id - - label: Description - property: description - - label: Active - property: active - - label: Tags - property: tags - next: - filter: - - "status:pending" - sort: - - sort_by: uuid - columns: - - label: Id - property: id - - label: Description - property: description - - label: Active - property: active - - label: Tags - property: tags -"#; - -/// Get the default settings for this application -pub(crate) fn default_settings() -> anyhow::Result { - let mut settings = Config::default(); - - // set up defaults - if let Some(dir) = dirs_next::data_local_dir() { - let mut tc_dir = dir.clone(); - tc_dir.push("taskchampion"); - settings.set_default( - "data_dir", - // the config crate does not support non-string paths - tc_dir.to_str().expect("data_local_dir is not utf-8"), - )?; - - let mut server_dir = dir; - server_dir.push("taskchampion-sync-server"); - settings.set_default( - "server_dir", - // the config crate does not support non-string paths - server_dir.to_str().expect("data_local_dir is not utf-8"), - )?; - } - - let defaults: File = File::from_str(DEFAULTS, FileFormat::Yaml); - settings.merge(defaults)?; - - Ok(settings) -} - -pub(crate) fn read_settings() -> anyhow::Result { - let mut settings = default_settings()?; - - // load either from the path in TASKCHAMPION_CONFIG, or from CONFIG_DIR/taskchampion - if let Some(config_file) = env::var_os("TASKCHAMPION_CONFIG") { - log::debug!("Loading configuration from {:?}", config_file); - let config_file: PathBuf = config_file.into(); - let config_file: File = config_file.into(); - settings.merge(config_file.required(true))?; - env::remove_var("TASKCHAMPION_CONFIG"); - } else if let Some(mut dir) = dirs_next::config_dir() { - dir.push("taskchampion"); - log::debug!("Loading configuration from {:?} (optional)", dir); - let config_file: File = dir.into(); - settings.merge(config_file.required(false))?; - } - - // merge environment variables - settings.merge(Environment::with_prefix("TASKCHAMPION"))?; - - Ok(settings) -} diff --git a/cli/src/settings/mod.rs b/cli/src/settings/mod.rs new file mode 100644 index 000000000..a164150f4 --- /dev/null +++ b/cli/src/settings/mod.rs @@ -0,0 +1,227 @@ +//! Support for the CLI's configuration file, including default settings. +//! +//! Configuration is stored in a "parsed" format, meaning that any syntax errors will be caught on +//! startup and not just when those values are used. + +mod report; +mod util; + +use crate::argparse::{Condition, Filter}; +use anyhow::{anyhow, Context, Result}; +use std::collections::HashMap; +use std::convert::TryFrom; +use std::env; +use std::fs; +use std::path::PathBuf; +use taskchampion::Status; +use toml::value::Table; +use util::table_with_keys; + +pub(crate) use report::{Column, Property, Report, Sort, SortBy}; + +#[derive(Debug)] +pub(crate) struct Settings { + // replica + pub(crate) data_dir: PathBuf, + + // remote sync server + pub(crate) server_client_key: Option, + pub(crate) server_origin: Option, + pub(crate) encryption_secret: Option, + + // local sync server + pub(crate) server_dir: PathBuf, + + // reports + pub(crate) reports: HashMap, +} + +impl Settings { + pub(crate) fn read() -> Result { + if let Some(config_file) = env::var_os("TASKCHAMPION_CONFIG") { + log::debug!("Loading configuration from {:?}", config_file); + env::remove_var("TASKCHAMPION_CONFIG"); + Self::load_from_file(config_file.into(), true) + } else if let Some(mut dir) = dirs_next::config_dir() { + dir.push("taskchampion.toml"); + log::debug!("Loading configuration from {:?} (optional)", dir); + Self::load_from_file(dir, false) + } else { + Ok(Default::default()) + } + } + + fn load_from_file(config_file: PathBuf, required: bool) -> Result { + let mut settings = Self::default(); + + let config_toml = match fs::read_to_string(config_file.clone()) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return if required { + Err(e.into()) + } else { + Ok(settings) + }; + } + Err(e) => return Err(e.into()), + Ok(s) => s, + }; + + let config_toml = config_toml + .parse::() + .with_context(|| format!("error while reading {:?}", config_file))?; + settings + .update_from_toml(&config_toml) + .with_context(|| format!("error while parsing {:?}", config_file))?; + + Ok(settings) + } + + /// Update this object with configuration from the given config file. This is + /// broken out mostly for convenience in error handling + fn update_from_toml(&mut self, config_toml: &toml::Value) -> Result<()> { + let table_keys = [ + "data_dir", + "server_client_key", + "server_origin", + "encryption_secret", + "server_dir", + "reports", + ]; + let table = table_with_keys(&config_toml, &table_keys)?; + + fn get_str_cfg( + table: &Table, + name: &'static str, + setter: F, + ) -> Result<()> { + if let Some(v) = table.get(name) { + setter( + v.as_str() + .ok_or_else(|| anyhow!(".{}: not a string", name))? + .to_owned(), + ); + } + Ok(()) + } + + get_str_cfg(table, "data_dir", |v| { + self.data_dir = v.into(); + })?; + + get_str_cfg(table, "server_client_key", |v| { + self.server_client_key = Some(v); + })?; + + get_str_cfg(table, "server_origin", |v| { + self.server_origin = Some(v); + })?; + + get_str_cfg(table, "encryption_secret", |v| { + self.encryption_secret = Some(v); + })?; + + get_str_cfg(table, "server_dir", |v| { + self.server_dir = v.into(); + })?; + + if let Some(v) = table.get("reports") { + let report_cfgs = v + .as_table() + .ok_or_else(|| anyhow!(".reports: not a table"))?; + for (name, cfg) in report_cfgs { + let report = Report::try_from(cfg).map_err(|e| anyhow!("reports.{}{}", name, e))?; + self.reports.insert(name.clone(), report); + } + } + + Ok(()) + } +} + +impl Default for Settings { + fn default() -> Self { + let data_dir; + let server_dir; + + if let Some(dir) = dirs_next::data_local_dir() { + data_dir = dir.join("taskchampion"); + server_dir = dir.join("taskchampion-sync-server"); + } else { + // fallback + data_dir = PathBuf::from("."); + server_dir = PathBuf::from("."); + } + + // define the default reports + let mut reports = HashMap::new(); + + reports.insert( + "list".to_owned(), + Report { + sort: vec![Sort { + ascending: true, + sort_by: SortBy::Uuid, + }], + columns: vec![ + Column { + label: "id".to_owned(), + property: Property::Id, + }, + Column { + label: "description".to_owned(), + property: Property::Description, + }, + Column { + label: "active".to_owned(), + property: Property::Active, + }, + Column { + label: "tags".to_owned(), + property: Property::Tags, + }, + ], + filter: Default::default(), + }, + ); + + reports.insert( + "next".to_owned(), + Report { + sort: vec![Sort { + ascending: true, + sort_by: SortBy::Uuid, + }], + columns: vec![ + Column { + label: "id".to_owned(), + property: Property::Id, + }, + Column { + label: "description".to_owned(), + property: Property::Description, + }, + Column { + label: "active".to_owned(), + property: Property::Active, + }, + Column { + label: "tags".to_owned(), + property: Property::Tags, + }, + ], + filter: Filter { + conditions: vec![Condition::Status(Status::Pending)], + }, + }, + ); + + Self { + data_dir, + server_client_key: None, + server_origin: None, + encryption_secret: None, + server_dir, + reports, + } + } +} diff --git a/cli/src/settings/report.rs b/cli/src/settings/report.rs new file mode 100644 index 000000000..959494e5d --- /dev/null +++ b/cli/src/settings/report.rs @@ -0,0 +1,535 @@ +//! This module contains the data structures used to define reports. + +use crate::argparse::{Condition, Filter}; +use crate::settings::util::table_with_keys; +use anyhow::{anyhow, bail, Result}; +use std::convert::{TryFrom, TryInto}; + +/// A report specifies a filter as well as a sort order and information about which +/// task attributes to display +#[derive(Clone, Debug, PartialEq, Default)] +pub(crate) struct Report { + /// Columns to display in this report + pub columns: Vec, + /// Sort order for this report + pub sort: Vec, + /// Filter selecting tasks for this report + pub filter: Filter, +} + +/// A column to display in a report +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Column { + /// The label for this column + pub label: String, + + /// The property to display + pub property: Property, +} + +/// Task property to display in a report +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Property { + /// The task's ID, either working-set index or Uuid if not in the working set + Id, + + /// The task's full UUID + Uuid, + + /// Whether the task is active or not + Active, + + /// The task's description + Description, + + /// The task's tags + Tags, +} + +/// A sorting criterion for a sort operation. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Sort { + /// True if the sort should be "ascending" (a -> z, 0 -> 9, etc.) + pub ascending: bool, + + /// The property to sort on + pub sort_by: SortBy, +} + +/// Task property to sort by +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum SortBy { + /// The task's ID, either working-set index or a UUID prefix; working + /// set tasks sort before others. + Id, + + /// The task's full UUID + Uuid, + + /// The task's description + Description, +} + +// Conversions from settings::Settings. + +impl TryFrom for Report { + type Error = anyhow::Error; + + fn try_from(cfg: toml::Value) -> Result { + Report::try_from(&cfg) + } +} + +impl TryFrom<&toml::Value> for Report { + type Error = anyhow::Error; + + /// Create a Report from a toml value. This should be the `report.` value. + /// The error message begins with any additional path information, e.g., `.sort[1].sort_by: + /// ..`. + fn try_from(cfg: &toml::Value) -> Result { + let keys = ["sort", "columns", "filter"]; + let table = table_with_keys(cfg, &keys).map_err(|e| anyhow!(": {}", e))?; + + let sort = match table.get("sort") { + Some(v) => v + .as_array() + .ok_or_else(|| anyhow!(".sort: not an array"))? + .iter() + .enumerate() + .map(|(i, v)| v.try_into().map_err(|e| anyhow!(".sort[{}]{}", i, e))) + .collect::>>()?, + None => vec![], + }; + + let columns = match table.get("columns") { + Some(v) => v + .as_array() + .ok_or_else(|| anyhow!(".columns: not an array"))? + .iter() + .enumerate() + .map(|(i, v)| v.try_into().map_err(|e| anyhow!(".columns[{}]{}", i, e))) + .collect::>>()?, + None => bail!(": `columns` property is required"), + }; + + let conditions = match table.get("filter") { + Some(v) => v + .as_array() + .ok_or_else(|| anyhow!(".filter: not an array"))? + .iter() + .enumerate() + .map(|(i, v)| { + v.as_str() + .ok_or_else(|| anyhow!(".filter[{}]: not a string", i)) + .and_then(|s| Condition::parse_str(&s)) + .map_err(|e| anyhow!(".filter[{}]: {}", i, e)) + }) + .collect::>>()?, + None => vec![], + }; + + Ok(Report { + columns, + sort, + filter: Filter { conditions }, + }) + } +} + +impl TryFrom<&toml::Value> for Column { + type Error = anyhow::Error; + + fn try_from(cfg: &toml::Value) -> Result { + let keys = ["label", "property"]; + let table = table_with_keys(cfg, &keys).map_err(|e| anyhow!(": {}", e))?; + + let label = match table.get("label") { + Some(v) => v + .as_str() + .ok_or_else(|| anyhow!(".label: not a string"))? + .to_owned(), + None => bail!(": `label` property is required"), + }; + + let property = match table.get("property") { + Some(v) => v.try_into().map_err(|e| anyhow!(".property{}", e))?, + None => bail!(": `property` property is required"), + }; + + Ok(Column { label, property }) + } +} + +impl TryFrom<&toml::Value> for Property { + type Error = anyhow::Error; + + fn try_from(cfg: &toml::Value) -> Result { + let s = cfg.as_str().ok_or_else(|| anyhow!(": not a string"))?; + Ok(match s { + "id" => Property::Id, + "uuid" => Property::Uuid, + "active" => Property::Active, + "description" => Property::Description, + "tags" => Property::Tags, + _ => bail!(": unknown property {}", s), + }) + } +} + +impl TryFrom<&toml::Value> for Sort { + type Error = anyhow::Error; + + fn try_from(cfg: &toml::Value) -> Result { + let keys = ["ascending", "sort_by"]; + let table = table_with_keys(cfg, &keys).map_err(|e| anyhow!(": {}", e))?; + let ascending = match table.get("ascending") { + Some(v) => v + .as_bool() + .ok_or_else(|| anyhow!(".ascending: not a boolean value"))?, + None => true, // default + }; + + let sort_by = match table.get("sort_by") { + Some(v) => v.try_into().map_err(|e| anyhow!(".sort_by{}", e))?, + None => bail!(": `sort_by` property is required"), + }; + + Ok(Sort { ascending, sort_by }) + } +} + +impl TryFrom<&toml::Value> for SortBy { + type Error = anyhow::Error; + + fn try_from(cfg: &toml::Value) -> Result { + let s = cfg.as_str().ok_or_else(|| anyhow!(": not a string"))?; + Ok(match s { + "id" => SortBy::Id, + "uuid" => SortBy::Uuid, + "description" => SortBy::Description, + _ => bail!(": unknown sort_by value `{}`", s), + }) + } +} + +#[cfg(test)] +mod test { + use super::*; + use taskchampion::Status; + use toml::toml; + + #[test] + fn test_report_ok() { + let val = toml! { + sort = [] + columns = [] + filter = ["status:pending"] + }; + let report: Report = TryInto::try_into(val).unwrap(); + assert_eq!( + report.filter, + Filter { + conditions: vec![Condition::Status(Status::Pending),], + } + ); + assert_eq!(report.columns, vec![]); + assert_eq!(report.sort, vec![]); + } + + #[test] + fn test_report_no_sort() { + let val = toml! { + filter = [] + columns = [] + }; + let report = Report::try_from(val).unwrap(); + assert_eq!(report.sort, vec![]); + } + + #[test] + fn test_report_sort_not_array() { + let val = toml! { + filter = [] + sort = true + columns = [] + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert_eq!(&err, ".sort: not an array"); + } + + #[test] + fn test_report_sort_error() { + let val = toml! { + filter = [] + sort = [ { sort_by = "id" }, true ] + columns = [] + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert!(err.starts_with(".sort[1]")); + } + + #[test] + fn test_report_unknown_prop() { + let val = toml! { + columns = [] + filter = [] + sort = [] + nosuch = true + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert_eq!(&err, ": unknown table key `nosuch`"); + } + + #[test] + fn test_report_no_columns() { + let val = toml! { + filter = [] + sort = [] + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert_eq!(&err, ": `columns` property is required"); + } + + #[test] + fn test_report_columns_not_array() { + let val = toml! { + filter = [] + sort = [] + columns = true + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert_eq!(&err, ".columns: not an array"); + } + + #[test] + fn test_report_column_error() { + let val = toml! { + filter = [] + sort = [] + + [[columns]] + label = "ID" + property = "id" + + [[columns]] + foo = 10 + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert_eq!(&err, ".columns[1]: unknown table key `foo`"); + } + + #[test] + fn test_report_filter_not_array() { + let val = toml! { + filter = "foo" + sort = [] + columns = [] + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert_eq!(&err, ".filter: not an array"); + } + + #[test] + fn test_report_filter_error() { + let val = toml! { + sort = [] + columns = [] + filter = [ "nosuchfilter" ] + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert!(err.starts_with(".filter[0]: invalid filter condition:")); + } + + #[test] + fn test_column() { + let val = toml! { + label = "ID" + property = "id" + }; + let column = Column::try_from(&val).unwrap(); + assert_eq!( + column, + Column { + label: "ID".to_owned(), + property: Property::Id, + } + ); + } + + #[test] + fn test_column_unknown_prop() { + let val = toml! { + label = "ID" + property = "id" + nosuch = "foo" + }; + assert_eq!( + &Column::try_from(&val).unwrap_err().to_string(), + ": unknown table key `nosuch`" + ); + } + + #[test] + fn test_column_no_label() { + let val = toml! { + property = "id" + }; + assert_eq!( + &Column::try_from(&val).unwrap_err().to_string(), + ": `label` property is required" + ); + } + + #[test] + fn test_column_invalid_label() { + let val = toml! { + label = [] + property = "id" + }; + assert_eq!( + &Column::try_from(&val).unwrap_err().to_string(), + ".label: not a string" + ); + } + + #[test] + fn test_column_no_property() { + let val = toml! { + label = "ID" + }; + assert_eq!( + &Column::try_from(&val).unwrap_err().to_string(), + ": `property` property is required" + ); + } + + #[test] + fn test_column_invalid_property() { + let val = toml! { + label = "ID" + property = [] + }; + assert_eq!( + &Column::try_from(&val).unwrap_err().to_string(), + ".property: not a string" + ); + } + + #[test] + fn test_property() { + let val = toml::Value::String("uuid".to_owned()); + let prop = Property::try_from(&val).unwrap(); + assert_eq!(prop, Property::Uuid); + } + + #[test] + fn test_property_invalid_type() { + let val = toml::Value::Array(vec![]); + assert_eq!( + &Property::try_from(&val).unwrap_err().to_string(), + ": not a string" + ); + } + + #[test] + fn test_sort() { + let val = toml! { + ascending = false + sort_by = "id" + }; + let sort = Sort::try_from(&val).unwrap(); + assert_eq!( + sort, + Sort { + ascending: false, + sort_by: SortBy::Id, + } + ); + } + + #[test] + fn test_sort_no_ascending() { + let val = toml! { + sort_by = "id" + }; + let sort = Sort::try_from(&val).unwrap(); + assert_eq!( + sort, + Sort { + ascending: true, + sort_by: SortBy::Id, + } + ); + } + + #[test] + fn test_sort_unknown_prop() { + let val = toml! { + sort_by = "id" + nosuch = true + }; + assert_eq!( + &Sort::try_from(&val).unwrap_err().to_string(), + ": unknown table key `nosuch`" + ); + } + + #[test] + fn test_sort_no_sort_by() { + let val = toml! { + ascending = true + }; + assert_eq!( + &Sort::try_from(&val).unwrap_err().to_string(), + ": `sort_by` property is required" + ); + } + + #[test] + fn test_sort_invalid_ascending() { + let val = toml! { + sort_by = "id" + ascending = {} + }; + assert_eq!( + &Sort::try_from(&val).unwrap_err().to_string(), + ".ascending: not a boolean value" + ); + } + + #[test] + fn test_sort_invalid_sort_by() { + let val = toml! { + sort_by = {} + }; + assert_eq!( + &Sort::try_from(&val).unwrap_err().to_string(), + ".sort_by: not a string" + ); + } + + #[test] + fn test_sort_by() { + let val = toml::Value::String("uuid".to_string()); + let prop = SortBy::try_from(&val).unwrap(); + assert_eq!(prop, SortBy::Uuid); + } + + #[test] + fn test_sort_by_unknown() { + let val = toml::Value::String("nosuch".to_string()); + assert_eq!( + &SortBy::try_from(&val).unwrap_err().to_string(), + ": unknown sort_by value `nosuch`" + ); + } + + #[test] + fn test_sort_by_invalid_type() { + let val = toml::Value::Array(vec![]); + assert_eq!( + &SortBy::try_from(&val).unwrap_err().to_string(), + ": not a string" + ); + } +} diff --git a/cli/src/settings/util.rs b/cli/src/settings/util.rs new file mode 100644 index 000000000..ea585bef2 --- /dev/null +++ b/cli/src/settings/util.rs @@ -0,0 +1,41 @@ +use anyhow::{anyhow, bail, Result}; +use toml::value::Table; + +/// Check that the input is a table and contains no keys not in the given list, returning +/// the table. +pub(super) fn table_with_keys<'a>(cfg: &'a toml::Value, keys: &[&str]) -> Result<&'a Table> { + let table = cfg.as_table().ok_or_else(|| anyhow!("not a table"))?; + + for tk in table.keys() { + if !keys.iter().any(|k| k == tk) { + bail!("unknown table key `{}`", tk); + } + } + Ok(table) +} + +#[cfg(test)] +mod test { + use super::*; + use toml::toml; + + #[test] + fn test_dissect_table_missing() { + let val = toml! { bar = true }; + let diss = table_with_keys(&val, &["foo", "bar"]).unwrap(); + assert_eq!(diss.get("bar"), Some(&toml::Value::Boolean(true))); + assert_eq!(diss.get("foo"), None); + } + + #[test] + fn test_dissect_table_extra() { + let val = toml! { nosuch = 10 }; + assert!(table_with_keys(&val, &["foo", "bar"]).is_err()); + } + + #[test] + fn test_dissect_table_not_a_table() { + let val = toml::Value::Array(vec![]); + assert!(table_with_keys(&val, &["foo", "bar"]).is_err()); + } +} diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs index e4eb0250b..3a39948b7 100644 --- a/cli/tests/cli.rs +++ b/cli/tests/cli.rs @@ -1,13 +1,31 @@ use assert_cmd::prelude::*; use predicates::prelude::*; +use std::fs; use std::process::Command; +use tempfile::TempDir; // NOTE: This tests that the task binary is running and parsing arguments. The details of // subcommands are handled with unit tests. +/// These tests force config to be read via TASKCHAMPION_CONFIG so that a user's own config file +/// (in their homedir) does not interfere with tests. +fn test_cmd(dir: &TempDir) -> Result> { + let config_filename = dir.path().join("config.toml"); + fs::write( + config_filename.clone(), + format!("data_dir = {:?}", dir.path()), + )?; + + let config_filename = config_filename.to_str().unwrap(); + let mut cmd = Command::cargo_bin("task")?; + cmd.env("TASKCHAMPION_CONFIG", config_filename); + Ok(cmd) +} + #[test] fn help() -> Result<(), Box> { - let mut cmd = Command::cargo_bin("task")?; + let dir = TempDir::new().unwrap(); + let mut cmd = test_cmd(&dir)?; cmd.arg("--help"); cmd.assert() @@ -19,7 +37,8 @@ fn help() -> Result<(), Box> { #[test] fn version() -> Result<(), Box> { - let mut cmd = Command::cargo_bin("task")?; + let dir = TempDir::new().unwrap(); + let mut cmd = test_cmd(&dir)?; cmd.arg("--version"); cmd.assert() @@ -31,7 +50,8 @@ fn version() -> Result<(), Box> { #[test] fn invalid_option() -> Result<(), Box> { - let mut cmd = Command::cargo_bin("task")?; + let dir = TempDir::new().unwrap(); + let mut cmd = test_cmd(&dir)?; cmd.arg("--no-such-option"); cmd.assert() diff --git a/docs/src/config-file.md b/docs/src/config-file.md index 74737b98c..f5daa5be5 100644 --- a/docs/src/config-file.md +++ b/docs/src/config-file.md @@ -2,14 +2,18 @@ The `task` command will work out-of-the-box with no configuration file, using default values. -Configuration is read from `taskchampion.yaml` in your config directory. +Configuration is read from `taskchampion.toml` in your config directory. On Linux systems, that directory is `~/.config`. On OS X, it's `~/Library/Preferences`. On Windows, it's `AppData/Roaming` in your home directory. -The path can be overridden by setting `$TASKCHAMPION_CONFIG`. +This can be overridden by setting `$TASKCHAMPION_CONFIG` to the configuration filename. -Individual configuration parameters can be overridden by environment variables, converted to upper-case and prefixed with `TASKCHAMPION_`, e.g., `TASKCHAMPION_DATA_DIR`. -Nested configuration parameters such as `reports` cannot be overridden by environment variables. +The file format is [TOML](https://toml.io/). +For example: + +```toml +data_dir = "/home/myuser/.tasks" +``` ## Directories diff --git a/docs/src/reports.md b/docs/src/reports.md index 376bf3531..5a7accc05 100644 --- a/docs/src/reports.md +++ b/docs/src/reports.md @@ -14,6 +14,7 @@ $ task Id Description Active Tags 1 learn about TaskChampion +next 2 buy wedding gift * +buy +3 plant tomatoes +garden ``` The `Id` column contains short numeric IDs that are assigned to pending tasks. @@ -23,58 +24,56 @@ The `list` report lists all tasks, with a similar set of columns. ## Custom Reports -Custom reports are defined in the configuration file's `reports` property. +Custom reports are defined in the configuration file's `reports` table. This is a mapping from each report's name to its definition. Each definition has the following properties: -* `filter` - criteria for the tasks to include in the report -* `sort` - how to order the tasks +* `filter` - criteria for the tasks to include in the report (optional) +* `sort` - how to order the tasks (optional) * `columns` - the columns of information to display for each task +For example: + +```toml +[reports.garden] +sort = [ + { sort_by = "description" } +] +filter = [ + "status:pending", + "+garden" +] +columns = [ + { label = "ID", property = "id" }, + { label = "Description", property = "description" }, +] +``` + The filter is a list of filter arguments, just like those that can be used on the command line. -See the `task help` output for more details on this syntax. -For example: +See the `ta help` output for more details on this syntax. +It will be merged with any filters provided on the command line, when the report is invoked. -```yaml -reports: - garden: - filter: - - "status:pending" - - "+garden" -``` - -The sort order is defined by an array of objects containing a `sort_by` property and an optional `ascending` property. +The sort order is defined by an array of tables containing a `sort_by` property and an optional `ascending` property. Tasks are compared by the first criterion, and if that is equal by the second, and so on. -For example: - -```yaml -reports: - garden: - sort: - - sort_by: description - - sort_by: uuid - ascending: false -``` If `ascending` is given, it can be `true` for the default sort order, or `false` for the reverse. +In most cases tasks are just sorted by one criterion, but a more advanced example might look like: + +```toml +[reports.garden] +sort = [ + { sort_by = "description" } + { sort_by = "uuid", ascending = false } +] +... +``` + The available values of `sort_by` are (TODO: generate automatically) -Finally, the configuration specifies the list of columns to display in the `columns` property. -Each element has a `label` and a `property`: - -```yaml -reports: - garden: - columns: - - label: Id - property: id - - label: Description - property: description - - label: Tags - property: tags -``` +Finally, the `columns` configuration specifies the list of columns to display. +Each element has a `label` and a `property`, as shown in the example above. The avaliable properties are: