mirror of
https://github.com/GothenburgBitFactory/taskwarrior.git
synced 2025-07-07 20:06:36 +02:00
Switch to TOML for configuration
This commit is contained in:
parent
b4a8b150a8
commit
94d1217d81
15 changed files with 901 additions and 776 deletions
36
Cargo.lock
generated
36
Cargo.lock
generated
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -2,7 +2,7 @@ use std::process::exit;
|
|||
|
||||
pub fn main() {
|
||||
if let Err(err) = taskchampion_cli::main() {
|
||||
eprintln!("{}", err);
|
||||
eprintln!("{:?}", err);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 }
|
||||
};
|
||||
|
|
|
@ -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};
|
||||
|
||||
|
|
|
@ -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(())
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
227
cli/src/settings/mod.rs
Normal 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
535
cli/src/settings/report.rs
Normal 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
41
cli/src/settings/util.rs
Normal 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());
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue