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", "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]] [[package]]
name = "const_fn" name = "const_fn"
version = "0.4.6" version = "0.4.6"
@ -1363,17 +1351,6 @@ dependencies = [
"winapi 0.3.9", "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]] [[package]]
name = "nom" name = "nom"
version = "6.1.2" version = "6.1.2"
@ -2170,17 +2147,17 @@ dependencies = [
"anyhow", "anyhow",
"assert_cmd", "assert_cmd",
"atty", "atty",
"config",
"dirs-next", "dirs-next",
"env_logger", "env_logger",
"log", "log",
"nom 6.1.2", "nom",
"predicates", "predicates",
"prettytable-rs", "prettytable-rs",
"taskchampion", "taskchampion",
"tempfile", "tempfile",
"termcolor", "termcolor",
"textwrap 0.13.4", "textwrap 0.13.4",
"toml",
] ]
[[package]] [[package]]
@ -2778,12 +2755,3 @@ name = "wyz"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" 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"] } textwrap = { version="^0.13.4", features=["terminal_size"] }
termcolor = "^1.1.2" termcolor = "^1.1.2"
atty = "^0.2.14" atty = "^0.2.14"
toml = "^0.5.8"
[dependencies.config]
default-features = false
features = ["yaml"]
version = "^0.11.0"
[dependencies.taskchampion] [dependencies.taskchampion]
path = "../taskchampion" path = "../taskchampion"

View file

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

View file

@ -1,13 +1,13 @@
use crate::argparse::Filter; use crate::argparse::Filter;
use crate::invocation::display_report; use crate::invocation::display_report;
use config::Config; use crate::settings::Settings;
use taskchampion::Replica; use taskchampion::Replica;
use termcolor::WriteColor; use termcolor::WriteColor;
pub(crate) fn execute<W: WriteColor>( pub(crate) fn execute<W: WriteColor>(
w: &mut W, w: &mut W,
replica: &mut Replica, replica: &mut Replica,
settings: &Config, settings: &Settings,
report_name: String, report_name: String,
filter: Filter, filter: Filter,
) -> anyhow::Result<()> { ) -> 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 // The function being tested is only one line long, so this is sort of an integration test
// for display_report. // for display_report.
let settings = crate::settings::default_settings().unwrap(); let settings = Default::default();
let report_name = "next".to_owned(); let report_name = "next".to_owned();
let filter = Filter { let filter = Filter {
..Default::default() ..Default::default()

View file

@ -1,7 +1,7 @@
//! The invocation module handles invoking the commands parsed by the argparse module. //! The invocation module handles invoking the commands parsed by the argparse module.
use crate::argparse::{Command, Subcommand}; use crate::argparse::{Command, Subcommand};
use config::Config; use crate::settings::Settings;
use taskchampion::{Replica, Server, ServerConfig, StorageConfig, Uuid}; use taskchampion::{Replica, Server, ServerConfig, StorageConfig, Uuid};
use termcolor::{ColorChoice, StandardStream}; use termcolor::{ColorChoice, StandardStream};
@ -19,7 +19,7 @@ use report::display_report;
/// Invoke the given Command in the context of the given settings /// Invoke the given Command in the context of the given settings
#[allow(clippy::needless_return)] #[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!("command: {:?}", command);
log::debug!("settings: {:?}", settings); log::debug!("settings: {:?}", settings);
@ -100,35 +100,33 @@ pub(crate) fn invoke(command: Command, settings: Config) -> anyhow::Result<()> {
// utilities for invoke // utilities for invoke
/// Get the replica for this invocation /// Get the replica for this invocation
fn get_replica(settings: &Config) -> anyhow::Result<Replica> { fn get_replica(settings: &Settings) -> anyhow::Result<Replica> {
let taskdb_dir = settings.get_str("data_dir")?.into(); let taskdb_dir = settings.data_dir.clone();
log::debug!("Replica data_dir: {:?}", taskdb_dir); log::debug!("Replica data_dir: {:?}", taskdb_dir);
let storage_config = StorageConfig::OnDisk { taskdb_dir }; let storage_config = StorageConfig::OnDisk { taskdb_dir };
Ok(Replica::new(storage_config.into_storage()?)) Ok(Replica::new(storage_config.into_storage()?))
} }
/// Get the server for this invocation /// 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 // if server_client_key and server_origin are both set, use
// the remote server // the remote server
let config = if let (Ok(client_key), Ok(origin)) = ( let config = if let (Some(client_key), Some(origin), Some(encryption_secret)) = (
settings.get_str("server_client_key"), settings.server_client_key.as_ref(),
settings.get_str("server_origin"), settings.server_origin.as_ref(),
settings.encryption_secret.as_ref(),
) { ) {
let client_key = Uuid::parse_str(&client_key)?; 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!("Using sync-server with origin {}", origin);
log::debug!("Sync client ID: {}", client_key); log::debug!("Sync client ID: {}", client_key);
ServerConfig::Remote { ServerConfig::Remote {
origin, origin: origin.clone(),
client_key, client_key,
encryption_secret: encryption_secret.as_bytes().to_vec(), encryption_secret: encryption_secret.as_bytes().to_vec(),
} }
} else { } 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); log::debug!("Using local sync-server at `{:?}`", server_dir);
ServerConfig::Local { server_dir } ServerConfig::Local { server_dir }
}; };

View file

@ -1,8 +1,8 @@
use crate::argparse::Filter; use crate::argparse::Filter;
use crate::invocation::filtered_tasks; use crate::invocation::filtered_tasks;
use crate::report::{Column, Property, Report, SortBy}; use crate::settings::{Column, Property, Report, Settings, SortBy};
use crate::table; use crate::table;
use config::Config; use anyhow::anyhow;
use prettytable::{Row, Table}; use prettytable::{Row, Table};
use std::cmp::Ordering; use std::cmp::Ordering;
use taskchampion::{Replica, Task, WorkingSet}; 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>( pub(super) fn display_report<W: WriteColor>(
w: &mut W, w: &mut W,
replica: &mut Replica, replica: &mut Replica,
settings: &Config, settings: &Settings,
report_name: String, report_name: String,
filter: Filter, filter: Filter,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
@ -87,8 +87,11 @@ pub(super) fn display_report<W: WriteColor>(
let working_set = replica.working_set()?; let working_set = replica.working_set()?;
// Get the report from settings // Get the report from settings
let mut report = Report::from_config(settings.get(&format!("reports.{}", report_name))?) let mut report = settings
.map_err(|e| anyhow::anyhow!("report.{}{}", report_name, e))?; .reports
.get(&report_name)
.ok_or_else(|| anyhow!("report `{}` not defined", report_name))?
.clone();
// include any user-supplied filter conditions // include any user-supplied filter conditions
report.filter = report.filter.intersect(filter); report.filter = report.filter.intersect(filter);
@ -122,7 +125,7 @@ pub(super) fn display_report<W: WriteColor>(
mod test { mod test {
use super::*; use super::*;
use crate::invocation::test::*; use crate::invocation::test::*;
use crate::report::Sort; use crate::settings::Sort;
use std::convert::TryInto; use std::convert::TryInto;
use taskchampion::{Status, Uuid}; use taskchampion::{Status, Uuid};

View file

@ -38,11 +38,12 @@ mod macros;
mod argparse; mod argparse;
mod invocation; mod invocation;
mod report;
mod settings; mod settings;
mod table; mod table;
mod usage; mod usage;
use settings::Settings;
/// The main entry point for the command-line interface. This builds an Invocation /// 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. /// from the particulars of the operating-system interface, and then executes it.
pub fn main() -> anyhow::Result<()> { pub fn main() -> anyhow::Result<()> {
@ -59,7 +60,7 @@ pub fn main() -> anyhow::Result<()> {
let command = argparse::Command::from_argv(&argv[..])?; let command = argparse::Command::from_argv(&argv[..])?;
// load the application settings // load the application settings
let settings = settings::read_settings()?; let settings = Settings::read()?;
invocation::invoke(command, settings)?; invocation::invoke(command, settings)?;
Ok(()) 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 assert_cmd::prelude::*;
use predicates::prelude::*; use predicates::prelude::*;
use std::fs;
use std::process::Command; use std::process::Command;
use tempfile::TempDir;
// NOTE: This tests that the task binary is running and parsing arguments. The details of // NOTE: This tests that the task binary is running and parsing arguments. The details of
// subcommands are handled with unit tests. // 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] #[test]
fn help() -> Result<(), Box<dyn std::error::Error>> { 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.arg("--help");
cmd.assert() cmd.assert()
@ -19,7 +37,8 @@ fn help() -> Result<(), Box<dyn std::error::Error>> {
#[test] #[test]
fn version() -> Result<(), Box<dyn std::error::Error>> { 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.arg("--version");
cmd.assert() cmd.assert()
@ -31,7 +50,8 @@ fn version() -> Result<(), Box<dyn std::error::Error>> {
#[test] #[test]
fn invalid_option() -> Result<(), Box<dyn std::error::Error>> { 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.arg("--no-such-option");
cmd.assert() cmd.assert()

View file

@ -2,14 +2,18 @@
The `task` command will work out-of-the-box with no configuration file, using default values. 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 Linux systems, that directory is `~/.config`.
On OS X, it's `~/Library/Preferences`. On OS X, it's `~/Library/Preferences`.
On Windows, it's `AppData/Roaming` in your home directory. 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`. The file format is [TOML](https://toml.io/).
Nested configuration parameters such as `reports` cannot be overridden by environment variables. For example:
```toml
data_dir = "/home/myuser/.tasks"
```
## Directories ## Directories

View file

@ -14,6 +14,7 @@ $ task
Id Description Active Tags Id Description Active Tags
1 learn about TaskChampion +next 1 learn about TaskChampion +next
2 buy wedding gift * +buy 2 buy wedding gift * +buy
3 plant tomatoes +garden
``` ```
The `Id` column contains short numeric IDs that are assigned to pending tasks. 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
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. This is a mapping from each report's name to its definition.
Each definition has the following properties: Each definition has the following properties:
* `filter` - criteria for the tasks to include in the report * `filter` - criteria for the tasks to include in the report (optional)
* `sort` - how to order the tasks * `sort` - how to order the tasks (optional)
* `columns` - the columns of information to display for each task * `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. 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. See the `ta help` output for more details on this syntax.
For example: It will be merged with any filters provided on the command line, when the report is invoked.
```yaml The sort order is defined by an array of tables containing a `sort_by` property and an optional `ascending` property.
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.
Tasks are compared by the first criterion, and if that is equal by the second, and so on. 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. 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 The available values of `sort_by` are
(TODO: generate automatically) (TODO: generate automatically)
Finally, the configuration specifies the list of columns to display in the `columns` property. Finally, the `columns` configuration specifies the list of columns to display.
Each element has a `label` and a `property`: Each element has a `label` and a `property`, as shown in the example above.
```yaml
reports:
garden:
columns:
- label: Id
property: id
- label: Description
property: description
- label: Tags
property: tags
```
The avaliable properties are: The avaliable properties are: