Merge branch 'main' into sqlstore
1
.github/workflows/publish-docs.yml
vendored
|
@ -8,7 +8,6 @@ on:
|
|||
jobs:
|
||||
mdbook-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: mdbook
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
|
2765
Cargo.lock
generated
55
POLICY.md
Normal file
|
@ -0,0 +1,55 @@
|
|||
# Compatibility & deprecation
|
||||
|
||||
Until TaskChampion reaches [v1.0.0](https://github.com/taskchampion/taskchampion/milestone/7), nothing is set in stone. That being said, we aim for the following:
|
||||
|
||||
1. Major versions represent significant change and may be incompatible with previous major release.
|
||||
2. Minor versions are always backwards compatible and might add some new functionality.
|
||||
3. Patch versions should not introduce any new functionality and do what name implies — fix bugs.
|
||||
|
||||
As there are no major releases yet, we do not support any older versions. Users are encouraged to use the latest release.
|
||||
|
||||
## ABI policy
|
||||
|
||||
1. We target stable `rustc`.
|
||||
2. TaskChampion will never upgrade any storage to a non-compatible version without explicit user's request.
|
||||
|
||||
## API policy
|
||||
|
||||
1. Deprecated features return a warning at least 1 minor version prior to being removed.
|
||||
|
||||
Example:
|
||||
|
||||
> If support of `--bar` is to be dropped in v2.0.0, we shall announce it in v1.9.0 at latest.
|
||||
|
||||
2. We aim to issue a notice of newly added functionality when appropriate.
|
||||
|
||||
Example:
|
||||
|
||||
> "NOTICE: Since v1.1.0 you can use `--foo` in conjunction with `--bar`. Foobar!"
|
||||
|
||||
3. TaskChampion always uses UTF-8.
|
||||
|
||||
## Command-line interface
|
||||
|
||||
Considered to be part of the API policy.
|
||||
|
||||
## CLI exit codes
|
||||
|
||||
- `0` No errors, normal exit.
|
||||
- `1` Generic error.
|
||||
- `2` Never used to avoid conflicts with Bash.
|
||||
- `3` Unable to execute with the given parameters.
|
||||
- `4` I/O error.
|
||||
- `5` Database error.
|
||||
|
||||
# Security
|
||||
|
||||
To report a vulnerability, please contact [dustin@cs.uchicago.edu](dustin@cs.uchicago.edu), you may use GPG public-key `D8097934A92E4B4210368102FF8B7AC6154E3226` which is available [here](https://keybase.io/djmitche/pgp_keys.asc?fingerprint=d8097934a92e4b4210368102ff8b7ac6154e3226). Initial response is expected within ~48h.
|
||||
|
||||
We kinldy ask to follow the responsible disclosure model and refrain from sharing information until:
|
||||
1. Vulnerabilities are patched in TaskChampion + 60 days to coordinate with distributions.
|
||||
2. 90 days since the vulnerability is disclosed to us.
|
||||
|
||||
We recognise the legitimacy of public interest and accept that security researchers can publish information after 90-days deadline unilaterally.
|
||||
|
||||
We will assist with obtaining CVE and acknowledge the vulnerabilites reported.
|
12
SECURITY.md
|
@ -1,12 +0,0 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
This software is currently pre-release, so no versions are formally supported.
|
||||
|
||||
Once 1.0 has been released, only the most recent version will be supported.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
To report a vulnerability in this application, contact me directly at `dustin@cs.uchicago.edu`.
|
||||
You can expect an initial response within a day or two, and a regular email conversational cadence thereafter.
|
|
@ -14,11 +14,8 @@ 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"
|
||||
toml_edit = "^0.2.0"
|
||||
|
||||
[dependencies.taskchampion]
|
||||
path = "../taskchampion"
|
||||
|
|
|
@ -49,10 +49,10 @@ mod test {
|
|||
#[test]
|
||||
fn test_version() {
|
||||
assert_eq!(
|
||||
Command::from_argv(argv!["task", "version"]).unwrap(),
|
||||
Command::from_argv(argv!["ta", "version"]).unwrap(),
|
||||
Command {
|
||||
subcommand: Subcommand::Version,
|
||||
command_name: s!("task"),
|
||||
command_name: s!("ta"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
36
cli/src/argparse/config.rs
Normal file
|
@ -0,0 +1,36 @@
|
|||
use super::args::{any, arg_matching, literal};
|
||||
use super::ArgList;
|
||||
use crate::usage;
|
||||
use nom::{combinator::*, sequence::*, IResult};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
/// A config operation
|
||||
pub(crate) enum ConfigOperation {
|
||||
/// Set a configuration value
|
||||
Set(String, String),
|
||||
}
|
||||
|
||||
impl ConfigOperation {
|
||||
pub(super) fn parse(input: ArgList) -> IResult<ArgList, ConfigOperation> {
|
||||
fn set_to_op(input: (&str, &str, &str)) -> Result<ConfigOperation, ()> {
|
||||
Ok(ConfigOperation::Set(input.1.to_owned(), input.2.to_owned()))
|
||||
}
|
||||
map_res(
|
||||
tuple((
|
||||
arg_matching(literal("set")),
|
||||
arg_matching(any),
|
||||
arg_matching(any),
|
||||
)),
|
||||
set_to_op,
|
||||
)(input)
|
||||
}
|
||||
|
||||
pub(super) fn get_usage(u: &mut usage::Usage) {
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "config set",
|
||||
syntax: "config set <key> <value>",
|
||||
summary: "Set a configuration value",
|
||||
description: "Update Taskchampion configuration file to set key = value",
|
||||
});
|
||||
}
|
||||
}
|
|
@ -18,12 +18,14 @@ That is, they contain no references, and have no methods to aid in their executi
|
|||
*/
|
||||
mod args;
|
||||
mod command;
|
||||
mod config;
|
||||
mod filter;
|
||||
mod modification;
|
||||
mod subcommand;
|
||||
|
||||
pub(crate) use args::TaskId;
|
||||
pub(crate) use command::Command;
|
||||
pub(crate) use config::ConfigOperation;
|
||||
pub(crate) use filter::{Condition, Filter};
|
||||
pub(crate) use modification::{DescriptionMod, Modification};
|
||||
pub(crate) use subcommand::Subcommand;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
use super::args::*;
|
||||
use super::{ArgList, DescriptionMod, Filter, Modification};
|
||||
use super::{ArgList, ConfigOperation, DescriptionMod, Filter, Modification};
|
||||
use crate::usage;
|
||||
use nom::{branch::alt, combinator::*, sequence::*, IResult};
|
||||
use taskchampion::Status;
|
||||
|
@ -25,6 +25,11 @@ pub(crate) enum Subcommand {
|
|||
summary: bool,
|
||||
},
|
||||
|
||||
/// Manipulate configuration
|
||||
Config {
|
||||
config_operation: ConfigOperation,
|
||||
},
|
||||
|
||||
/// Add a new task
|
||||
Add {
|
||||
modification: Modification,
|
||||
|
@ -61,6 +66,7 @@ impl Subcommand {
|
|||
all_consuming(alt((
|
||||
Version::parse,
|
||||
Help::parse,
|
||||
Config::parse,
|
||||
Add::parse,
|
||||
Modify::parse,
|
||||
Info::parse,
|
||||
|
@ -74,6 +80,7 @@ impl Subcommand {
|
|||
pub(super) fn get_usage(u: &mut usage::Usage) {
|
||||
Version::get_usage(u);
|
||||
Help::get_usage(u);
|
||||
Config::get_usage(u);
|
||||
Add::get_usage(u);
|
||||
Modify::get_usage(u);
|
||||
Info::get_usage(u);
|
||||
|
@ -131,6 +138,26 @@ impl Help {
|
|||
fn get_usage(_u: &mut usage::Usage) {}
|
||||
}
|
||||
|
||||
struct Config;
|
||||
|
||||
impl Config {
|
||||
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
|
||||
fn to_subcommand(input: (&str, ConfigOperation)) -> Result<Subcommand, ()> {
|
||||
Ok(Subcommand::Config {
|
||||
config_operation: input.1,
|
||||
})
|
||||
}
|
||||
map_res(
|
||||
tuple((arg_matching(literal("config")), ConfigOperation::parse)),
|
||||
to_subcommand,
|
||||
)(input)
|
||||
}
|
||||
|
||||
fn get_usage(u: &mut usage::Usage) {
|
||||
ConfigOperation::get_usage(u);
|
||||
}
|
||||
}
|
||||
|
||||
struct Add;
|
||||
|
||||
impl Add {
|
||||
|
@ -427,6 +454,19 @@ mod test {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_set() {
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["config", "set", "x", "y"]).unwrap(),
|
||||
(
|
||||
&EMPTY[..],
|
||||
Subcommand::Config {
|
||||
config_operation: ConfigOperation::Set("x".to_owned(), "y".to_owned())
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_description() {
|
||||
let subcommand = Subcommand::Add {
|
||||
|
|
|
@ -2,7 +2,7 @@ use std::process::exit;
|
|||
|
||||
pub fn main() {
|
||||
if let Err(err) = taskchampion_cli::main() {
|
||||
eprintln!("{}", err);
|
||||
eprintln!("{:?}", err);
|
||||
exit(1);
|
||||
}
|
||||
}
|
62
cli/src/invocation/cmd/config.rs
Normal file
|
@ -0,0 +1,62 @@
|
|||
use crate::argparse::ConfigOperation;
|
||||
use crate::settings::Settings;
|
||||
use termcolor::{ColorSpec, WriteColor};
|
||||
|
||||
pub(crate) fn execute<W: WriteColor>(
|
||||
w: &mut W,
|
||||
config_operation: ConfigOperation,
|
||||
settings: &Settings,
|
||||
) -> anyhow::Result<()> {
|
||||
match config_operation {
|
||||
ConfigOperation::Set(key, value) => {
|
||||
let filename = settings.set(&key, &value)?;
|
||||
write!(w, "Set configuration value ")?;
|
||||
w.set_color(ColorSpec::new().set_bold(true))?;
|
||||
write!(w, "{}", &key)?;
|
||||
w.set_color(ColorSpec::new().set_bold(false))?;
|
||||
write!(w, " in ")?;
|
||||
w.set_color(ColorSpec::new().set_bold(true))?;
|
||||
writeln!(w, "{:?}.", filename)?;
|
||||
w.set_color(ColorSpec::new().set_bold(false))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::invocation::test::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_config_set() {
|
||||
let cfg_dir = TempDir::new().unwrap();
|
||||
let cfg_file = cfg_dir.path().join("foo.toml");
|
||||
fs::write(
|
||||
cfg_file.clone(),
|
||||
"# store data everywhere\ndata_dir = \"/nowhere\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
|
||||
|
||||
let mut w = test_writer();
|
||||
|
||||
execute(
|
||||
&mut w,
|
||||
ConfigOperation::Set("data_dir".to_owned(), "/somewhere".to_owned()),
|
||||
&settings,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(w.into_string().starts_with("Set configuration value "));
|
||||
|
||||
let updated_toml = fs::read_to_string(cfg_file.clone()).unwrap();
|
||||
dbg!(&updated_toml);
|
||||
assert_eq!(
|
||||
updated_toml,
|
||||
"# store data everywhere\ndata_dir = \"/somewhere\"\n"
|
||||
);
|
||||
}
|
||||
}
|
|
@ -19,12 +19,12 @@ mod test {
|
|||
#[test]
|
||||
fn test_summary() {
|
||||
let mut w = test_writer();
|
||||
execute(&mut w, s!("task"), true).unwrap();
|
||||
execute(&mut w, s!("ta"), true).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_long() {
|
||||
let mut w = test_writer();
|
||||
execute(&mut w, s!("task"), false).unwrap();
|
||||
execute(&mut w, s!("ta"), false).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
//! Responsible for executing commands as parsed by [`crate::argparse`].
|
||||
|
||||
pub(crate) mod add;
|
||||
pub(crate) mod config;
|
||||
pub(crate) mod gc;
|
||||
pub(crate) mod help;
|
||||
pub(crate) mod info;
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -6,7 +6,7 @@ pub(crate) fn execute<W: WriteColor>(
|
|||
replica: &mut Replica,
|
||||
server: &mut Box<dyn Server>,
|
||||
) -> anyhow::Result<()> {
|
||||
replica.sync(server).unwrap();
|
||||
replica.sync(server)?;
|
||||
writeln!(w, "sync complete.")?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
@ -35,6 +35,10 @@ pub(crate) fn invoke(command: Command, settings: Config) -> anyhow::Result<()> {
|
|||
subcommand: Subcommand::Help { summary },
|
||||
command_name,
|
||||
} => return cmd::help::execute(&mut w, command_name, summary),
|
||||
Command {
|
||||
subcommand: Subcommand::Config { config_operation },
|
||||
..
|
||||
} => return cmd::config::execute(&mut w, config_operation, &settings),
|
||||
Command {
|
||||
subcommand: Subcommand::Version,
|
||||
..
|
||||
|
@ -90,6 +94,10 @@ pub(crate) fn invoke(command: Command, settings: Config) -> anyhow::Result<()> {
|
|||
subcommand: Subcommand::Help { .. },
|
||||
..
|
||||
} => unreachable!(),
|
||||
Command {
|
||||
subcommand: Subcommand::Config { .. },
|
||||
..
|
||||
} => unreachable!(),
|
||||
Command {
|
||||
subcommand: Subcommand::Version,
|
||||
..
|
||||
|
@ -100,35 +108,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};
|
||||
|
@ -18,8 +18,6 @@ fn sort_tasks(tasks: &mut Vec<Task>, report: &Report, working_set: &WorkingSet)
|
|||
let b_uuid = b.get_uuid();
|
||||
let a_id = working_set.by_uuid(a_uuid);
|
||||
let b_id = working_set.by_uuid(b_uuid);
|
||||
println!("a_uuid {} -> a_id {:?}", a_uuid, a_id);
|
||||
println!("b_uuid {} -> b_id {:?}", b_uuid, b_id);
|
||||
match (a_id, b_id) {
|
||||
(Some(a_id), Some(b_id)) => a_id.cmp(&b_id),
|
||||
(Some(_), None) => Ordering::Less,
|
||||
|
@ -79,7 +77,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 +85,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 +123,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};
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
#![deny(clippy::all)]
|
||||
#![allow(clippy::unnecessary_wraps)] // for Rust 1.50, https://github.com/rust-lang/rust-clippy/pull/6765
|
||||
#![allow(clippy::module_inception)] // we use re-exports to shorten stuttering paths like settings::settings::Settings
|
||||
/*!
|
||||
This crate implements the command-line interface to TaskChampion.
|
||||
|
||||
|
@ -38,11 +39,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 +61,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)
|
||||
}
|
11
cli/src/settings/mod.rs
Normal file
|
@ -0,0 +1,11 @@
|
|||
//! 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 settings;
|
||||
mod util;
|
||||
|
||||
pub(crate) use report::{Column, Property, Report, Sort, SortBy};
|
||||
pub(crate) use settings::Settings;
|
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"
|
||||
);
|
||||
}
|
||||
}
|
360
cli/src/settings/settings.rs
Normal file
|
@ -0,0 +1,360 @@
|
|||
use super::util::table_with_keys;
|
||||
use super::{Column, Property, Report, Sort, SortBy};
|
||||
use crate::argparse::{Condition, Filter};
|
||||
use anyhow::{anyhow, bail, 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 toml_edit::Document;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) struct Settings {
|
||||
// filename from which this configuration was loaded, if any
|
||||
pub(crate) filename: Option<PathBuf>,
|
||||
|
||||
// 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(filename) = Settings::default_filename() {
|
||||
log::debug!("Loading configuration from {:?} (optional)", filename);
|
||||
Self::load_from_file(filename, false)
|
||||
} else {
|
||||
Ok(Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the default filename for the configuration, or None if that cannot
|
||||
/// be determined.
|
||||
fn default_filename() -> Option<PathBuf> {
|
||||
dirs_next::config_dir().map(|dir| dir.join("taskchampion.toml"))
|
||||
}
|
||||
|
||||
/// Update this settings object with the contents of the given TOML file. Top-level settings
|
||||
/// are overwritten, and reports are overwritten by name.
|
||||
pub(crate) 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 {
|
||||
settings.filename = Some(config_file);
|
||||
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.filename = Some(config_file.clone());
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Set a value in the config file, modifying it in place. Returns the filename.
|
||||
pub(crate) fn set(&self, key: &str, value: &str) -> Result<PathBuf> {
|
||||
let allowed_keys = [
|
||||
"data_dir",
|
||||
"server_client_key",
|
||||
"server_origin",
|
||||
"encryption_secret",
|
||||
"server_dir",
|
||||
// reports is not allowed, since it is not a string
|
||||
];
|
||||
if !allowed_keys.contains(&key) {
|
||||
bail!("No such configuration key {}", key);
|
||||
}
|
||||
|
||||
let filename = if let Some(ref f) = self.filename {
|
||||
f.clone()
|
||||
} else {
|
||||
Settings::default_filename()
|
||||
.ok_or_else(|| anyhow!("Could not determine config file name"))?
|
||||
};
|
||||
|
||||
let mut document = fs::read_to_string(filename.clone())
|
||||
.context("Could not read existing configuration file")?
|
||||
.parse::<Document>()
|
||||
.context("Could not parse existing configuration file")?;
|
||||
|
||||
document[key] = toml_edit::value(value);
|
||||
|
||||
fs::write(filename.clone(), document.to_string())
|
||||
.context("Could not write updated configuration file")?;
|
||||
|
||||
Ok(filename)
|
||||
}
|
||||
}
|
||||
|
||||
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::Id,
|
||||
},
|
||||
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 {
|
||||
filename: None,
|
||||
data_dir,
|
||||
server_client_key: None,
|
||||
server_origin: None,
|
||||
encryption_secret: None,
|
||||
server_dir,
|
||||
reports,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
use toml::toml;
|
||||
|
||||
#[test]
|
||||
fn test_load_from_file_not_required() {
|
||||
let cfg_dir = TempDir::new().unwrap();
|
||||
let cfg_file = cfg_dir.path().join("foo.toml");
|
||||
|
||||
let settings = Settings::load_from_file(cfg_file.clone(), false).unwrap();
|
||||
|
||||
let mut expected = Settings::default();
|
||||
expected.filename = Some(cfg_file.clone());
|
||||
assert_eq!(settings, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_from_file_required() {
|
||||
let cfg_dir = TempDir::new().unwrap();
|
||||
|
||||
assert!(Settings::load_from_file(cfg_dir.path().join("foo.toml"), true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_from_file_exists() {
|
||||
let cfg_dir = TempDir::new().unwrap();
|
||||
let cfg_file = cfg_dir.path().join("foo.toml");
|
||||
fs::write(cfg_file.clone(), "data_dir = \"/nowhere\"").unwrap();
|
||||
|
||||
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
|
||||
assert_eq!(settings.data_dir, PathBuf::from("/nowhere"));
|
||||
assert_eq!(settings.filename, Some(cfg_file));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_from_toml_top_level_keys() {
|
||||
let val = toml! {
|
||||
data_dir = "/data"
|
||||
server_client_key = "sck"
|
||||
server_origin = "so"
|
||||
encryption_secret = "es"
|
||||
server_dir = "/server"
|
||||
};
|
||||
let mut settings = Settings::default();
|
||||
settings.update_from_toml(&val).unwrap();
|
||||
|
||||
assert_eq!(settings.data_dir, PathBuf::from("/data"));
|
||||
assert_eq!(settings.server_client_key, Some("sck".to_owned()));
|
||||
assert_eq!(settings.server_origin, Some("so".to_owned()));
|
||||
assert_eq!(settings.encryption_secret, Some("es".to_owned()));
|
||||
assert_eq!(settings.server_dir, PathBuf::from("/server"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_from_toml_report() {
|
||||
let val = toml! {
|
||||
[reports.foo]
|
||||
sort = [ { sort_by = "id" } ]
|
||||
columns = [ { label = "ID", property = "id" } ]
|
||||
};
|
||||
let mut settings = Settings::default();
|
||||
settings.update_from_toml(&val).unwrap();
|
||||
|
||||
assert!(settings.reports.get("foo").is_some());
|
||||
// beyond existence of this report, we can rely on Report's unit tests
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_valid_key() {
|
||||
let cfg_dir = TempDir::new().unwrap();
|
||||
let cfg_file = cfg_dir.path().join("foo.toml");
|
||||
fs::write(cfg_file.clone(), "server_dir = \"/srv\"").unwrap();
|
||||
|
||||
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
|
||||
assert_eq!(settings.filename, Some(cfg_file.clone()));
|
||||
settings.set("data_dir", "/data").unwrap();
|
||||
|
||||
// load the file again and see the change
|
||||
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
|
||||
assert_eq!(settings.data_dir, PathBuf::from("/data"));
|
||||
assert_eq!(settings.server_dir, PathBuf::from("/srv"));
|
||||
assert_eq!(settings.filename, Some(cfg_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());
|
||||
}
|
||||
}
|
|
@ -42,7 +42,7 @@ impl Usage {
|
|||
writeln!(w, "USAGE:\n {} [args]\n", command_name)?;
|
||||
writeln!(w, "TaskChampion subcommands:")?;
|
||||
for subcommand in self.subcommands.iter() {
|
||||
subcommand.write_help(&mut w, summary)?;
|
||||
subcommand.write_help(&mut w, command_name, summary)?;
|
||||
}
|
||||
writeln!(w, "Filter Expressions:\n")?;
|
||||
writeln!(
|
||||
|
@ -56,7 +56,7 @@ impl Usage {
|
|||
)
|
||||
)?;
|
||||
for filter in self.filters.iter() {
|
||||
filter.write_help(&mut w, summary)?;
|
||||
filter.write_help(&mut w, command_name, summary)?;
|
||||
}
|
||||
writeln!(w, "Modifications:\n")?;
|
||||
writeln!(
|
||||
|
@ -70,10 +70,10 @@ impl Usage {
|
|||
)
|
||||
)?;
|
||||
for modification in self.modifications.iter() {
|
||||
modification.write_help(&mut w, summary)?;
|
||||
modification.write_help(&mut w, command_name, summary)?;
|
||||
}
|
||||
if !summary {
|
||||
writeln!(w, "\nSee `task help` for more detail")?;
|
||||
writeln!(w, "\nSee `{} help` for more detail", command_name)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
@ -108,13 +108,14 @@ pub(crate) struct Subcommand {
|
|||
}
|
||||
|
||||
impl Subcommand {
|
||||
fn write_help<W: Write>(&self, mut w: W, summary: bool) -> Result<()> {
|
||||
fn write_help<W: Write>(&self, mut w: W, command_name: &str, summary: bool) -> Result<()> {
|
||||
if summary {
|
||||
writeln!(w, " task {} - {}", self.name, self.summary)?;
|
||||
writeln!(w, " {} {} - {}", command_name, self.name, self.summary)?;
|
||||
} else {
|
||||
writeln!(
|
||||
w,
|
||||
" task {}\n{}",
|
||||
" {} {}\n{}",
|
||||
command_name,
|
||||
self.syntax,
|
||||
indented(self.description, " ")
|
||||
)?;
|
||||
|
@ -138,7 +139,7 @@ pub(crate) struct Filter {
|
|||
}
|
||||
|
||||
impl Filter {
|
||||
fn write_help<W: Write>(&self, mut w: W, summary: bool) -> Result<()> {
|
||||
fn write_help<W: Write>(&self, mut w: W, _: &str, summary: bool) -> Result<()> {
|
||||
if summary {
|
||||
writeln!(w, " {} - {}", self.syntax, self.summary)?;
|
||||
} else {
|
||||
|
@ -168,7 +169,7 @@ pub(crate) struct Modification {
|
|||
}
|
||||
|
||||
impl Modification {
|
||||
fn write_help<W: Write>(&self, mut w: W, summary: bool) -> Result<()> {
|
||||
fn write_help<W: Write>(&self, mut w: W, _: &str, summary: bool) -> Result<()> {
|
||||
if summary {
|
||||
writeln!(w, " {} - {}", self.syntax, self.summary)?;
|
||||
} else {
|
||||
|
|
|
@ -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
|
||||
// NOTE: This tests that the `ta` 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("ta")?;
|
||||
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
docs/assets/cgi/LICENSE.md
Normal file
|
@ -0,0 +1,2 @@
|
|||
Copyright (C) Andrew Savchenko - All Rights Reserved
|
||||
All files within this folder are proprietary and reserved for the use by TaskChampion project.
|
BIN
docs/assets/cgi/icon_rounded/icon_rounded_1024.png
Executable file
After Width: | Height: | Size: 554 KiB |
BIN
docs/assets/cgi/icon_rounded/icon_rounded_128.png
Executable file
After Width: | Height: | Size: 17 KiB |
BIN
docs/assets/cgi/icon_rounded/icon_rounded_16.png
Executable file
After Width: | Height: | Size: 1.2 KiB |
BIN
docs/assets/cgi/icon_rounded/icon_rounded_256.png
Executable file
After Width: | Height: | Size: 51 KiB |
BIN
docs/assets/cgi/icon_rounded/icon_rounded_32.png
Executable file
After Width: | Height: | Size: 2.3 KiB |
BIN
docs/assets/cgi/icon_rounded/icon_rounded_512.png
Executable file
After Width: | Height: | Size: 166 KiB |
BIN
docs/assets/cgi/icon_rounded/icon_rounded_64.png
Executable file
After Width: | Height: | Size: 5.7 KiB |
BIN
docs/assets/cgi/icon_square/icon_square_1024.png
Executable file
After Width: | Height: | Size: 523 KiB |
BIN
docs/assets/cgi/icon_square/icon_square_128.png
Executable file
After Width: | Height: | Size: 14 KiB |
BIN
docs/assets/cgi/icon_square/icon_square_16.png
Executable file
After Width: | Height: | Size: 1 KiB |
BIN
docs/assets/cgi/icon_square/icon_square_256.png
Executable file
After Width: | Height: | Size: 45 KiB |
BIN
docs/assets/cgi/icon_square/icon_square_32.png
Executable file
After Width: | Height: | Size: 2 KiB |
BIN
docs/assets/cgi/icon_square/icon_square_512.png
Executable file
After Width: | Height: | Size: 152 KiB |
BIN
docs/assets/cgi/icon_square/icon_square_64.png
Executable file
After Width: | Height: | Size: 4.9 KiB |
BIN
docs/assets/cgi/logo/logo_1024.png
Executable file
After Width: | Height: | Size: 807 KiB |
BIN
docs/assets/cgi/logo/logo_128.png
Executable file
After Width: | Height: | Size: 21 KiB |
BIN
docs/assets/cgi/logo/logo_16.png
Executable file
After Width: | Height: | Size: 1.3 KiB |
BIN
docs/assets/cgi/logo/logo_256.png
Executable file
After Width: | Height: | Size: 67 KiB |
BIN
docs/assets/cgi/logo/logo_32.png
Executable file
After Width: | Height: | Size: 2.8 KiB |
BIN
docs/assets/cgi/logo/logo_512.png
Executable file
After Width: | Height: | Size: 229 KiB |
BIN
docs/assets/cgi/logo/logo_64.png
Executable file
After Width: | Height: | Size: 7.2 KiB |
|
@ -1,20 +1,20 @@
|
|||
# Summary
|
||||
|
||||
- [Welcome to TaskChampion](./welcome.md)
|
||||
- [Installation](./installation.md)
|
||||
* [Installation](./installation.md)
|
||||
* [Using the Task Command](./using-task-command.md)
|
||||
* [Configuration](./config-file.md)
|
||||
* [Reports](./reports.md)
|
||||
* [Tags](./tags.md)
|
||||
* [Environment](./environment.md)
|
||||
* [Synchronization](./task-sync.md)
|
||||
* [Running the Sync Server](./running-sync-server.md)
|
||||
* [Debugging](./debugging.md)
|
||||
- [Internal Details](./internals.md)
|
||||
- [Data Model](./data-model.md)
|
||||
- [Replica Storage](./storage.md)
|
||||
- [Task Database](./taskdb.md)
|
||||
- [Tasks](./tasks.md)
|
||||
- [Synchronization and the Sync Server](./sync.md)
|
||||
- [Synchronization Model](./sync-model.md)
|
||||
- [Server-Replica Protocol](./sync-protocol.md)
|
||||
- [Planned Functionality](./plans.md)
|
||||
* [Data Model](./data-model.md)
|
||||
* [Replica Storage](./storage.md)
|
||||
* [Task Database](./taskdb.md)
|
||||
* [Tasks](./tasks.md)
|
||||
* [Synchronization and the Sync Server](./sync.md)
|
||||
* [Synchronization Model](./sync-model.md)
|
||||
* [Server-Replica Protocol](./sync-protocol.md)
|
||||
* [Planned Functionality](./plans.md)
|
||||
|
|
|
@ -1,15 +1,19 @@
|
|||
# Configuration
|
||||
|
||||
The `task` command will work out-of-the-box with no configuration file, using default values.
|
||||
The `ta` 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
|
||||
|
||||
|
@ -36,7 +40,15 @@ If using a remote server:
|
|||
* `server_client_key` - Client key to identify this replica to the sync server (a UUID)
|
||||
If not set, then sync is done to a local server.
|
||||
|
||||
# Reports
|
||||
## Reports
|
||||
|
||||
* `reports` - a mapping of each report's name to its definition.
|
||||
See [Reports](./reports.md) for details.
|
||||
|
||||
## Editing
|
||||
|
||||
As a shortcut, the simple, top-level configuration values can be edited from the command line:
|
||||
|
||||
```shell
|
||||
ta config set data_dir /home/myuser/.taskchampion
|
||||
```
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
# Debugging
|
||||
|
||||
Both `task` and `taskchampion-sync-server` use [env-logger](https://docs.rs/env_logger) and can be configured to log at various levels with the `RUST_LOG` environment variable.
|
||||
For example:
|
||||
```shell
|
||||
$ RUST_LOG=taskchampion=trace task add foo
|
||||
```
|
||||
|
||||
The output may provide valuable clues in debugging problems.
|
21
docs/src/environment.md
Normal file
|
@ -0,0 +1,21 @@
|
|||
# Environment Variables
|
||||
|
||||
## Configuration
|
||||
|
||||
Set `TASKCHAMPION_CONFIG` to the location of a configuration file in order to override the default location.
|
||||
|
||||
## Terminal Output
|
||||
|
||||
Taskchampion uses [termcolor](https://github.com/BurntSushi/termcolor) to color its output.
|
||||
This library interprets [`TERM` and `NO_COLOR`](https://github.com/BurntSushi/termcolor#automatic-color-selection) to determine how it should behave, when writing to a tty.
|
||||
Set `NO_COLOR` to any value to force plain-text output.
|
||||
|
||||
## Debugging
|
||||
|
||||
Both `ta` and `taskchampion-sync-server` use [env-logger](https://docs.rs/env_logger) and can be configured to log at various levels with the `RUST_LOG` environment variable.
|
||||
For example:
|
||||
```shell
|
||||
$ RUST_LOG=taskchampion=trace ta add foo
|
||||
```
|
||||
|
||||
The output may provide valuable clues in debugging problems.
|
|
@ -10,71 +10,70 @@ TaskChampion includes several "built-in" reports, as well as supporting custom r
|
|||
The `next` report is the default, and lists all pending tasks:
|
||||
|
||||
```text
|
||||
$ task
|
||||
$ ta
|
||||
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.
|
||||
These IDs are easy to type, such as to mark task 2 done (`task 2 done`).
|
||||
These IDs are easy to type, such as to mark task 2 done (`ta 2 done`).
|
||||
|
||||
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:
|
||||
|
||||
|
|
|
@ -125,4 +125,4 @@ Without synchronization, its list of pending operations would grow indefinitely,
|
|||
So all replicas, even "singleton" replicas which do not replicate task data with any other replica, must synchronize periodically.
|
||||
|
||||
TaskChampion provides a `LocalServer` for this purpose.
|
||||
It implements the `get_child_version` and `add_version` operations as described, storing data on-disk locally, all within the `task` binary.
|
||||
It implements the `get_child_version` and `add_version` operations as described, storing data on-disk locally, all within the `ta` binary.
|
||||
|
|
|
@ -73,7 +73,7 @@ This value is passed with every request in the `X-Client-Id` header, in its dash
|
|||
|
||||
### AddVersion
|
||||
|
||||
The request is a `POST` to `<origin>/client/add-version/<parentVersionId>`.
|
||||
The request is a `POST` to `<origin>/v1/client/add-version/<parentVersionId>`.
|
||||
The request body contains the history segment, optionally encoded using any encoding supported by actix-web.
|
||||
The content-type must be `application/vnd.taskchampion.history-segment`.
|
||||
|
||||
|
@ -87,7 +87,7 @@ Other error responses (4xx or 5xx) may be returned and should be treated appropr
|
|||
|
||||
### GetChildVersion
|
||||
|
||||
The request is a `GET` to `<origin>/client/get-child-version/<parentVersionId>`.
|
||||
The request is a `GET` to `<origin>/v1/client/get-child-version/<parentVersionId>`.
|
||||
The response is 404 NOT FOUND if no such version exists.
|
||||
Otherwise, the response is a 200 OK.
|
||||
The version's history segment is returned in the response body, with content-type `application/vnd.taskchampion.history-segment`.
|
||||
|
|
|
@ -4,7 +4,7 @@ Each task has a collection of associated tags.
|
|||
Tags are short words that categorize tasks, typically written with a leading `+`, such as `+next` or `+jobsearch`.
|
||||
|
||||
Tags are useful for filtering tasks in reports or on the command line.
|
||||
For example, when it's time to continue the job search, `task +jobsearch` will show pending tasks with the `jobsearch` tag.
|
||||
For example, when it's time to continue the job search, `ta +jobsearch` will show pending tasks with the `jobsearch` tag.
|
||||
|
||||
## Allowed Tags
|
||||
|
||||
|
|
|
@ -4,20 +4,46 @@ A single TaskChampion task database is known as a "replica".
|
|||
A replica "synchronizes" its local information with other replicas via a sync server.
|
||||
Many replicas can thus share the same task history.
|
||||
|
||||
This operation is triggered by running `task sync`.
|
||||
This operation is triggered by running `ta sync`.
|
||||
Typically this runs frequently in a cron task.
|
||||
Synchronization is quick, especially if no changes have occurred.
|
||||
|
||||
Each replica expects to be synchronized frequently, even if no server is involved.
|
||||
Without periodic syncs, the storage space used for the task database will grow quickly, and performance will suffer.
|
||||
|
||||
## Local Sync
|
||||
|
||||
By default, TaskChampion syncs to a "local server", as specified by the `server_dir` configuration parameter.
|
||||
This defaults to `taskchampion-sync-server` in your [data directory](https://docs.rs/dirs-next/2.0.0/dirs_next/fn.data_dir.html), but can be customized in the configuration file.
|
||||
|
||||
## Remote Sync
|
||||
|
||||
For remote synchronization, you will need a few pieces of information.
|
||||
From the server operator, you will need an origin and a client key.
|
||||
Configure these with
|
||||
|
||||
```shell
|
||||
ta config set server_origin "<origin from server operator>"
|
||||
ta config set server_client_key "<client key from server operator>"
|
||||
```
|
||||
|
||||
You will need to generate your own encryption secret.
|
||||
This is used to encrypt your task history, so treat it as a password.
|
||||
The following will use the `openssl` utility to generate a suitable value:
|
||||
|
||||
```shell
|
||||
ta config set encryption_secret $(openssl rand -hex 35)
|
||||
```
|
||||
|
||||
Every replica sharing a task history should have precisely the same configuration for `server_origin`, `server_client_key`, and `encryption_secret`.
|
||||
|
||||
Synchronizing a new replica to an existing task history is easy: begin with an empty replica, configured for the remote server, and run `task sync`.
|
||||
### Adding a New Replica
|
||||
|
||||
Synchronizing a new replica to an existing task history is easy: begin with an empty replica, configured for the remote server, and run `ta sync`.
|
||||
The replica will download the entire task history.
|
||||
|
||||
It is possible to switch a single replica to a remote server by simply configuring for the remote server and running `task sync`.
|
||||
### Upgrading a Locally-Sync'd Replica
|
||||
|
||||
It is possible to switch a single replica to a remote server by simply configuring for the remote server and running `ta sync`.
|
||||
The replica will upload the entire task history to the server.
|
||||
Once this is complete, additional replicas can be configured with the same settings in order to share the task history.
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
# Using the Task Command
|
||||
|
||||
The main interface to your tasks is the `task` command, which supports various subcommands such as `add`, `modify`, `start`, and `done`.
|
||||
The main interface to your tasks is the `ta` command, which supports various subcommands such as `add`, `modify`, `start`, and `done`.
|
||||
Customizable [reports](./reports.md) are also available as subcommands, such as `next`.
|
||||
The command reads a [configuration file](./config-file.md) for its settings, including where to find the task database.
|
||||
And the `sync` subcommand [synchronizes tasks with a sync server](./task-sync.md).
|
||||
You can find a list of all subcommands, as well as the built-in reports, with `task help`.
|
||||
You can find a list of all subcommands, as well as the built-in reports, with `ta help`.
|
||||
|
||||
> NOTE: the `task` interface does not precisely match that of TaskWarrior.
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# TaskChampion
|
||||
|
||||
TaskChampion is a personal task-tracking tool.
|
||||
It works from the command line, with simple commands like `task add "fix the kitchen sink"`.
|
||||
It works from the command line, with simple commands like `ta add "fix the kitchen sink"`.
|
||||
It can synchronize tasks on multiple devices, and does so in an "offline" mode so you can update your tasks even when you can't reach the server.
|
||||
If you've heard of [TaskWarrior](https://taskwarrior.org/), this tool is very similar, but with some different design choices and greater reliability.
|
||||
|
||||
|
@ -10,18 +10,18 @@ If you've heard of [TaskWarrior](https://taskwarrior.org/), this tool is very si
|
|||
> NOTE: TaskChampion is still in development and not yet feature-complete.
|
||||
> This section is limited to completed functionality.
|
||||
|
||||
Once you've [installed TaskChampion](./installation.md), your interface will be via the `task` command.
|
||||
Once you've [installed TaskChampion](./installation.md), your interface will be via the `ta` command.
|
||||
Start by adding a task:
|
||||
|
||||
```shell
|
||||
$ task add learn how to use taskchampion
|
||||
$ ta add learn how to use taskchampion
|
||||
added task ba57deaf-f97b-4e9c-b9ab-04bc1ecb22b8
|
||||
```
|
||||
|
||||
You can see all of your pending tasks with `task next`, or just `task` for short:
|
||||
You can see all of your pending tasks with `ta next`, or just `ta` for short:
|
||||
|
||||
```shell
|
||||
$ task
|
||||
$ ta
|
||||
Id Description Active Tags
|
||||
1 learn how to use taskchampion
|
||||
```
|
||||
|
@ -29,13 +29,13 @@ $ task
|
|||
Tell TaskChampion you're working on the task, using the shorthand id:
|
||||
|
||||
```shell
|
||||
$ task start 1
|
||||
$ ta start 1
|
||||
```
|
||||
|
||||
and when you're done with the task, mark it as complete:
|
||||
|
||||
```shell
|
||||
$ task done 1
|
||||
$ ta done 1
|
||||
```
|
||||
|
||||
## Synchronizing
|
||||
|
@ -44,7 +44,7 @@ Even if you don't have a server, it's a good idea to sync your task database per
|
|||
This acts as a backup and also enables some internal house-cleaning.
|
||||
|
||||
```shell
|
||||
$ task sync
|
||||
$ ta sync
|
||||
```
|
||||
|
||||
Typically sync is run from a crontab, on whatever schedule fits your needs.
|
||||
|
@ -57,7 +57,7 @@ server_client_key: "f8d4d09d-f6c7-4dd2-ab50-634ed20a3ff2"
|
|||
server_origin: "https://taskchampion.example.com"
|
||||
```
|
||||
|
||||
The next run of `task sync` will upload your task history to that server.
|
||||
Configuring another device identically and running `task sync` will download that task history, and continue to stay in sync with subsequent runs of the command.
|
||||
The next run of `ta sync` will upload your task history to that server.
|
||||
Configuring another device identically and running `ta sync` will download that task history, and continue to stay in sync with subsequent runs of the command.
|
||||
|
||||
See [Usage](./usage.md) for more detailed information on using TaskChampion.
|
||||
See [Usage](./using-task-command.md) for more detailed information on using TaskChampion.
|
||||
|
|
|
@ -19,7 +19,7 @@ const MAX_SIZE: usize = 100 * 1024 * 1024;
|
|||
/// parent version ID in the `X-Parent-Version-Id` header.
|
||||
///
|
||||
/// Returns other 4xx or 5xx responses on other errors.
|
||||
#[post("/client/add-version/{parent_version_id}")]
|
||||
#[post("/v1/client/add-version/{parent_version_id}")]
|
||||
pub(crate) async fn service(
|
||||
req: HttpRequest,
|
||||
server_state: web::Data<ServerState>,
|
||||
|
@ -99,7 +99,7 @@ mod test {
|
|||
let server_state = ServerState::new(server_box);
|
||||
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
|
||||
|
||||
let uri = format!("/client/add-version/{}", parent_version_id);
|
||||
let uri = format!("/v1/client/add-version/{}", parent_version_id);
|
||||
let req = test::TestRequest::post()
|
||||
.uri(&uri)
|
||||
.header(
|
||||
|
@ -136,7 +136,7 @@ mod test {
|
|||
let server_state = ServerState::new(server_box);
|
||||
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
|
||||
|
||||
let uri = format!("/client/add-version/{}", parent_version_id);
|
||||
let uri = format!("/v1/client/add-version/{}", parent_version_id);
|
||||
let req = test::TestRequest::post()
|
||||
.uri(&uri)
|
||||
.header(
|
||||
|
@ -163,7 +163,7 @@ mod test {
|
|||
let server_state = ServerState::new(server_box);
|
||||
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
|
||||
|
||||
let uri = format!("/client/add-version/{}", parent_version_id);
|
||||
let uri = format!("/v1/client/add-version/{}", parent_version_id);
|
||||
let req = test::TestRequest::post()
|
||||
.uri(&uri)
|
||||
.header("Content-Type", "not/correct")
|
||||
|
@ -182,7 +182,7 @@ mod test {
|
|||
let server_state = ServerState::new(server_box);
|
||||
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
|
||||
|
||||
let uri = format!("/client/add-version/{}", parent_version_id);
|
||||
let uri = format!("/v1/client/add-version/{}", parent_version_id);
|
||||
let req = test::TestRequest::post()
|
||||
.uri(&uri)
|
||||
.header(
|
||||
|
|
|
@ -13,7 +13,7 @@ use actix_web::{error, get, web, HttpRequest, HttpResponse, Result};
|
|||
///
|
||||
/// If no such child exists, returns a 404 with no content.
|
||||
/// Returns other 4xx or 5xx responses on other errors.
|
||||
#[get("/client/get-child-version/{parent_version_id}")]
|
||||
#[get("/v1/client/get-child-version/{parent_version_id}")]
|
||||
pub(crate) async fn service(
|
||||
req: HttpRequest,
|
||||
server_state: web::Data<ServerState>,
|
||||
|
@ -68,7 +68,7 @@ mod test {
|
|||
let server_state = ServerState::new(server_box);
|
||||
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
|
||||
|
||||
let uri = format!("/client/get-child-version/{}", parent_version_id);
|
||||
let uri = format!("/v1/client/get-child-version/{}", parent_version_id);
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&uri)
|
||||
.header("X-Client-Key", client_key.to_string())
|
||||
|
@ -101,7 +101,7 @@ mod test {
|
|||
let server_state = ServerState::new(server_box);
|
||||
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
|
||||
|
||||
let uri = format!("/client/get-child-version/{}", parent_version_id);
|
||||
let uri = format!("/v1/client/get-child-version/{}", parent_version_id);
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&uri)
|
||||
.header("X-Client-Key", client_key.to_string())
|
||||
|
@ -126,7 +126,7 @@ mod test {
|
|||
let server_state = ServerState::new(server_box);
|
||||
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
|
||||
|
||||
let uri = format!("/client/get-child-version/{}", parent_version_id);
|
||||
let uri = format!("/v1/client/get-child-version/{}", parent_version_id);
|
||||
let req = test::TestRequest::get()
|
||||
.uri(&uri)
|
||||
.header("X-Client-Key", client_key.to_string())
|
||||
|
|
|
@ -4,6 +4,7 @@ use crate::storage::{Operation, Storage, TaskMap};
|
|||
use crate::task::{Status, Task};
|
||||
use crate::taskdb::TaskDb;
|
||||
use crate::workingset::WorkingSet;
|
||||
use anyhow::Context;
|
||||
use chrono::Utc;
|
||||
use log::trace;
|
||||
use std::collections::HashMap;
|
||||
|
@ -123,8 +124,10 @@ impl Replica {
|
|||
/// this occurs, but without renumbering, so any newly-pending tasks should appear in
|
||||
/// the working set.
|
||||
pub fn sync(&mut self, server: &mut Box<dyn Server>) -> anyhow::Result<()> {
|
||||
self.taskdb.sync(server)?;
|
||||
self.taskdb.sync(server).context("Failed to synchronize")?;
|
||||
self.rebuild_working_set(false)
|
||||
.context("Failed to rebuild working set after sync")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild this replica's working set, based on whether tasks are pending or not. If
|
||||
|
|
|
@ -49,7 +49,10 @@ impl Server for RemoteServer {
|
|||
parent_version_id: VersionId,
|
||||
history_segment: HistorySegment,
|
||||
) -> anyhow::Result<AddVersionResult> {
|
||||
let url = format!("{}/client/add-version/{}", self.origin, parent_version_id);
|
||||
let url = format!(
|
||||
"{}/v1/client/add-version/{}",
|
||||
self.origin, parent_version_id
|
||||
);
|
||||
let history_cleartext = HistoryCleartext {
|
||||
parent_version_id,
|
||||
history_segment,
|
||||
|
@ -82,7 +85,7 @@ impl Server for RemoteServer {
|
|||
parent_version_id: VersionId,
|
||||
) -> anyhow::Result<GetVersionResult> {
|
||||
let url = format!(
|
||||
"{}/client/get-child-version/{}",
|
||||
"{}/v1/client/get-child-version/{}",
|
||||
self.origin, parent_version_id
|
||||
);
|
||||
match self
|
||||
|
|
|
@ -117,14 +117,14 @@ impl TaskDb {
|
|||
{
|
||||
let mut txn = self.storage.txn()?;
|
||||
|
||||
let mut new_ws = vec![];
|
||||
let mut new_ws = vec![None]; // index 0 is always None
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
// The goal here is for existing working-set items to be "compressed' down to index 1, so
|
||||
// we begin by scanning the current working set and inserting any tasks that should still
|
||||
// be in the set into new_ws, implicitly dropping any tasks that are no longer in the
|
||||
// working set.
|
||||
for elt in txn.get_working_set()? {
|
||||
for elt in txn.get_working_set()?.drain(1..) {
|
||||
if let Some(uuid) = elt {
|
||||
if let Some(task) = txn.get_task(uuid)? {
|
||||
if in_working_set(&task) {
|
||||
|
@ -144,14 +144,12 @@ impl TaskDb {
|
|||
// if renumbering, clear the working set and re-add
|
||||
if renumber {
|
||||
txn.clear_working_set()?;
|
||||
for elt in new_ws.drain(0..new_ws.len()) {
|
||||
if let Some(uuid) = elt {
|
||||
txn.add_to_working_set(uuid)?;
|
||||
}
|
||||
for elt in new_ws.drain(1..new_ws.len()).flatten() {
|
||||
txn.add_to_working_set(elt)?;
|
||||
}
|
||||
} else {
|
||||
// ..otherwise, just clear the None items determined above from the working set
|
||||
for (i, elt) in new_ws.iter().enumerate() {
|
||||
for (i, elt) in new_ws.iter().enumerate().skip(1) {
|
||||
if elt.is_none() {
|
||||
txn.set_working_set_item(i, None)?;
|
||||
}
|
||||
|
|
|
@ -21,6 +21,10 @@ impl WorkingSet {
|
|||
/// Create a new WorkingSet. Typically this is acquired via `replica.working_set()`
|
||||
pub(crate) fn new(by_index: Vec<Option<Uuid>>) -> Self {
|
||||
let mut by_uuid = HashMap::new();
|
||||
|
||||
// working sets are 1-indexed, so element 0 should always be None
|
||||
assert!(by_index.is_empty() || by_index[0].is_none());
|
||||
|
||||
for (index, uuid) in by_index.iter().enumerate() {
|
||||
if let Some(uuid) = uuid {
|
||||
by_uuid.insert(*uuid, index);
|
||||
|
@ -58,13 +62,7 @@ impl WorkingSet {
|
|||
self.by_index
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, uuid)| {
|
||||
if let Some(uuid) = uuid {
|
||||
Some((index, *uuid))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.filter_map(|(index, uuid)| uuid.as_ref().map(|uuid| (index, *uuid)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|