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",
|
"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",
|
|
||||||
]
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 }
|
||||||
};
|
};
|
||||||
|
|
|
@ -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};
|
||||||
|
|
||||||
|
|
|
@ -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(())
|
||||||
|
|
|
@ -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 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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue