Switch to TOML for configuration

This commit is contained in:
Dustin J. Mitchell 2021-05-02 16:59:51 -04:00
parent b4a8b150a8
commit 94d1217d81
15 changed files with 901 additions and 776 deletions

36
Cargo.lock generated
View file

@ -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",
]

View file

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

View file

@ -2,7 +2,7 @@ use std::process::exit;
pub fn main() {
if let Err(err) = taskchampion_cli::main() {
eprintln!("{}", err);
eprintln!("{:?}", err);
exit(1);
}
}

View file

@ -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: WriteColor>(
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()

View file

@ -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<Replica> {
let taskdb_dir = settings.get_str("data_dir")?.into();
fn get_replica(settings: &Settings) -> anyhow::Result<Replica> {
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<Box<dyn Server>> {
fn get_server(settings: &Settings) -> anyhow::Result<Box<dyn Server>> {
// 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 }
};

View file

@ -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: WriteColor>(
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<W: WriteColor>(
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<W: WriteColor>(
mod test {
use super::*;
use crate::invocation::test::*;
use crate::report::Sort;
use crate::settings::Sort;
use std::convert::TryInto;
use taskchampion::{Status, Uuid};

View file

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

View file

@ -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<Column>,
/// Sort order for this report
pub sort: Vec<Sort>,
/// 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.<report_name>` 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<Report> {
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::<anyhow::Result<Vec<_>>>()?
} 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::<anyhow::Result<Vec<_>>>()?;
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::<anyhow::Result<Vec<_>>>()?
} 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<Column> {
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<Property> {
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<Sort> {
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<SortBy> {
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<FileSourceString> = 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"
);
}
}

View file

@ -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<Config> {
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<FileSourceString> = File::from_str(DEFAULTS, FileFormat::Yaml);
settings.merge(defaults)?;
Ok(settings)
}
pub(crate) fn read_settings() -> anyhow::Result<Config> {
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<FileSourceFile> = 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<FileSourceFile> = dir.into();
settings.merge(config_file.required(false))?;
}
// merge environment variables
settings.merge(Environment::with_prefix("TASKCHAMPION"))?;
Ok(settings)
}

227
cli/src/settings/mod.rs Normal file
View file

@ -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<String>,
pub(crate) server_origin: Option<String>,
pub(crate) encryption_secret: Option<String>,
// local sync server
pub(crate) server_dir: PathBuf,
// reports
pub(crate) reports: HashMap<String, Report>,
}
impl Settings {
pub(crate) fn read() -> Result<Self> {
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<Self> {
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::<toml::Value>()
.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<F: FnOnce(String)>(
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,
}
}
}

535
cli/src/settings/report.rs Normal file
View file

@ -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<Column>,
/// Sort order for this report
pub sort: Vec<Sort>,
/// 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<toml::Value> for Report {
type Error = anyhow::Error;
fn try_from(cfg: toml::Value) -> Result<Report> {
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.<report_name>` value.
/// The error message begins with any additional path information, e.g., `.sort[1].sort_by:
/// ..`.
fn try_from(cfg: &toml::Value) -> Result<Report> {
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::<Result<Vec<_>>>()?,
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::<Result<Vec<_>>>()?,
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::<Result<Vec<_>>>()?,
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<Column> {
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<Property> {
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<Sort> {
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<SortBy> {
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"
);
}
}

41
cli/src/settings/util.rs Normal file
View file

@ -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());
}
}

View file

@ -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<Command, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
#[test]
fn version() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
#[test]
fn invalid_option() -> Result<(), Box<dyn std::error::Error>> {
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()

View file

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

View file

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