Merge branch 'main' into sqlstore
1
.github/workflows/publish-docs.yml
vendored
|
@ -8,7 +8,6 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
mdbook-deploy:
|
mdbook-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: mdbook
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- 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"] }
|
textwrap = { version="^0.13.4", features=["terminal_size"] }
|
||||||
termcolor = "^1.1.2"
|
termcolor = "^1.1.2"
|
||||||
atty = "^0.2.14"
|
atty = "^0.2.14"
|
||||||
|
toml = "^0.5.8"
|
||||||
[dependencies.config]
|
toml_edit = "^0.2.0"
|
||||||
default-features = false
|
|
||||||
features = ["yaml"]
|
|
||||||
version = "^0.11.0"
|
|
||||||
|
|
||||||
[dependencies.taskchampion]
|
[dependencies.taskchampion]
|
||||||
path = "../taskchampion"
|
path = "../taskchampion"
|
||||||
|
|
|
@ -49,10 +49,10 @@ mod test {
|
||||||
#[test]
|
#[test]
|
||||||
fn test_version() {
|
fn test_version() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Command::from_argv(argv!["task", "version"]).unwrap(),
|
Command::from_argv(argv!["ta", "version"]).unwrap(),
|
||||||
Command {
|
Command {
|
||||||
subcommand: Subcommand::Version,
|
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 args;
|
||||||
mod command;
|
mod command;
|
||||||
|
mod config;
|
||||||
mod filter;
|
mod filter;
|
||||||
mod modification;
|
mod modification;
|
||||||
mod subcommand;
|
mod subcommand;
|
||||||
|
|
||||||
pub(crate) use args::TaskId;
|
pub(crate) use args::TaskId;
|
||||||
pub(crate) use command::Command;
|
pub(crate) use command::Command;
|
||||||
|
pub(crate) use config::ConfigOperation;
|
||||||
pub(crate) use filter::{Condition, Filter};
|
pub(crate) use filter::{Condition, Filter};
|
||||||
pub(crate) use modification::{DescriptionMod, Modification};
|
pub(crate) use modification::{DescriptionMod, Modification};
|
||||||
pub(crate) use subcommand::Subcommand;
|
pub(crate) use subcommand::Subcommand;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
use super::args::*;
|
use super::args::*;
|
||||||
use super::{ArgList, DescriptionMod, Filter, Modification};
|
use super::{ArgList, ConfigOperation, DescriptionMod, Filter, Modification};
|
||||||
use crate::usage;
|
use crate::usage;
|
||||||
use nom::{branch::alt, combinator::*, sequence::*, IResult};
|
use nom::{branch::alt, combinator::*, sequence::*, IResult};
|
||||||
use taskchampion::Status;
|
use taskchampion::Status;
|
||||||
|
@ -25,6 +25,11 @@ pub(crate) enum Subcommand {
|
||||||
summary: bool,
|
summary: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Manipulate configuration
|
||||||
|
Config {
|
||||||
|
config_operation: ConfigOperation,
|
||||||
|
},
|
||||||
|
|
||||||
/// Add a new task
|
/// Add a new task
|
||||||
Add {
|
Add {
|
||||||
modification: Modification,
|
modification: Modification,
|
||||||
|
@ -61,6 +66,7 @@ impl Subcommand {
|
||||||
all_consuming(alt((
|
all_consuming(alt((
|
||||||
Version::parse,
|
Version::parse,
|
||||||
Help::parse,
|
Help::parse,
|
||||||
|
Config::parse,
|
||||||
Add::parse,
|
Add::parse,
|
||||||
Modify::parse,
|
Modify::parse,
|
||||||
Info::parse,
|
Info::parse,
|
||||||
|
@ -74,6 +80,7 @@ impl Subcommand {
|
||||||
pub(super) fn get_usage(u: &mut usage::Usage) {
|
pub(super) fn get_usage(u: &mut usage::Usage) {
|
||||||
Version::get_usage(u);
|
Version::get_usage(u);
|
||||||
Help::get_usage(u);
|
Help::get_usage(u);
|
||||||
|
Config::get_usage(u);
|
||||||
Add::get_usage(u);
|
Add::get_usage(u);
|
||||||
Modify::get_usage(u);
|
Modify::get_usage(u);
|
||||||
Info::get_usage(u);
|
Info::get_usage(u);
|
||||||
|
@ -131,6 +138,26 @@ impl Help {
|
||||||
fn get_usage(_u: &mut usage::Usage) {}
|
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;
|
struct Add;
|
||||||
|
|
||||||
impl 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]
|
#[test]
|
||||||
fn test_add_description() {
|
fn test_add_description() {
|
||||||
let subcommand = Subcommand::Add {
|
let subcommand = Subcommand::Add {
|
||||||
|
|
|
@ -2,7 +2,7 @@ use std::process::exit;
|
||||||
|
|
||||||
pub fn main() {
|
pub fn main() {
|
||||||
if let Err(err) = taskchampion_cli::main() {
|
if let Err(err) = taskchampion_cli::main() {
|
||||||
eprintln!("{}", err);
|
eprintln!("{:?}", err);
|
||||||
exit(1);
|
exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
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]
|
#[test]
|
||||||
fn test_summary() {
|
fn test_summary() {
|
||||||
let mut w = test_writer();
|
let mut w = test_writer();
|
||||||
execute(&mut w, s!("task"), true).unwrap();
|
execute(&mut w, s!("ta"), true).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_long() {
|
fn test_long() {
|
||||||
let mut w = test_writer();
|
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`].
|
//! Responsible for executing commands as parsed by [`crate::argparse`].
|
||||||
|
|
||||||
pub(crate) mod add;
|
pub(crate) mod add;
|
||||||
|
pub(crate) mod config;
|
||||||
pub(crate) mod gc;
|
pub(crate) mod gc;
|
||||||
pub(crate) mod help;
|
pub(crate) mod help;
|
||||||
pub(crate) mod info;
|
pub(crate) mod info;
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
use crate::argparse::Filter;
|
use crate::argparse::Filter;
|
||||||
use crate::invocation::display_report;
|
use crate::invocation::display_report;
|
||||||
use config::Config;
|
use crate::settings::Settings;
|
||||||
use taskchampion::Replica;
|
use taskchampion::Replica;
|
||||||
use termcolor::WriteColor;
|
use termcolor::WriteColor;
|
||||||
|
|
||||||
pub(crate) fn execute<W: WriteColor>(
|
pub(crate) fn execute<W: WriteColor>(
|
||||||
w: &mut W,
|
w: &mut W,
|
||||||
replica: &mut Replica,
|
replica: &mut Replica,
|
||||||
settings: &Config,
|
settings: &Settings,
|
||||||
report_name: String,
|
report_name: String,
|
||||||
filter: Filter,
|
filter: Filter,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
|
@ -30,7 +30,7 @@ mod test {
|
||||||
// The function being tested is only one line long, so this is sort of an integration test
|
// The function being tested is only one line long, so this is sort of an integration test
|
||||||
// for display_report.
|
// for display_report.
|
||||||
|
|
||||||
let settings = crate::settings::default_settings().unwrap();
|
let settings = Default::default();
|
||||||
let report_name = "next".to_owned();
|
let report_name = "next".to_owned();
|
||||||
let filter = Filter {
|
let filter = Filter {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|
|
@ -6,7 +6,7 @@ pub(crate) fn execute<W: WriteColor>(
|
||||||
replica: &mut Replica,
|
replica: &mut Replica,
|
||||||
server: &mut Box<dyn Server>,
|
server: &mut Box<dyn Server>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
replica.sync(server).unwrap();
|
replica.sync(server)?;
|
||||||
writeln!(w, "sync complete.")?;
|
writeln!(w, "sync complete.")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
//! The invocation module handles invoking the commands parsed by the argparse module.
|
//! The invocation module handles invoking the commands parsed by the argparse module.
|
||||||
|
|
||||||
use crate::argparse::{Command, Subcommand};
|
use crate::argparse::{Command, Subcommand};
|
||||||
use config::Config;
|
use crate::settings::Settings;
|
||||||
use taskchampion::{Replica, Server, ServerConfig, StorageConfig, Uuid};
|
use taskchampion::{Replica, Server, ServerConfig, StorageConfig, Uuid};
|
||||||
use termcolor::{ColorChoice, StandardStream};
|
use termcolor::{ColorChoice, StandardStream};
|
||||||
|
|
||||||
|
@ -19,7 +19,7 @@ use report::display_report;
|
||||||
|
|
||||||
/// Invoke the given Command in the context of the given settings
|
/// Invoke the given Command in the context of the given settings
|
||||||
#[allow(clippy::needless_return)]
|
#[allow(clippy::needless_return)]
|
||||||
pub(crate) fn invoke(command: Command, settings: Config) -> anyhow::Result<()> {
|
pub(crate) fn invoke(command: Command, settings: Settings) -> anyhow::Result<()> {
|
||||||
log::debug!("command: {:?}", command);
|
log::debug!("command: {:?}", command);
|
||||||
log::debug!("settings: {:?}", settings);
|
log::debug!("settings: {:?}", settings);
|
||||||
|
|
||||||
|
@ -35,6 +35,10 @@ pub(crate) fn invoke(command: Command, settings: Config) -> anyhow::Result<()> {
|
||||||
subcommand: Subcommand::Help { summary },
|
subcommand: Subcommand::Help { summary },
|
||||||
command_name,
|
command_name,
|
||||||
} => return cmd::help::execute(&mut w, command_name, summary),
|
} => 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 {
|
Command {
|
||||||
subcommand: Subcommand::Version,
|
subcommand: Subcommand::Version,
|
||||||
..
|
..
|
||||||
|
@ -90,6 +94,10 @@ pub(crate) fn invoke(command: Command, settings: Config) -> anyhow::Result<()> {
|
||||||
subcommand: Subcommand::Help { .. },
|
subcommand: Subcommand::Help { .. },
|
||||||
..
|
..
|
||||||
} => unreachable!(),
|
} => unreachable!(),
|
||||||
|
Command {
|
||||||
|
subcommand: Subcommand::Config { .. },
|
||||||
|
..
|
||||||
|
} => unreachable!(),
|
||||||
Command {
|
Command {
|
||||||
subcommand: Subcommand::Version,
|
subcommand: Subcommand::Version,
|
||||||
..
|
..
|
||||||
|
@ -100,35 +108,33 @@ pub(crate) fn invoke(command: Command, settings: Config) -> anyhow::Result<()> {
|
||||||
// utilities for invoke
|
// utilities for invoke
|
||||||
|
|
||||||
/// Get the replica for this invocation
|
/// Get the replica for this invocation
|
||||||
fn get_replica(settings: &Config) -> anyhow::Result<Replica> {
|
fn get_replica(settings: &Settings) -> anyhow::Result<Replica> {
|
||||||
let taskdb_dir = settings.get_str("data_dir")?.into();
|
let taskdb_dir = settings.data_dir.clone();
|
||||||
log::debug!("Replica data_dir: {:?}", taskdb_dir);
|
log::debug!("Replica data_dir: {:?}", taskdb_dir);
|
||||||
let storage_config = StorageConfig::OnDisk { taskdb_dir };
|
let storage_config = StorageConfig::OnDisk { taskdb_dir };
|
||||||
Ok(Replica::new(storage_config.into_storage()?))
|
Ok(Replica::new(storage_config.into_storage()?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the server for this invocation
|
/// Get the server for this invocation
|
||||||
fn get_server(settings: &Config) -> anyhow::Result<Box<dyn Server>> {
|
fn get_server(settings: &Settings) -> anyhow::Result<Box<dyn Server>> {
|
||||||
// if server_client_key and server_origin are both set, use
|
// if server_client_key and server_origin are both set, use
|
||||||
// the remote server
|
// the remote server
|
||||||
let config = if let (Ok(client_key), Ok(origin)) = (
|
let config = if let (Some(client_key), Some(origin), Some(encryption_secret)) = (
|
||||||
settings.get_str("server_client_key"),
|
settings.server_client_key.as_ref(),
|
||||||
settings.get_str("server_origin"),
|
settings.server_origin.as_ref(),
|
||||||
|
settings.encryption_secret.as_ref(),
|
||||||
) {
|
) {
|
||||||
let client_key = Uuid::parse_str(&client_key)?;
|
let client_key = Uuid::parse_str(&client_key)?;
|
||||||
let encryption_secret = settings
|
|
||||||
.get_str("encryption_secret")
|
|
||||||
.map_err(|_| anyhow::anyhow!("Could not read `encryption_secret` configuration"))?;
|
|
||||||
|
|
||||||
log::debug!("Using sync-server with origin {}", origin);
|
log::debug!("Using sync-server with origin {}", origin);
|
||||||
log::debug!("Sync client ID: {}", client_key);
|
log::debug!("Sync client ID: {}", client_key);
|
||||||
ServerConfig::Remote {
|
ServerConfig::Remote {
|
||||||
origin,
|
origin: origin.clone(),
|
||||||
client_key,
|
client_key,
|
||||||
encryption_secret: encryption_secret.as_bytes().to_vec(),
|
encryption_secret: encryption_secret.as_bytes().to_vec(),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let server_dir = settings.get_str("server_dir")?.into();
|
let server_dir = settings.server_dir.clone();
|
||||||
log::debug!("Using local sync-server at `{:?}`", server_dir);
|
log::debug!("Using local sync-server at `{:?}`", server_dir);
|
||||||
ServerConfig::Local { server_dir }
|
ServerConfig::Local { server_dir }
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
use crate::argparse::Filter;
|
use crate::argparse::Filter;
|
||||||
use crate::invocation::filtered_tasks;
|
use crate::invocation::filtered_tasks;
|
||||||
use crate::report::{Column, Property, Report, SortBy};
|
use crate::settings::{Column, Property, Report, Settings, SortBy};
|
||||||
use crate::table;
|
use crate::table;
|
||||||
use config::Config;
|
use anyhow::anyhow;
|
||||||
use prettytable::{Row, Table};
|
use prettytable::{Row, Table};
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use taskchampion::{Replica, Task, WorkingSet};
|
use taskchampion::{Replica, Task, WorkingSet};
|
||||||
|
@ -18,8 +18,6 @@ fn sort_tasks(tasks: &mut Vec<Task>, report: &Report, working_set: &WorkingSet)
|
||||||
let b_uuid = b.get_uuid();
|
let b_uuid = b.get_uuid();
|
||||||
let a_id = working_set.by_uuid(a_uuid);
|
let a_id = working_set.by_uuid(a_uuid);
|
||||||
let b_id = working_set.by_uuid(b_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) {
|
match (a_id, b_id) {
|
||||||
(Some(a_id), Some(b_id)) => a_id.cmp(&b_id),
|
(Some(a_id), Some(b_id)) => a_id.cmp(&b_id),
|
||||||
(Some(_), None) => Ordering::Less,
|
(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>(
|
pub(super) fn display_report<W: WriteColor>(
|
||||||
w: &mut W,
|
w: &mut W,
|
||||||
replica: &mut Replica,
|
replica: &mut Replica,
|
||||||
settings: &Config,
|
settings: &Settings,
|
||||||
report_name: String,
|
report_name: String,
|
||||||
filter: Filter,
|
filter: Filter,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
|
@ -87,8 +85,11 @@ pub(super) fn display_report<W: WriteColor>(
|
||||||
let working_set = replica.working_set()?;
|
let working_set = replica.working_set()?;
|
||||||
|
|
||||||
// Get the report from settings
|
// Get the report from settings
|
||||||
let mut report = Report::from_config(settings.get(&format!("reports.{}", report_name))?)
|
let mut report = settings
|
||||||
.map_err(|e| anyhow::anyhow!("report.{}{}", report_name, e))?;
|
.reports
|
||||||
|
.get(&report_name)
|
||||||
|
.ok_or_else(|| anyhow!("report `{}` not defined", report_name))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
// include any user-supplied filter conditions
|
// include any user-supplied filter conditions
|
||||||
report.filter = report.filter.intersect(filter);
|
report.filter = report.filter.intersect(filter);
|
||||||
|
@ -122,7 +123,7 @@ pub(super) fn display_report<W: WriteColor>(
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::invocation::test::*;
|
use crate::invocation::test::*;
|
||||||
use crate::report::Sort;
|
use crate::settings::Sort;
|
||||||
use std::convert::TryInto;
|
use std::convert::TryInto;
|
||||||
use taskchampion::{Status, Uuid};
|
use taskchampion::{Status, Uuid};
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#![deny(clippy::all)]
|
#![deny(clippy::all)]
|
||||||
#![allow(clippy::unnecessary_wraps)] // for Rust 1.50, https://github.com/rust-lang/rust-clippy/pull/6765
|
#![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.
|
This crate implements the command-line interface to TaskChampion.
|
||||||
|
|
||||||
|
@ -38,11 +39,12 @@ mod macros;
|
||||||
|
|
||||||
mod argparse;
|
mod argparse;
|
||||||
mod invocation;
|
mod invocation;
|
||||||
mod report;
|
|
||||||
mod settings;
|
mod settings;
|
||||||
mod table;
|
mod table;
|
||||||
mod usage;
|
mod usage;
|
||||||
|
|
||||||
|
use settings::Settings;
|
||||||
|
|
||||||
/// The main entry point for the command-line interface. This builds an Invocation
|
/// The main entry point for the command-line interface. This builds an Invocation
|
||||||
/// from the particulars of the operating-system interface, and then executes it.
|
/// from the particulars of the operating-system interface, and then executes it.
|
||||||
pub fn main() -> anyhow::Result<()> {
|
pub fn main() -> anyhow::Result<()> {
|
||||||
|
@ -59,7 +61,7 @@ pub fn main() -> anyhow::Result<()> {
|
||||||
let command = argparse::Command::from_argv(&argv[..])?;
|
let command = argparse::Command::from_argv(&argv[..])?;
|
||||||
|
|
||||||
// load the application settings
|
// load the application settings
|
||||||
let settings = settings::read_settings()?;
|
let settings = Settings::read()?;
|
||||||
|
|
||||||
invocation::invoke(command, settings)?;
|
invocation::invoke(command, settings)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
@ -1,582 +0,0 @@
|
||||||
//! This module contains the data structures used to define reports.
|
|
||||||
|
|
||||||
use crate::argparse::{Condition, Filter};
|
|
||||||
use anyhow::bail;
|
|
||||||
|
|
||||||
/// A report specifies a filter as well as a sort order and information about which
|
|
||||||
/// task attributes to display
|
|
||||||
#[derive(Clone, Debug, PartialEq, Default)]
|
|
||||||
pub(crate) struct Report {
|
|
||||||
/// Columns to display in this report
|
|
||||||
pub columns: Vec<Column>,
|
|
||||||
/// Sort order for this report
|
|
||||||
pub sort: Vec<Sort>,
|
|
||||||
/// Filter selecting tasks for this report
|
|
||||||
pub filter: Filter,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A column to display in a report
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub(crate) struct Column {
|
|
||||||
/// The label for this column
|
|
||||||
pub label: String,
|
|
||||||
|
|
||||||
/// The property to display
|
|
||||||
pub property: Property,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Task property to display in a report
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(crate) enum Property {
|
|
||||||
/// The task's ID, either working-set index or Uuid if not in the working set
|
|
||||||
Id,
|
|
||||||
|
|
||||||
/// The task's full UUID
|
|
||||||
Uuid,
|
|
||||||
|
|
||||||
/// Whether the task is active or not
|
|
||||||
Active,
|
|
||||||
|
|
||||||
/// The task's description
|
|
||||||
Description,
|
|
||||||
|
|
||||||
/// The task's tags
|
|
||||||
Tags,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A sorting criterion for a sort operation.
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub(crate) struct Sort {
|
|
||||||
/// True if the sort should be "ascending" (a -> z, 0 -> 9, etc.)
|
|
||||||
pub ascending: bool,
|
|
||||||
|
|
||||||
/// The property to sort on
|
|
||||||
pub sort_by: SortBy,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Task property to sort by
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub(crate) enum SortBy {
|
|
||||||
/// The task's ID, either working-set index or a UUID prefix; working
|
|
||||||
/// set tasks sort before others.
|
|
||||||
Id,
|
|
||||||
|
|
||||||
/// The task's full UUID
|
|
||||||
Uuid,
|
|
||||||
|
|
||||||
/// The task's description
|
|
||||||
Description,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conversions from config::Value. Note that these cannot ergonomically use TryFrom/TryInto; see
|
|
||||||
// https://github.com/mehcode/config-rs/issues/162
|
|
||||||
|
|
||||||
impl Report {
|
|
||||||
/// Create a Report from a config value. This should be the `report.<report_name>` value.
|
|
||||||
/// The error message begins with any additional path information, e.g., `.sort[1].sort_by:
|
|
||||||
/// ..`.
|
|
||||||
pub(crate) fn from_config(cfg: config::Value) -> anyhow::Result<Report> {
|
|
||||||
let mut map = cfg.into_table().map_err(|e| anyhow::anyhow!(": {}", e))?;
|
|
||||||
let sort = if let Some(sort_array) = map.remove("sort") {
|
|
||||||
sort_array
|
|
||||||
.into_array()
|
|
||||||
.map_err(|e| anyhow::anyhow!(".sort: {}", e))?
|
|
||||||
.drain(..)
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, v)| {
|
|
||||||
Sort::from_config(v).map_err(|e| anyhow::anyhow!(".sort[{}]{}", i, e))
|
|
||||||
})
|
|
||||||
.collect::<anyhow::Result<Vec<_>>>()?
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
};
|
|
||||||
|
|
||||||
let columns = map
|
|
||||||
.remove("columns")
|
|
||||||
.ok_or_else(|| anyhow::anyhow!(": 'columns' property is required"))?
|
|
||||||
.into_array()
|
|
||||||
.map_err(|e| anyhow::anyhow!(".columns: {}", e))?
|
|
||||||
.drain(..)
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, v)| {
|
|
||||||
Column::from_config(v).map_err(|e| anyhow::anyhow!(".columns[{}]{}", i, e))
|
|
||||||
})
|
|
||||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
|
||||||
|
|
||||||
let conditions = if let Some(conditions) = map.remove("filter") {
|
|
||||||
conditions
|
|
||||||
.into_array()
|
|
||||||
.map_err(|e| anyhow::anyhow!(".filter: {}", e))?
|
|
||||||
.drain(..)
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, v)| {
|
|
||||||
v.into_str()
|
|
||||||
.map_err(|e| e.into())
|
|
||||||
.and_then(|s| Condition::parse_str(&s))
|
|
||||||
.map_err(|e| anyhow::anyhow!(".filter[{}]: {}", i, e))
|
|
||||||
})
|
|
||||||
.collect::<anyhow::Result<Vec<_>>>()?
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
};
|
|
||||||
|
|
||||||
let filter = Filter { conditions };
|
|
||||||
|
|
||||||
if !map.is_empty() {
|
|
||||||
bail!(": unknown properties");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Report {
|
|
||||||
columns,
|
|
||||||
sort,
|
|
||||||
filter,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Column {
|
|
||||||
pub(crate) fn from_config(cfg: config::Value) -> anyhow::Result<Column> {
|
|
||||||
let mut map = cfg.into_table().map_err(|e| anyhow::anyhow!(": {}", e))?;
|
|
||||||
let label = map
|
|
||||||
.remove("label")
|
|
||||||
.ok_or_else(|| anyhow::anyhow!(": 'label' property is required"))?
|
|
||||||
.into_str()
|
|
||||||
.map_err(|e| anyhow::anyhow!(".label: {}", e))?;
|
|
||||||
let property: config::Value = map
|
|
||||||
.remove("property")
|
|
||||||
.ok_or_else(|| anyhow::anyhow!(": 'property' property is required"))?;
|
|
||||||
let property =
|
|
||||||
Property::from_config(property).map_err(|e| anyhow::anyhow!(".property{}", e))?;
|
|
||||||
|
|
||||||
if !map.is_empty() {
|
|
||||||
bail!(": unknown properties");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Column { label, property })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Property {
|
|
||||||
pub(crate) fn from_config(cfg: config::Value) -> anyhow::Result<Property> {
|
|
||||||
let s = cfg.into_str().map_err(|e| anyhow::anyhow!(": {}", e))?;
|
|
||||||
Ok(match s.as_ref() {
|
|
||||||
"id" => Property::Id,
|
|
||||||
"uuid" => Property::Uuid,
|
|
||||||
"active" => Property::Active,
|
|
||||||
"description" => Property::Description,
|
|
||||||
"tags" => Property::Tags,
|
|
||||||
_ => bail!(": unknown property {}", s),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Sort {
|
|
||||||
pub(crate) fn from_config(cfg: config::Value) -> anyhow::Result<Sort> {
|
|
||||||
let mut map = cfg.into_table().map_err(|e| anyhow::anyhow!(": {}", e))?;
|
|
||||||
let ascending = match map.remove("ascending") {
|
|
||||||
Some(v) => v
|
|
||||||
.into_bool()
|
|
||||||
.map_err(|e| anyhow::anyhow!(".ascending: {}", e))?,
|
|
||||||
None => true, // default
|
|
||||||
};
|
|
||||||
let sort_by: config::Value = map
|
|
||||||
.remove("sort_by")
|
|
||||||
.ok_or_else(|| anyhow::anyhow!(": 'sort_by' property is required"))?;
|
|
||||||
let sort_by = SortBy::from_config(sort_by).map_err(|e| anyhow::anyhow!(".sort_by{}", e))?;
|
|
||||||
|
|
||||||
if !map.is_empty() {
|
|
||||||
bail!(": unknown properties");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Sort { ascending, sort_by })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SortBy {
|
|
||||||
pub(crate) fn from_config(cfg: config::Value) -> anyhow::Result<SortBy> {
|
|
||||||
let s = cfg.into_str().map_err(|e| anyhow::anyhow!(": {}", e))?;
|
|
||||||
Ok(match s.as_ref() {
|
|
||||||
"id" => SortBy::Id,
|
|
||||||
"uuid" => SortBy::Uuid,
|
|
||||||
"description" => SortBy::Description,
|
|
||||||
_ => bail!(": unknown sort_by {}", s),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use super::*;
|
|
||||||
use config::{Config, File, FileFormat, FileSourceString};
|
|
||||||
use taskchampion::Status;
|
|
||||||
use textwrap::{dedent, indent};
|
|
||||||
|
|
||||||
fn config_from(cfg: &str) -> config::Value {
|
|
||||||
// wrap this in a "table" so that we can get any type of value at the top level.
|
|
||||||
let yaml = format!("val:\n{}", indent(&dedent(&cfg), " "));
|
|
||||||
let mut settings = Config::new();
|
|
||||||
let cfg_file: File<FileSourceString> = File::from_str(&yaml, FileFormat::Yaml);
|
|
||||||
settings.merge(cfg_file).unwrap();
|
|
||||||
settings.cache.into_table().unwrap().remove("val").unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_report_ok() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
filter: []
|
|
||||||
sort: []
|
|
||||||
columns: []
|
|
||||||
filter:
|
|
||||||
- status:pending",
|
|
||||||
);
|
|
||||||
let report = Report::from_config(val).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
report.filter,
|
|
||||||
Filter {
|
|
||||||
conditions: vec![Condition::Status(Status::Pending),],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(report.columns, vec![]);
|
|
||||||
assert_eq!(report.sort, vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_report_no_sort() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
filter: []
|
|
||||||
columns: []",
|
|
||||||
);
|
|
||||||
let report = Report::from_config(val).unwrap();
|
|
||||||
assert_eq!(report.sort, vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_report_sort_not_array() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
filter: []
|
|
||||||
sort: true
|
|
||||||
columns: []",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
&Report::from_config(val).unwrap_err().to_string(),
|
|
||||||
".sort: invalid type: boolean `true`, expected an array"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_report_sort_error() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
filter: []
|
|
||||||
sort:
|
|
||||||
- sort_by: id
|
|
||||||
- true
|
|
||||||
columns: []",
|
|
||||||
);
|
|
||||||
assert!(&Report::from_config(val)
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string()
|
|
||||||
.starts_with(".sort[1]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_report_unknown_prop() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
columns: []
|
|
||||||
filter: []
|
|
||||||
sort: []
|
|
||||||
nosuch: true
|
|
||||||
",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
&Report::from_config(val).unwrap_err().to_string(),
|
|
||||||
": unknown properties"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_report_no_columns() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
filter: []
|
|
||||||
sort: []",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
&Report::from_config(val).unwrap_err().to_string(),
|
|
||||||
": \'columns\' property is required"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_report_columns_not_array() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
filter: []
|
|
||||||
sort: []
|
|
||||||
columns: true",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
&Report::from_config(val).unwrap_err().to_string(),
|
|
||||||
".columns: invalid type: boolean `true`, expected an array"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_report_column_error() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
filter: []
|
|
||||||
sort: []
|
|
||||||
columns:
|
|
||||||
- label: ID
|
|
||||||
property: id
|
|
||||||
- true",
|
|
||||||
);
|
|
||||||
assert!(&Report::from_config(val)
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string()
|
|
||||||
.starts_with(".columns[1]:"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_report_filter_not_array() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
filter: []
|
|
||||||
sort: []
|
|
||||||
columns: []
|
|
||||||
filter: true",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
&Report::from_config(val).unwrap_err().to_string(),
|
|
||||||
".filter: invalid type: boolean `true`, expected an array"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_report_filter_error() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
filter: []
|
|
||||||
sort: []
|
|
||||||
columns: []
|
|
||||||
filter:
|
|
||||||
- nosuchfilter",
|
|
||||||
);
|
|
||||||
assert!(&Report::from_config(val)
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string()
|
|
||||||
.starts_with(".filter[0]: invalid filter condition:"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_column() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
label: ID
|
|
||||||
property: id",
|
|
||||||
);
|
|
||||||
let column = Column::from_config(val).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
column,
|
|
||||||
Column {
|
|
||||||
label: "ID".to_owned(),
|
|
||||||
property: Property::Id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_column_unknown_prop() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
label: ID
|
|
||||||
property: id
|
|
||||||
nosuch: foo",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
&Column::from_config(val).unwrap_err().to_string(),
|
|
||||||
": unknown properties"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_column_no_label() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
property: id",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
&Column::from_config(val).unwrap_err().to_string(),
|
|
||||||
": 'label' property is required"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_column_invalid_label() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
label: []
|
|
||||||
property: id",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
&Column::from_config(val).unwrap_err().to_string(),
|
|
||||||
".label: invalid type: sequence, expected a string"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_column_no_property() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
label: ID",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
&Column::from_config(val).unwrap_err().to_string(),
|
|
||||||
": 'property' property is required"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_column_invalid_property() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
label: ID
|
|
||||||
property: []",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
&Column::from_config(val).unwrap_err().to_string(),
|
|
||||||
".property: invalid type: sequence, expected a string"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_property() {
|
|
||||||
let val = config_from("uuid");
|
|
||||||
let prop = Property::from_config(val).unwrap();
|
|
||||||
assert_eq!(prop, Property::Uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_property_invalid_type() {
|
|
||||||
let val = config_from("{}");
|
|
||||||
assert_eq!(
|
|
||||||
&Property::from_config(val).unwrap_err().to_string(),
|
|
||||||
": invalid type: map, expected a string"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sort() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
ascending: false
|
|
||||||
sort_by: id",
|
|
||||||
);
|
|
||||||
let sort = Sort::from_config(val).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
sort,
|
|
||||||
Sort {
|
|
||||||
ascending: false,
|
|
||||||
sort_by: SortBy::Id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sort_no_ascending() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
sort_by: id",
|
|
||||||
);
|
|
||||||
let sort = Sort::from_config(val).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
sort,
|
|
||||||
Sort {
|
|
||||||
ascending: true,
|
|
||||||
sort_by: SortBy::Id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sort_unknown_prop() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
sort_by: id
|
|
||||||
nosuch: foo",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
&Sort::from_config(val).unwrap_err().to_string(),
|
|
||||||
": unknown properties"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sort_no_sort_by() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
ascending: true",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
&Sort::from_config(val).unwrap_err().to_string(),
|
|
||||||
": 'sort_by' property is required"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sort_invalid_ascending() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
sort_by: id
|
|
||||||
ascending: {}",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
&Sort::from_config(val).unwrap_err().to_string(),
|
|
||||||
".ascending: invalid type: map, expected a boolean"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sort_invalid_sort_by() {
|
|
||||||
let val = config_from(
|
|
||||||
"
|
|
||||||
sort_by: {}",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
&Sort::from_config(val).unwrap_err().to_string(),
|
|
||||||
".sort_by: invalid type: map, expected a string"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sort_by() {
|
|
||||||
let val = config_from("uuid");
|
|
||||||
let prop = SortBy::from_config(val).unwrap();
|
|
||||||
assert_eq!(prop, SortBy::Uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sort_by_unknown() {
|
|
||||||
let val = config_from("nosuch");
|
|
||||||
assert_eq!(
|
|
||||||
&SortBy::from_config(val).unwrap_err().to_string(),
|
|
||||||
": unknown sort_by nosuch"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sort_by_invalid_type() {
|
|
||||||
let val = config_from("{}");
|
|
||||||
assert_eq!(
|
|
||||||
&SortBy::from_config(val).unwrap_err().to_string(),
|
|
||||||
": invalid type: map, expected a string"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,85 +0,0 @@
|
||||||
use config::{Config, Environment, File, FileFormat, FileSourceFile, FileSourceString};
|
|
||||||
use std::env;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
const DEFAULTS: &str = r#"
|
|
||||||
reports:
|
|
||||||
list:
|
|
||||||
sort:
|
|
||||||
- sort_by: uuid
|
|
||||||
columns:
|
|
||||||
- label: Id
|
|
||||||
property: id
|
|
||||||
- label: Description
|
|
||||||
property: description
|
|
||||||
- label: Active
|
|
||||||
property: active
|
|
||||||
- label: Tags
|
|
||||||
property: tags
|
|
||||||
next:
|
|
||||||
filter:
|
|
||||||
- "status:pending"
|
|
||||||
sort:
|
|
||||||
- sort_by: uuid
|
|
||||||
columns:
|
|
||||||
- label: Id
|
|
||||||
property: id
|
|
||||||
- label: Description
|
|
||||||
property: description
|
|
||||||
- label: Active
|
|
||||||
property: active
|
|
||||||
- label: Tags
|
|
||||||
property: tags
|
|
||||||
"#;
|
|
||||||
|
|
||||||
/// Get the default settings for this application
|
|
||||||
pub(crate) fn default_settings() -> anyhow::Result<Config> {
|
|
||||||
let mut settings = Config::default();
|
|
||||||
|
|
||||||
// set up defaults
|
|
||||||
if let Some(dir) = dirs_next::data_local_dir() {
|
|
||||||
let mut tc_dir = dir.clone();
|
|
||||||
tc_dir.push("taskchampion");
|
|
||||||
settings.set_default(
|
|
||||||
"data_dir",
|
|
||||||
// the config crate does not support non-string paths
|
|
||||||
tc_dir.to_str().expect("data_local_dir is not utf-8"),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let mut server_dir = dir;
|
|
||||||
server_dir.push("taskchampion-sync-server");
|
|
||||||
settings.set_default(
|
|
||||||
"server_dir",
|
|
||||||
// the config crate does not support non-string paths
|
|
||||||
server_dir.to_str().expect("data_local_dir is not utf-8"),
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let defaults: File<FileSourceString> = File::from_str(DEFAULTS, FileFormat::Yaml);
|
|
||||||
settings.merge(defaults)?;
|
|
||||||
|
|
||||||
Ok(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn read_settings() -> anyhow::Result<Config> {
|
|
||||||
let mut settings = default_settings()?;
|
|
||||||
|
|
||||||
// load either from the path in TASKCHAMPION_CONFIG, or from CONFIG_DIR/taskchampion
|
|
||||||
if let Some(config_file) = env::var_os("TASKCHAMPION_CONFIG") {
|
|
||||||
log::debug!("Loading configuration from {:?}", config_file);
|
|
||||||
let config_file: PathBuf = config_file.into();
|
|
||||||
let config_file: File<FileSourceFile> = config_file.into();
|
|
||||||
settings.merge(config_file.required(true))?;
|
|
||||||
env::remove_var("TASKCHAMPION_CONFIG");
|
|
||||||
} else if let Some(mut dir) = dirs_next::config_dir() {
|
|
||||||
dir.push("taskchampion");
|
|
||||||
log::debug!("Loading configuration from {:?} (optional)", dir);
|
|
||||||
let config_file: File<FileSourceFile> = dir.into();
|
|
||||||
settings.merge(config_file.required(false))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// merge environment variables
|
|
||||||
settings.merge(Environment::with_prefix("TASKCHAMPION"))?;
|
|
||||||
|
|
||||||
Ok(settings)
|
|
||||||
}
|
|
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, "USAGE:\n {} [args]\n", command_name)?;
|
||||||
writeln!(w, "TaskChampion subcommands:")?;
|
writeln!(w, "TaskChampion subcommands:")?;
|
||||||
for subcommand in self.subcommands.iter() {
|
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!(w, "Filter Expressions:\n")?;
|
||||||
writeln!(
|
writeln!(
|
||||||
|
@ -56,7 +56,7 @@ impl Usage {
|
||||||
)
|
)
|
||||||
)?;
|
)?;
|
||||||
for filter in self.filters.iter() {
|
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!(w, "Modifications:\n")?;
|
||||||
writeln!(
|
writeln!(
|
||||||
|
@ -70,10 +70,10 @@ impl Usage {
|
||||||
)
|
)
|
||||||
)?;
|
)?;
|
||||||
for modification in self.modifications.iter() {
|
for modification in self.modifications.iter() {
|
||||||
modification.write_help(&mut w, summary)?;
|
modification.write_help(&mut w, command_name, summary)?;
|
||||||
}
|
}
|
||||||
if !summary {
|
if !summary {
|
||||||
writeln!(w, "\nSee `task help` for more detail")?;
|
writeln!(w, "\nSee `{} help` for more detail", command_name)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -108,13 +108,14 @@ pub(crate) struct Subcommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
if summary {
|
||||||
writeln!(w, " task {} - {}", self.name, self.summary)?;
|
writeln!(w, " {} {} - {}", command_name, self.name, self.summary)?;
|
||||||
} else {
|
} else {
|
||||||
writeln!(
|
writeln!(
|
||||||
w,
|
w,
|
||||||
" task {}\n{}",
|
" {} {}\n{}",
|
||||||
|
command_name,
|
||||||
self.syntax,
|
self.syntax,
|
||||||
indented(self.description, " ")
|
indented(self.description, " ")
|
||||||
)?;
|
)?;
|
||||||
|
@ -138,7 +139,7 @@ pub(crate) struct Filter {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
if summary {
|
||||||
writeln!(w, " {} - {}", self.syntax, self.summary)?;
|
writeln!(w, " {} - {}", self.syntax, self.summary)?;
|
||||||
} else {
|
} else {
|
||||||
|
@ -168,7 +169,7 @@ pub(crate) struct Modification {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
if summary {
|
||||||
writeln!(w, " {} - {}", self.syntax, self.summary)?;
|
writeln!(w, " {} - {}", self.syntax, self.summary)?;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -1,13 +1,31 @@
|
||||||
use assert_cmd::prelude::*;
|
use assert_cmd::prelude::*;
|
||||||
use predicates::prelude::*;
|
use predicates::prelude::*;
|
||||||
|
use std::fs;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
// NOTE: This tests that the task binary is running and parsing arguments. The details of
|
// NOTE: This tests that the `ta` binary is running and parsing arguments. The details of
|
||||||
// subcommands are handled with unit tests.
|
// subcommands are handled with unit tests.
|
||||||
|
|
||||||
|
/// These tests force config to be read via TASKCHAMPION_CONFIG so that a user's own config file
|
||||||
|
/// (in their homedir) does not interfere with tests.
|
||||||
|
fn test_cmd(dir: &TempDir) -> Result<Command, Box<dyn std::error::Error>> {
|
||||||
|
let config_filename = dir.path().join("config.toml");
|
||||||
|
fs::write(
|
||||||
|
config_filename.clone(),
|
||||||
|
format!("data_dir = {:?}", dir.path()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let config_filename = config_filename.to_str().unwrap();
|
||||||
|
let mut cmd = Command::cargo_bin("ta")?;
|
||||||
|
cmd.env("TASKCHAMPION_CONFIG", config_filename);
|
||||||
|
Ok(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help() -> Result<(), Box<dyn std::error::Error>> {
|
fn help() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut cmd = Command::cargo_bin("task")?;
|
let dir = TempDir::new().unwrap();
|
||||||
|
let mut cmd = test_cmd(&dir)?;
|
||||||
|
|
||||||
cmd.arg("--help");
|
cmd.arg("--help");
|
||||||
cmd.assert()
|
cmd.assert()
|
||||||
|
@ -19,7 +37,8 @@ fn help() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version() -> Result<(), Box<dyn std::error::Error>> {
|
fn version() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut cmd = Command::cargo_bin("task")?;
|
let dir = TempDir::new().unwrap();
|
||||||
|
let mut cmd = test_cmd(&dir)?;
|
||||||
|
|
||||||
cmd.arg("--version");
|
cmd.arg("--version");
|
||||||
cmd.assert()
|
cmd.assert()
|
||||||
|
@ -31,7 +50,8 @@ fn version() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_option() -> Result<(), Box<dyn std::error::Error>> {
|
fn invalid_option() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut cmd = Command::cargo_bin("task")?;
|
let dir = TempDir::new().unwrap();
|
||||||
|
let mut cmd = test_cmd(&dir)?;
|
||||||
|
|
||||||
cmd.arg("--no-such-option");
|
cmd.arg("--no-such-option");
|
||||||
cmd.assert()
|
cmd.assert()
|
||||||
|
|
2
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
|
# Summary
|
||||||
|
|
||||||
- [Welcome to TaskChampion](./welcome.md)
|
- [Welcome to TaskChampion](./welcome.md)
|
||||||
- [Installation](./installation.md)
|
* [Installation](./installation.md)
|
||||||
* [Using the Task Command](./using-task-command.md)
|
* [Using the Task Command](./using-task-command.md)
|
||||||
* [Configuration](./config-file.md)
|
* [Configuration](./config-file.md)
|
||||||
* [Reports](./reports.md)
|
* [Reports](./reports.md)
|
||||||
* [Tags](./tags.md)
|
* [Tags](./tags.md)
|
||||||
|
* [Environment](./environment.md)
|
||||||
* [Synchronization](./task-sync.md)
|
* [Synchronization](./task-sync.md)
|
||||||
* [Running the Sync Server](./running-sync-server.md)
|
* [Running the Sync Server](./running-sync-server.md)
|
||||||
* [Debugging](./debugging.md)
|
|
||||||
- [Internal Details](./internals.md)
|
- [Internal Details](./internals.md)
|
||||||
- [Data Model](./data-model.md)
|
* [Data Model](./data-model.md)
|
||||||
- [Replica Storage](./storage.md)
|
* [Replica Storage](./storage.md)
|
||||||
- [Task Database](./taskdb.md)
|
* [Task Database](./taskdb.md)
|
||||||
- [Tasks](./tasks.md)
|
* [Tasks](./tasks.md)
|
||||||
- [Synchronization and the Sync Server](./sync.md)
|
* [Synchronization and the Sync Server](./sync.md)
|
||||||
- [Synchronization Model](./sync-model.md)
|
* [Synchronization Model](./sync-model.md)
|
||||||
- [Server-Replica Protocol](./sync-protocol.md)
|
* [Server-Replica Protocol](./sync-protocol.md)
|
||||||
- [Planned Functionality](./plans.md)
|
* [Planned Functionality](./plans.md)
|
||||||
|
|
|
@ -1,15 +1,19 @@
|
||||||
# Configuration
|
# 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 Linux systems, that directory is `~/.config`.
|
||||||
On OS X, it's `~/Library/Preferences`.
|
On OS X, it's `~/Library/Preferences`.
|
||||||
On Windows, it's `AppData/Roaming` in your home directory.
|
On Windows, it's `AppData/Roaming` in your home directory.
|
||||||
The path can be overridden by setting `$TASKCHAMPION_CONFIG`.
|
This can be overridden by setting `TASKCHAMPION_CONFIG` to the configuration filename.
|
||||||
|
|
||||||
Individual configuration parameters can be overridden by environment variables, converted to upper-case and prefixed with `TASKCHAMPION_`, e.g., `TASKCHAMPION_DATA_DIR`.
|
The file format is [TOML](https://toml.io/).
|
||||||
Nested configuration parameters such as `reports` cannot be overridden by environment variables.
|
For example:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
data_dir = "/home/myuser/.tasks"
|
||||||
|
```
|
||||||
|
|
||||||
## Directories
|
## Directories
|
||||||
|
|
||||||
|
@ -36,7 +40,15 @@ If using a remote server:
|
||||||
* `server_client_key` - Client key to identify this replica to the sync server (a UUID)
|
* `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.
|
If not set, then sync is done to a local server.
|
||||||
|
|
||||||
# Reports
|
## Reports
|
||||||
|
|
||||||
* `reports` - a mapping of each report's name to its definition.
|
* `reports` - a mapping of each report's name to its definition.
|
||||||
See [Reports](./reports.md) for details.
|
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:
|
The `next` report is the default, and lists all pending tasks:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
$ task
|
$ ta
|
||||||
Id Description Active Tags
|
Id Description Active Tags
|
||||||
1 learn about TaskChampion +next
|
1 learn about TaskChampion +next
|
||||||
2 buy wedding gift * +buy
|
2 buy wedding gift * +buy
|
||||||
|
3 plant tomatoes +garden
|
||||||
```
|
```
|
||||||
|
|
||||||
The `Id` column contains short numeric IDs that are assigned to pending tasks.
|
The `Id` column contains short numeric IDs that are assigned to pending tasks.
|
||||||
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.
|
The `list` report lists all tasks, with a similar set of columns.
|
||||||
|
|
||||||
## Custom Reports
|
## Custom Reports
|
||||||
|
|
||||||
Custom reports are defined in the configuration file's `reports` property.
|
Custom reports are defined in the configuration file's `reports` table.
|
||||||
This is a mapping from each report's name to its definition.
|
This is a mapping from each report's name to its definition.
|
||||||
Each definition has the following properties:
|
Each definition has the following properties:
|
||||||
|
|
||||||
* `filter` - criteria for the tasks to include in the report
|
* `filter` - criteria for the tasks to include in the report (optional)
|
||||||
* `sort` - how to order the tasks
|
* `sort` - how to order the tasks (optional)
|
||||||
* `columns` - the columns of information to display for each task
|
* `columns` - the columns of information to display for each task
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[reports.garden]
|
||||||
|
sort = [
|
||||||
|
{ sort_by = "description" }
|
||||||
|
]
|
||||||
|
filter = [
|
||||||
|
"status:pending",
|
||||||
|
"+garden"
|
||||||
|
]
|
||||||
|
columns = [
|
||||||
|
{ label = "ID", property = "id" },
|
||||||
|
{ label = "Description", property = "description" },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
The filter is a list of filter arguments, just like those that can be used on the command line.
|
The filter is a list of filter arguments, just like those that can be used on the command line.
|
||||||
See the `task help` output for more details on this syntax.
|
See the `ta help` output for more details on this syntax.
|
||||||
For example:
|
It will be merged with any filters provided on the command line, when the report is invoked.
|
||||||
|
|
||||||
```yaml
|
The sort order is defined by an array of tables containing a `sort_by` property and an optional `ascending` property.
|
||||||
reports:
|
|
||||||
garden:
|
|
||||||
filter:
|
|
||||||
- "status:pending"
|
|
||||||
- "+garden"
|
|
||||||
```
|
|
||||||
|
|
||||||
The sort order is defined by an array of objects containing a `sort_by` property and an optional `ascending` property.
|
|
||||||
Tasks are compared by the first criterion, and if that is equal by the second, and so on.
|
Tasks are compared by the first criterion, and if that is equal by the second, and so on.
|
||||||
For example:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
reports:
|
|
||||||
garden:
|
|
||||||
sort:
|
|
||||||
- sort_by: description
|
|
||||||
- sort_by: uuid
|
|
||||||
ascending: false
|
|
||||||
```
|
|
||||||
If `ascending` is given, it can be `true` for the default sort order, or `false` for the reverse.
|
If `ascending` is given, it can be `true` for the default sort order, or `false` for the reverse.
|
||||||
|
|
||||||
|
In most cases tasks are just sorted by one criterion, but a more advanced example might look like:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[reports.garden]
|
||||||
|
sort = [
|
||||||
|
{ sort_by = "description" }
|
||||||
|
{ sort_by = "uuid", ascending = false }
|
||||||
|
]
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
The available values of `sort_by` are
|
The available values of `sort_by` are
|
||||||
|
|
||||||
(TODO: generate automatically)
|
(TODO: generate automatically)
|
||||||
|
|
||||||
Finally, the configuration specifies the list of columns to display in the `columns` property.
|
Finally, the `columns` configuration specifies the list of columns to display.
|
||||||
Each element has a `label` and a `property`:
|
Each element has a `label` and a `property`, as shown in the example above.
|
||||||
|
|
||||||
```yaml
|
|
||||||
reports:
|
|
||||||
garden:
|
|
||||||
columns:
|
|
||||||
- label: Id
|
|
||||||
property: id
|
|
||||||
- label: Description
|
|
||||||
property: description
|
|
||||||
- label: Tags
|
|
||||||
property: tags
|
|
||||||
```
|
|
||||||
|
|
||||||
The avaliable properties are:
|
The avaliable properties are:
|
||||||
|
|
||||||
|
|
|
@ -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.
|
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.
|
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
|
### 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 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`.
|
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
|
### 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.
|
The response is 404 NOT FOUND if no such version exists.
|
||||||
Otherwise, the response is a 200 OK.
|
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`.
|
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 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.
|
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
|
## 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.
|
A replica "synchronizes" its local information with other replicas via a sync server.
|
||||||
Many replicas can thus share the same task history.
|
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.
|
Typically this runs frequently in a cron task.
|
||||||
Synchronization is quick, especially if no changes have occurred.
|
Synchronization is quick, especially if no changes have occurred.
|
||||||
|
|
||||||
Each replica expects to be synchronized frequently, even if no server is involved.
|
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.
|
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.
|
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`.
|
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.
|
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.
|
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.
|
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
|
# 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`.
|
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.
|
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).
|
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.
|
> NOTE: the `task` interface does not precisely match that of TaskWarrior.
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# TaskChampion
|
# TaskChampion
|
||||||
|
|
||||||
TaskChampion is a personal task-tracking tool.
|
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.
|
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.
|
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.
|
> NOTE: TaskChampion is still in development and not yet feature-complete.
|
||||||
> This section is limited to completed functionality.
|
> 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:
|
Start by adding a task:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
$ task add learn how to use taskchampion
|
$ ta add learn how to use taskchampion
|
||||||
added task ba57deaf-f97b-4e9c-b9ab-04bc1ecb22b8
|
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
|
```shell
|
||||||
$ task
|
$ ta
|
||||||
Id Description Active Tags
|
Id Description Active Tags
|
||||||
1 learn how to use taskchampion
|
1 learn how to use taskchampion
|
||||||
```
|
```
|
||||||
|
@ -29,13 +29,13 @@ $ task
|
||||||
Tell TaskChampion you're working on the task, using the shorthand id:
|
Tell TaskChampion you're working on the task, using the shorthand id:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
$ task start 1
|
$ ta start 1
|
||||||
```
|
```
|
||||||
|
|
||||||
and when you're done with the task, mark it as complete:
|
and when you're done with the task, mark it as complete:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
$ task done 1
|
$ ta done 1
|
||||||
```
|
```
|
||||||
|
|
||||||
## Synchronizing
|
## 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.
|
This acts as a backup and also enables some internal house-cleaning.
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
$ task sync
|
$ ta sync
|
||||||
```
|
```
|
||||||
|
|
||||||
Typically sync is run from a crontab, on whatever schedule fits your needs.
|
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"
|
server_origin: "https://taskchampion.example.com"
|
||||||
```
|
```
|
||||||
|
|
||||||
The next run of `task sync` will upload your task history to that server.
|
The next run of `ta 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.
|
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.
|
/// parent version ID in the `X-Parent-Version-Id` header.
|
||||||
///
|
///
|
||||||
/// Returns other 4xx or 5xx responses on other errors.
|
/// 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(
|
pub(crate) async fn service(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
server_state: web::Data<ServerState>,
|
server_state: web::Data<ServerState>,
|
||||||
|
@ -99,7 +99,7 @@ mod test {
|
||||||
let server_state = ServerState::new(server_box);
|
let server_state = ServerState::new(server_box);
|
||||||
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
|
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()
|
let req = test::TestRequest::post()
|
||||||
.uri(&uri)
|
.uri(&uri)
|
||||||
.header(
|
.header(
|
||||||
|
@ -136,7 +136,7 @@ mod test {
|
||||||
let server_state = ServerState::new(server_box);
|
let server_state = ServerState::new(server_box);
|
||||||
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
|
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()
|
let req = test::TestRequest::post()
|
||||||
.uri(&uri)
|
.uri(&uri)
|
||||||
.header(
|
.header(
|
||||||
|
@ -163,7 +163,7 @@ mod test {
|
||||||
let server_state = ServerState::new(server_box);
|
let server_state = ServerState::new(server_box);
|
||||||
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
|
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()
|
let req = test::TestRequest::post()
|
||||||
.uri(&uri)
|
.uri(&uri)
|
||||||
.header("Content-Type", "not/correct")
|
.header("Content-Type", "not/correct")
|
||||||
|
@ -182,7 +182,7 @@ mod test {
|
||||||
let server_state = ServerState::new(server_box);
|
let server_state = ServerState::new(server_box);
|
||||||
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
|
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()
|
let req = test::TestRequest::post()
|
||||||
.uri(&uri)
|
.uri(&uri)
|
||||||
.header(
|
.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.
|
/// If no such child exists, returns a 404 with no content.
|
||||||
/// Returns other 4xx or 5xx responses on other errors.
|
/// 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(
|
pub(crate) async fn service(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
server_state: web::Data<ServerState>,
|
server_state: web::Data<ServerState>,
|
||||||
|
@ -68,7 +68,7 @@ mod test {
|
||||||
let server_state = ServerState::new(server_box);
|
let server_state = ServerState::new(server_box);
|
||||||
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
|
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()
|
let req = test::TestRequest::get()
|
||||||
.uri(&uri)
|
.uri(&uri)
|
||||||
.header("X-Client-Key", client_key.to_string())
|
.header("X-Client-Key", client_key.to_string())
|
||||||
|
@ -101,7 +101,7 @@ mod test {
|
||||||
let server_state = ServerState::new(server_box);
|
let server_state = ServerState::new(server_box);
|
||||||
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
|
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()
|
let req = test::TestRequest::get()
|
||||||
.uri(&uri)
|
.uri(&uri)
|
||||||
.header("X-Client-Key", client_key.to_string())
|
.header("X-Client-Key", client_key.to_string())
|
||||||
|
@ -126,7 +126,7 @@ mod test {
|
||||||
let server_state = ServerState::new(server_box);
|
let server_state = ServerState::new(server_box);
|
||||||
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
|
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()
|
let req = test::TestRequest::get()
|
||||||
.uri(&uri)
|
.uri(&uri)
|
||||||
.header("X-Client-Key", client_key.to_string())
|
.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::task::{Status, Task};
|
||||||
use crate::taskdb::TaskDb;
|
use crate::taskdb::TaskDb;
|
||||||
use crate::workingset::WorkingSet;
|
use crate::workingset::WorkingSet;
|
||||||
|
use anyhow::Context;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use log::trace;
|
use log::trace;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
@ -123,8 +124,10 @@ impl Replica {
|
||||||
/// this occurs, but without renumbering, so any newly-pending tasks should appear in
|
/// this occurs, but without renumbering, so any newly-pending tasks should appear in
|
||||||
/// the working set.
|
/// the working set.
|
||||||
pub fn sync(&mut self, server: &mut Box<dyn Server>) -> anyhow::Result<()> {
|
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)
|
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
|
/// 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,
|
parent_version_id: VersionId,
|
||||||
history_segment: HistorySegment,
|
history_segment: HistorySegment,
|
||||||
) -> anyhow::Result<AddVersionResult> {
|
) -> 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 {
|
let history_cleartext = HistoryCleartext {
|
||||||
parent_version_id,
|
parent_version_id,
|
||||||
history_segment,
|
history_segment,
|
||||||
|
@ -82,7 +85,7 @@ impl Server for RemoteServer {
|
||||||
parent_version_id: VersionId,
|
parent_version_id: VersionId,
|
||||||
) -> anyhow::Result<GetVersionResult> {
|
) -> anyhow::Result<GetVersionResult> {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/client/get-child-version/{}",
|
"{}/v1/client/get-child-version/{}",
|
||||||
self.origin, parent_version_id
|
self.origin, parent_version_id
|
||||||
);
|
);
|
||||||
match self
|
match self
|
||||||
|
|
|
@ -117,14 +117,14 @@ impl TaskDb {
|
||||||
{
|
{
|
||||||
let mut txn = self.storage.txn()?;
|
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();
|
let mut seen = HashSet::new();
|
||||||
|
|
||||||
// The goal here is for existing working-set items to be "compressed' down to index 1, so
|
// 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
|
// 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
|
// be in the set into new_ws, implicitly dropping any tasks that are no longer in the
|
||||||
// working set.
|
// 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(uuid) = elt {
|
||||||
if let Some(task) = txn.get_task(uuid)? {
|
if let Some(task) = txn.get_task(uuid)? {
|
||||||
if in_working_set(&task) {
|
if in_working_set(&task) {
|
||||||
|
@ -144,14 +144,12 @@ impl TaskDb {
|
||||||
// if renumbering, clear the working set and re-add
|
// if renumbering, clear the working set and re-add
|
||||||
if renumber {
|
if renumber {
|
||||||
txn.clear_working_set()?;
|
txn.clear_working_set()?;
|
||||||
for elt in new_ws.drain(0..new_ws.len()) {
|
for elt in new_ws.drain(1..new_ws.len()).flatten() {
|
||||||
if let Some(uuid) = elt {
|
txn.add_to_working_set(elt)?;
|
||||||
txn.add_to_working_set(uuid)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ..otherwise, just clear the None items determined above from the working set
|
// ..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() {
|
if elt.is_none() {
|
||||||
txn.set_working_set_item(i, 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()`
|
/// Create a new WorkingSet. Typically this is acquired via `replica.working_set()`
|
||||||
pub(crate) fn new(by_index: Vec<Option<Uuid>>) -> Self {
|
pub(crate) fn new(by_index: Vec<Option<Uuid>>) -> Self {
|
||||||
let mut by_uuid = HashMap::new();
|
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() {
|
for (index, uuid) in by_index.iter().enumerate() {
|
||||||
if let Some(uuid) = uuid {
|
if let Some(uuid) = uuid {
|
||||||
by_uuid.insert(*uuid, index);
|
by_uuid.insert(*uuid, index);
|
||||||
|
@ -58,13 +62,7 @@ impl WorkingSet {
|
||||||
self.by_index
|
self.by_index
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter_map(|(index, uuid)| {
|
.filter_map(|(index, uuid)| uuid.as_ref().map(|uuid| (index, *uuid)))
|
||||||
if let Some(uuid) = uuid {
|
|
||||||
Some((index, *uuid))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|