Merge branch 'main' into sqlstore

This commit is contained in:
dbr 2021-05-28 12:17:09 +10:00
commit aa2340965e
65 changed files with 1386 additions and 3608 deletions

View file

@ -8,7 +8,6 @@ on:
jobs:
mdbook-deploy:
runs-on: ubuntu-latest
needs: mdbook
steps:
- uses: actions/checkout@v1

2765
Cargo.lock generated

File diff suppressed because it is too large Load diff

55
POLICY.md Normal file
View 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.

View file

@ -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.

View file

@ -14,11 +14,8 @@ prettytable-rs = "^0.8.0"
textwrap = { version="^0.13.4", features=["terminal_size"] }
termcolor = "^1.1.2"
atty = "^0.2.14"
[dependencies.config]
default-features = false
features = ["yaml"]
version = "^0.11.0"
toml = "^0.5.8"
toml_edit = "^0.2.0"
[dependencies.taskchampion]
path = "../taskchampion"

View file

@ -49,10 +49,10 @@ mod test {
#[test]
fn test_version() {
assert_eq!(
Command::from_argv(argv!["task", "version"]).unwrap(),
Command::from_argv(argv!["ta", "version"]).unwrap(),
Command {
subcommand: Subcommand::Version,
command_name: s!("task"),
command_name: s!("ta"),
}
);
}

View 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",
});
}
}

View file

@ -18,12 +18,14 @@ That is, they contain no references, and have no methods to aid in their executi
*/
mod args;
mod command;
mod config;
mod filter;
mod modification;
mod subcommand;
pub(crate) use args::TaskId;
pub(crate) use command::Command;
pub(crate) use config::ConfigOperation;
pub(crate) use filter::{Condition, Filter};
pub(crate) use modification::{DescriptionMod, Modification};
pub(crate) use subcommand::Subcommand;

View file

@ -1,5 +1,5 @@
use super::args::*;
use super::{ArgList, DescriptionMod, Filter, Modification};
use super::{ArgList, ConfigOperation, DescriptionMod, Filter, Modification};
use crate::usage;
use nom::{branch::alt, combinator::*, sequence::*, IResult};
use taskchampion::Status;
@ -25,6 +25,11 @@ pub(crate) enum Subcommand {
summary: bool,
},
/// Manipulate configuration
Config {
config_operation: ConfigOperation,
},
/// Add a new task
Add {
modification: Modification,
@ -61,6 +66,7 @@ impl Subcommand {
all_consuming(alt((
Version::parse,
Help::parse,
Config::parse,
Add::parse,
Modify::parse,
Info::parse,
@ -74,6 +80,7 @@ impl Subcommand {
pub(super) fn get_usage(u: &mut usage::Usage) {
Version::get_usage(u);
Help::get_usage(u);
Config::get_usage(u);
Add::get_usage(u);
Modify::get_usage(u);
Info::get_usage(u);
@ -131,6 +138,26 @@ impl Help {
fn get_usage(_u: &mut usage::Usage) {}
}
struct Config;
impl Config {
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
fn to_subcommand(input: (&str, ConfigOperation)) -> Result<Subcommand, ()> {
Ok(Subcommand::Config {
config_operation: input.1,
})
}
map_res(
tuple((arg_matching(literal("config")), ConfigOperation::parse)),
to_subcommand,
)(input)
}
fn get_usage(u: &mut usage::Usage) {
ConfigOperation::get_usage(u);
}
}
struct Add;
impl Add {
@ -427,6 +454,19 @@ mod test {
);
}
#[test]
fn test_config_set() {
assert_eq!(
Subcommand::parse(argv!["config", "set", "x", "y"]).unwrap(),
(
&EMPTY[..],
Subcommand::Config {
config_operation: ConfigOperation::Set("x".to_owned(), "y".to_owned())
}
)
);
}
#[test]
fn test_add_description() {
let subcommand = Subcommand::Add {

View file

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

View 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"
);
}
}

View file

@ -19,12 +19,12 @@ mod test {
#[test]
fn test_summary() {
let mut w = test_writer();
execute(&mut w, s!("task"), true).unwrap();
execute(&mut w, s!("ta"), true).unwrap();
}
#[test]
fn test_long() {
let mut w = test_writer();
execute(&mut w, s!("task"), false).unwrap();
execute(&mut w, s!("ta"), false).unwrap();
}
}

View file

@ -1,6 +1,7 @@
//! Responsible for executing commands as parsed by [`crate::argparse`].
pub(crate) mod add;
pub(crate) mod config;
pub(crate) mod gc;
pub(crate) mod help;
pub(crate) mod info;

View file

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

View file

@ -6,7 +6,7 @@ pub(crate) fn execute<W: WriteColor>(
replica: &mut Replica,
server: &mut Box<dyn Server>,
) -> anyhow::Result<()> {
replica.sync(server).unwrap();
replica.sync(server)?;
writeln!(w, "sync complete.")?;
Ok(())
}

View file

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

View file

@ -1,8 +1,8 @@
use crate::argparse::Filter;
use crate::invocation::filtered_tasks;
use crate::report::{Column, Property, Report, SortBy};
use crate::settings::{Column, Property, Report, Settings, SortBy};
use crate::table;
use config::Config;
use anyhow::anyhow;
use prettytable::{Row, Table};
use std::cmp::Ordering;
use taskchampion::{Replica, Task, WorkingSet};
@ -18,8 +18,6 @@ fn sort_tasks(tasks: &mut Vec<Task>, report: &Report, working_set: &WorkingSet)
let b_uuid = b.get_uuid();
let a_id = working_set.by_uuid(a_uuid);
let b_id = working_set.by_uuid(b_uuid);
println!("a_uuid {} -> a_id {:?}", a_uuid, a_id);
println!("b_uuid {} -> b_id {:?}", b_uuid, b_id);
match (a_id, b_id) {
(Some(a_id), Some(b_id)) => a_id.cmp(&b_id),
(Some(_), None) => Ordering::Less,
@ -79,7 +77,7 @@ fn task_column(task: &Task, column: &Column, working_set: &WorkingSet) -> String
pub(super) fn display_report<W: WriteColor>(
w: &mut W,
replica: &mut Replica,
settings: &Config,
settings: &Settings,
report_name: String,
filter: Filter,
) -> anyhow::Result<()> {
@ -87,8 +85,11 @@ pub(super) fn display_report<W: WriteColor>(
let working_set = replica.working_set()?;
// Get the report from settings
let mut report = Report::from_config(settings.get(&format!("reports.{}", report_name))?)
.map_err(|e| anyhow::anyhow!("report.{}{}", report_name, e))?;
let mut report = settings
.reports
.get(&report_name)
.ok_or_else(|| anyhow!("report `{}` not defined", report_name))?
.clone();
// include any user-supplied filter conditions
report.filter = report.filter.intersect(filter);
@ -122,7 +123,7 @@ pub(super) fn display_report<W: WriteColor>(
mod test {
use super::*;
use crate::invocation::test::*;
use crate::report::Sort;
use crate::settings::Sort;
use std::convert::TryInto;
use taskchampion::{Status, Uuid};

View file

@ -1,5 +1,6 @@
#![deny(clippy::all)]
#![allow(clippy::unnecessary_wraps)] // for Rust 1.50, https://github.com/rust-lang/rust-clippy/pull/6765
#![allow(clippy::module_inception)] // we use re-exports to shorten stuttering paths like settings::settings::Settings
/*!
This crate implements the command-line interface to TaskChampion.
@ -38,11 +39,12 @@ mod macros;
mod argparse;
mod invocation;
mod report;
mod settings;
mod table;
mod usage;
use settings::Settings;
/// The main entry point for the command-line interface. This builds an Invocation
/// from the particulars of the operating-system interface, and then executes it.
pub fn main() -> anyhow::Result<()> {
@ -59,7 +61,7 @@ pub fn main() -> anyhow::Result<()> {
let command = argparse::Command::from_argv(&argv[..])?;
// load the application settings
let settings = settings::read_settings()?;
let settings = Settings::read()?;
invocation::invoke(command, settings)?;
Ok(())

View file

@ -1,582 +0,0 @@
//! This module contains the data structures used to define reports.
use crate::argparse::{Condition, Filter};
use anyhow::bail;
/// A report specifies a filter as well as a sort order and information about which
/// task attributes to display
#[derive(Clone, Debug, PartialEq, Default)]
pub(crate) struct Report {
/// Columns to display in this report
pub columns: Vec<Column>,
/// Sort order for this report
pub sort: Vec<Sort>,
/// Filter selecting tasks for this report
pub filter: Filter,
}
/// A column to display in a report
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Column {
/// The label for this column
pub label: String,
/// The property to display
pub property: Property,
}
/// Task property to display in a report
#[derive(Clone, Debug, PartialEq)]
#[allow(dead_code)]
pub(crate) enum Property {
/// The task's ID, either working-set index or Uuid if not in the working set
Id,
/// The task's full UUID
Uuid,
/// Whether the task is active or not
Active,
/// The task's description
Description,
/// The task's tags
Tags,
}
/// A sorting criterion for a sort operation.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Sort {
/// True if the sort should be "ascending" (a -> z, 0 -> 9, etc.)
pub ascending: bool,
/// The property to sort on
pub sort_by: SortBy,
}
/// Task property to sort by
#[derive(Clone, Debug, PartialEq)]
#[allow(dead_code)]
pub(crate) enum SortBy {
/// The task's ID, either working-set index or a UUID prefix; working
/// set tasks sort before others.
Id,
/// The task's full UUID
Uuid,
/// The task's description
Description,
}
// Conversions from config::Value. Note that these cannot ergonomically use TryFrom/TryInto; see
// https://github.com/mehcode/config-rs/issues/162
impl Report {
/// Create a Report from a config value. This should be the `report.<report_name>` value.
/// The error message begins with any additional path information, e.g., `.sort[1].sort_by:
/// ..`.
pub(crate) fn from_config(cfg: config::Value) -> anyhow::Result<Report> {
let mut map = cfg.into_table().map_err(|e| anyhow::anyhow!(": {}", e))?;
let sort = if let Some(sort_array) = map.remove("sort") {
sort_array
.into_array()
.map_err(|e| anyhow::anyhow!(".sort: {}", e))?
.drain(..)
.enumerate()
.map(|(i, v)| {
Sort::from_config(v).map_err(|e| anyhow::anyhow!(".sort[{}]{}", i, e))
})
.collect::<anyhow::Result<Vec<_>>>()?
} else {
vec![]
};
let columns = map
.remove("columns")
.ok_or_else(|| anyhow::anyhow!(": 'columns' property is required"))?
.into_array()
.map_err(|e| anyhow::anyhow!(".columns: {}", e))?
.drain(..)
.enumerate()
.map(|(i, v)| {
Column::from_config(v).map_err(|e| anyhow::anyhow!(".columns[{}]{}", i, e))
})
.collect::<anyhow::Result<Vec<_>>>()?;
let conditions = if let Some(conditions) = map.remove("filter") {
conditions
.into_array()
.map_err(|e| anyhow::anyhow!(".filter: {}", e))?
.drain(..)
.enumerate()
.map(|(i, v)| {
v.into_str()
.map_err(|e| e.into())
.and_then(|s| Condition::parse_str(&s))
.map_err(|e| anyhow::anyhow!(".filter[{}]: {}", i, e))
})
.collect::<anyhow::Result<Vec<_>>>()?
} else {
vec![]
};
let filter = Filter { conditions };
if !map.is_empty() {
bail!(": unknown properties");
}
Ok(Report {
columns,
sort,
filter,
})
}
}
impl Column {
pub(crate) fn from_config(cfg: config::Value) -> anyhow::Result<Column> {
let mut map = cfg.into_table().map_err(|e| anyhow::anyhow!(": {}", e))?;
let label = map
.remove("label")
.ok_or_else(|| anyhow::anyhow!(": 'label' property is required"))?
.into_str()
.map_err(|e| anyhow::anyhow!(".label: {}", e))?;
let property: config::Value = map
.remove("property")
.ok_or_else(|| anyhow::anyhow!(": 'property' property is required"))?;
let property =
Property::from_config(property).map_err(|e| anyhow::anyhow!(".property{}", e))?;
if !map.is_empty() {
bail!(": unknown properties");
}
Ok(Column { label, property })
}
}
impl Property {
pub(crate) fn from_config(cfg: config::Value) -> anyhow::Result<Property> {
let s = cfg.into_str().map_err(|e| anyhow::anyhow!(": {}", e))?;
Ok(match s.as_ref() {
"id" => Property::Id,
"uuid" => Property::Uuid,
"active" => Property::Active,
"description" => Property::Description,
"tags" => Property::Tags,
_ => bail!(": unknown property {}", s),
})
}
}
impl Sort {
pub(crate) fn from_config(cfg: config::Value) -> anyhow::Result<Sort> {
let mut map = cfg.into_table().map_err(|e| anyhow::anyhow!(": {}", e))?;
let ascending = match map.remove("ascending") {
Some(v) => v
.into_bool()
.map_err(|e| anyhow::anyhow!(".ascending: {}", e))?,
None => true, // default
};
let sort_by: config::Value = map
.remove("sort_by")
.ok_or_else(|| anyhow::anyhow!(": 'sort_by' property is required"))?;
let sort_by = SortBy::from_config(sort_by).map_err(|e| anyhow::anyhow!(".sort_by{}", e))?;
if !map.is_empty() {
bail!(": unknown properties");
}
Ok(Sort { ascending, sort_by })
}
}
impl SortBy {
pub(crate) fn from_config(cfg: config::Value) -> anyhow::Result<SortBy> {
let s = cfg.into_str().map_err(|e| anyhow::anyhow!(": {}", e))?;
Ok(match s.as_ref() {
"id" => SortBy::Id,
"uuid" => SortBy::Uuid,
"description" => SortBy::Description,
_ => bail!(": unknown sort_by {}", s),
})
}
}
#[cfg(test)]
mod test {
use super::*;
use config::{Config, File, FileFormat, FileSourceString};
use taskchampion::Status;
use textwrap::{dedent, indent};
fn config_from(cfg: &str) -> config::Value {
// wrap this in a "table" so that we can get any type of value at the top level.
let yaml = format!("val:\n{}", indent(&dedent(&cfg), " "));
let mut settings = Config::new();
let cfg_file: File<FileSourceString> = File::from_str(&yaml, FileFormat::Yaml);
settings.merge(cfg_file).unwrap();
settings.cache.into_table().unwrap().remove("val").unwrap()
}
#[test]
fn test_report_ok() {
let val = config_from(
"
filter: []
sort: []
columns: []
filter:
- status:pending",
);
let report = Report::from_config(val).unwrap();
assert_eq!(
report.filter,
Filter {
conditions: vec![Condition::Status(Status::Pending),],
}
);
assert_eq!(report.columns, vec![]);
assert_eq!(report.sort, vec![]);
}
#[test]
fn test_report_no_sort() {
let val = config_from(
"
filter: []
columns: []",
);
let report = Report::from_config(val).unwrap();
assert_eq!(report.sort, vec![]);
}
#[test]
fn test_report_sort_not_array() {
let val = config_from(
"
filter: []
sort: true
columns: []",
);
assert_eq!(
&Report::from_config(val).unwrap_err().to_string(),
".sort: invalid type: boolean `true`, expected an array"
);
}
#[test]
fn test_report_sort_error() {
let val = config_from(
"
filter: []
sort:
- sort_by: id
- true
columns: []",
);
assert!(&Report::from_config(val)
.unwrap_err()
.to_string()
.starts_with(".sort[1]"));
}
#[test]
fn test_report_unknown_prop() {
let val = config_from(
"
columns: []
filter: []
sort: []
nosuch: true
",
);
assert_eq!(
&Report::from_config(val).unwrap_err().to_string(),
": unknown properties"
);
}
#[test]
fn test_report_no_columns() {
let val = config_from(
"
filter: []
sort: []",
);
assert_eq!(
&Report::from_config(val).unwrap_err().to_string(),
": \'columns\' property is required"
);
}
#[test]
fn test_report_columns_not_array() {
let val = config_from(
"
filter: []
sort: []
columns: true",
);
assert_eq!(
&Report::from_config(val).unwrap_err().to_string(),
".columns: invalid type: boolean `true`, expected an array"
);
}
#[test]
fn test_report_column_error() {
let val = config_from(
"
filter: []
sort: []
columns:
- label: ID
property: id
- true",
);
assert!(&Report::from_config(val)
.unwrap_err()
.to_string()
.starts_with(".columns[1]:"));
}
#[test]
fn test_report_filter_not_array() {
let val = config_from(
"
filter: []
sort: []
columns: []
filter: true",
);
assert_eq!(
&Report::from_config(val).unwrap_err().to_string(),
".filter: invalid type: boolean `true`, expected an array"
);
}
#[test]
fn test_report_filter_error() {
let val = config_from(
"
filter: []
sort: []
columns: []
filter:
- nosuchfilter",
);
assert!(&Report::from_config(val)
.unwrap_err()
.to_string()
.starts_with(".filter[0]: invalid filter condition:"));
}
#[test]
fn test_column() {
let val = config_from(
"
label: ID
property: id",
);
let column = Column::from_config(val).unwrap();
assert_eq!(
column,
Column {
label: "ID".to_owned(),
property: Property::Id,
}
);
}
#[test]
fn test_column_unknown_prop() {
let val = config_from(
"
label: ID
property: id
nosuch: foo",
);
assert_eq!(
&Column::from_config(val).unwrap_err().to_string(),
": unknown properties"
);
}
#[test]
fn test_column_no_label() {
let val = config_from(
"
property: id",
);
assert_eq!(
&Column::from_config(val).unwrap_err().to_string(),
": 'label' property is required"
);
}
#[test]
fn test_column_invalid_label() {
let val = config_from(
"
label: []
property: id",
);
assert_eq!(
&Column::from_config(val).unwrap_err().to_string(),
".label: invalid type: sequence, expected a string"
);
}
#[test]
fn test_column_no_property() {
let val = config_from(
"
label: ID",
);
assert_eq!(
&Column::from_config(val).unwrap_err().to_string(),
": 'property' property is required"
);
}
#[test]
fn test_column_invalid_property() {
let val = config_from(
"
label: ID
property: []",
);
assert_eq!(
&Column::from_config(val).unwrap_err().to_string(),
".property: invalid type: sequence, expected a string"
);
}
#[test]
fn test_property() {
let val = config_from("uuid");
let prop = Property::from_config(val).unwrap();
assert_eq!(prop, Property::Uuid);
}
#[test]
fn test_property_invalid_type() {
let val = config_from("{}");
assert_eq!(
&Property::from_config(val).unwrap_err().to_string(),
": invalid type: map, expected a string"
);
}
#[test]
fn test_sort() {
let val = config_from(
"
ascending: false
sort_by: id",
);
let sort = Sort::from_config(val).unwrap();
assert_eq!(
sort,
Sort {
ascending: false,
sort_by: SortBy::Id,
}
);
}
#[test]
fn test_sort_no_ascending() {
let val = config_from(
"
sort_by: id",
);
let sort = Sort::from_config(val).unwrap();
assert_eq!(
sort,
Sort {
ascending: true,
sort_by: SortBy::Id,
}
);
}
#[test]
fn test_sort_unknown_prop() {
let val = config_from(
"
sort_by: id
nosuch: foo",
);
assert_eq!(
&Sort::from_config(val).unwrap_err().to_string(),
": unknown properties"
);
}
#[test]
fn test_sort_no_sort_by() {
let val = config_from(
"
ascending: true",
);
assert_eq!(
&Sort::from_config(val).unwrap_err().to_string(),
": 'sort_by' property is required"
);
}
#[test]
fn test_sort_invalid_ascending() {
let val = config_from(
"
sort_by: id
ascending: {}",
);
assert_eq!(
&Sort::from_config(val).unwrap_err().to_string(),
".ascending: invalid type: map, expected a boolean"
);
}
#[test]
fn test_sort_invalid_sort_by() {
let val = config_from(
"
sort_by: {}",
);
assert_eq!(
&Sort::from_config(val).unwrap_err().to_string(),
".sort_by: invalid type: map, expected a string"
);
}
#[test]
fn test_sort_by() {
let val = config_from("uuid");
let prop = SortBy::from_config(val).unwrap();
assert_eq!(prop, SortBy::Uuid);
}
#[test]
fn test_sort_by_unknown() {
let val = config_from("nosuch");
assert_eq!(
&SortBy::from_config(val).unwrap_err().to_string(),
": unknown sort_by nosuch"
);
}
#[test]
fn test_sort_by_invalid_type() {
let val = config_from("{}");
assert_eq!(
&SortBy::from_config(val).unwrap_err().to_string(),
": invalid type: map, expected a string"
);
}
}

View file

@ -1,85 +0,0 @@
use config::{Config, Environment, File, FileFormat, FileSourceFile, FileSourceString};
use std::env;
use std::path::PathBuf;
const DEFAULTS: &str = r#"
reports:
list:
sort:
- sort_by: uuid
columns:
- label: Id
property: id
- label: Description
property: description
- label: Active
property: active
- label: Tags
property: tags
next:
filter:
- "status:pending"
sort:
- sort_by: uuid
columns:
- label: Id
property: id
- label: Description
property: description
- label: Active
property: active
- label: Tags
property: tags
"#;
/// Get the default settings for this application
pub(crate) fn default_settings() -> anyhow::Result<Config> {
let mut settings = Config::default();
// set up defaults
if let Some(dir) = dirs_next::data_local_dir() {
let mut tc_dir = dir.clone();
tc_dir.push("taskchampion");
settings.set_default(
"data_dir",
// the config crate does not support non-string paths
tc_dir.to_str().expect("data_local_dir is not utf-8"),
)?;
let mut server_dir = dir;
server_dir.push("taskchampion-sync-server");
settings.set_default(
"server_dir",
// the config crate does not support non-string paths
server_dir.to_str().expect("data_local_dir is not utf-8"),
)?;
}
let defaults: File<FileSourceString> = File::from_str(DEFAULTS, FileFormat::Yaml);
settings.merge(defaults)?;
Ok(settings)
}
pub(crate) fn read_settings() -> anyhow::Result<Config> {
let mut settings = default_settings()?;
// load either from the path in TASKCHAMPION_CONFIG, or from CONFIG_DIR/taskchampion
if let Some(config_file) = env::var_os("TASKCHAMPION_CONFIG") {
log::debug!("Loading configuration from {:?}", config_file);
let config_file: PathBuf = config_file.into();
let config_file: File<FileSourceFile> = config_file.into();
settings.merge(config_file.required(true))?;
env::remove_var("TASKCHAMPION_CONFIG");
} else if let Some(mut dir) = dirs_next::config_dir() {
dir.push("taskchampion");
log::debug!("Loading configuration from {:?} (optional)", dir);
let config_file: File<FileSourceFile> = dir.into();
settings.merge(config_file.required(false))?;
}
// merge environment variables
settings.merge(Environment::with_prefix("TASKCHAMPION"))?;
Ok(settings)
}

11
cli/src/settings/mod.rs Normal file
View 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
View file

@ -0,0 +1,535 @@
//! This module contains the data structures used to define reports.
use crate::argparse::{Condition, Filter};
use crate::settings::util::table_with_keys;
use anyhow::{anyhow, bail, Result};
use std::convert::{TryFrom, TryInto};
/// A report specifies a filter as well as a sort order and information about which
/// task attributes to display
#[derive(Clone, Debug, PartialEq, Default)]
pub(crate) struct Report {
/// Columns to display in this report
pub columns: Vec<Column>,
/// Sort order for this report
pub sort: Vec<Sort>,
/// Filter selecting tasks for this report
pub filter: Filter,
}
/// A column to display in a report
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Column {
/// The label for this column
pub label: String,
/// The property to display
pub property: Property,
}
/// Task property to display in a report
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Property {
/// The task's ID, either working-set index or Uuid if not in the working set
Id,
/// The task's full UUID
Uuid,
/// Whether the task is active or not
Active,
/// The task's description
Description,
/// The task's tags
Tags,
}
/// A sorting criterion for a sort operation.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Sort {
/// True if the sort should be "ascending" (a -> z, 0 -> 9, etc.)
pub ascending: bool,
/// The property to sort on
pub sort_by: SortBy,
}
/// Task property to sort by
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum SortBy {
/// The task's ID, either working-set index or a UUID prefix; working
/// set tasks sort before others.
Id,
/// The task's full UUID
Uuid,
/// The task's description
Description,
}
// Conversions from settings::Settings.
impl TryFrom<toml::Value> for Report {
type Error = anyhow::Error;
fn try_from(cfg: toml::Value) -> Result<Report> {
Report::try_from(&cfg)
}
}
impl TryFrom<&toml::Value> for Report {
type Error = anyhow::Error;
/// Create a Report from a toml value. This should be the `report.<report_name>` value.
/// The error message begins with any additional path information, e.g., `.sort[1].sort_by:
/// ..`.
fn try_from(cfg: &toml::Value) -> Result<Report> {
let keys = ["sort", "columns", "filter"];
let table = table_with_keys(cfg, &keys).map_err(|e| anyhow!(": {}", e))?;
let sort = match table.get("sort") {
Some(v) => v
.as_array()
.ok_or_else(|| anyhow!(".sort: not an array"))?
.iter()
.enumerate()
.map(|(i, v)| v.try_into().map_err(|e| anyhow!(".sort[{}]{}", i, e)))
.collect::<Result<Vec<_>>>()?,
None => vec![],
};
let columns = match table.get("columns") {
Some(v) => v
.as_array()
.ok_or_else(|| anyhow!(".columns: not an array"))?
.iter()
.enumerate()
.map(|(i, v)| v.try_into().map_err(|e| anyhow!(".columns[{}]{}", i, e)))
.collect::<Result<Vec<_>>>()?,
None => bail!(": `columns` property is required"),
};
let conditions = match table.get("filter") {
Some(v) => v
.as_array()
.ok_or_else(|| anyhow!(".filter: not an array"))?
.iter()
.enumerate()
.map(|(i, v)| {
v.as_str()
.ok_or_else(|| anyhow!(".filter[{}]: not a string", i))
.and_then(|s| Condition::parse_str(&s))
.map_err(|e| anyhow!(".filter[{}]: {}", i, e))
})
.collect::<Result<Vec<_>>>()?,
None => vec![],
};
Ok(Report {
columns,
sort,
filter: Filter { conditions },
})
}
}
impl TryFrom<&toml::Value> for Column {
type Error = anyhow::Error;
fn try_from(cfg: &toml::Value) -> Result<Column> {
let keys = ["label", "property"];
let table = table_with_keys(cfg, &keys).map_err(|e| anyhow!(": {}", e))?;
let label = match table.get("label") {
Some(v) => v
.as_str()
.ok_or_else(|| anyhow!(".label: not a string"))?
.to_owned(),
None => bail!(": `label` property is required"),
};
let property = match table.get("property") {
Some(v) => v.try_into().map_err(|e| anyhow!(".property{}", e))?,
None => bail!(": `property` property is required"),
};
Ok(Column { label, property })
}
}
impl TryFrom<&toml::Value> for Property {
type Error = anyhow::Error;
fn try_from(cfg: &toml::Value) -> Result<Property> {
let s = cfg.as_str().ok_or_else(|| anyhow!(": not a string"))?;
Ok(match s {
"id" => Property::Id,
"uuid" => Property::Uuid,
"active" => Property::Active,
"description" => Property::Description,
"tags" => Property::Tags,
_ => bail!(": unknown property {}", s),
})
}
}
impl TryFrom<&toml::Value> for Sort {
type Error = anyhow::Error;
fn try_from(cfg: &toml::Value) -> Result<Sort> {
let keys = ["ascending", "sort_by"];
let table = table_with_keys(cfg, &keys).map_err(|e| anyhow!(": {}", e))?;
let ascending = match table.get("ascending") {
Some(v) => v
.as_bool()
.ok_or_else(|| anyhow!(".ascending: not a boolean value"))?,
None => true, // default
};
let sort_by = match table.get("sort_by") {
Some(v) => v.try_into().map_err(|e| anyhow!(".sort_by{}", e))?,
None => bail!(": `sort_by` property is required"),
};
Ok(Sort { ascending, sort_by })
}
}
impl TryFrom<&toml::Value> for SortBy {
type Error = anyhow::Error;
fn try_from(cfg: &toml::Value) -> Result<SortBy> {
let s = cfg.as_str().ok_or_else(|| anyhow!(": not a string"))?;
Ok(match s {
"id" => SortBy::Id,
"uuid" => SortBy::Uuid,
"description" => SortBy::Description,
_ => bail!(": unknown sort_by value `{}`", s),
})
}
}
#[cfg(test)]
mod test {
use super::*;
use taskchampion::Status;
use toml::toml;
#[test]
fn test_report_ok() {
let val = toml! {
sort = []
columns = []
filter = ["status:pending"]
};
let report: Report = TryInto::try_into(val).unwrap();
assert_eq!(
report.filter,
Filter {
conditions: vec![Condition::Status(Status::Pending),],
}
);
assert_eq!(report.columns, vec![]);
assert_eq!(report.sort, vec![]);
}
#[test]
fn test_report_no_sort() {
let val = toml! {
filter = []
columns = []
};
let report = Report::try_from(val).unwrap();
assert_eq!(report.sort, vec![]);
}
#[test]
fn test_report_sort_not_array() {
let val = toml! {
filter = []
sort = true
columns = []
};
let err = Report::try_from(val).unwrap_err().to_string();
assert_eq!(&err, ".sort: not an array");
}
#[test]
fn test_report_sort_error() {
let val = toml! {
filter = []
sort = [ { sort_by = "id" }, true ]
columns = []
};
let err = Report::try_from(val).unwrap_err().to_string();
assert!(err.starts_with(".sort[1]"));
}
#[test]
fn test_report_unknown_prop() {
let val = toml! {
columns = []
filter = []
sort = []
nosuch = true
};
let err = Report::try_from(val).unwrap_err().to_string();
assert_eq!(&err, ": unknown table key `nosuch`");
}
#[test]
fn test_report_no_columns() {
let val = toml! {
filter = []
sort = []
};
let err = Report::try_from(val).unwrap_err().to_string();
assert_eq!(&err, ": `columns` property is required");
}
#[test]
fn test_report_columns_not_array() {
let val = toml! {
filter = []
sort = []
columns = true
};
let err = Report::try_from(val).unwrap_err().to_string();
assert_eq!(&err, ".columns: not an array");
}
#[test]
fn test_report_column_error() {
let val = toml! {
filter = []
sort = []
[[columns]]
label = "ID"
property = "id"
[[columns]]
foo = 10
};
let err = Report::try_from(val).unwrap_err().to_string();
assert_eq!(&err, ".columns[1]: unknown table key `foo`");
}
#[test]
fn test_report_filter_not_array() {
let val = toml! {
filter = "foo"
sort = []
columns = []
};
let err = Report::try_from(val).unwrap_err().to_string();
assert_eq!(&err, ".filter: not an array");
}
#[test]
fn test_report_filter_error() {
let val = toml! {
sort = []
columns = []
filter = [ "nosuchfilter" ]
};
let err = Report::try_from(val).unwrap_err().to_string();
assert!(err.starts_with(".filter[0]: invalid filter condition:"));
}
#[test]
fn test_column() {
let val = toml! {
label = "ID"
property = "id"
};
let column = Column::try_from(&val).unwrap();
assert_eq!(
column,
Column {
label: "ID".to_owned(),
property: Property::Id,
}
);
}
#[test]
fn test_column_unknown_prop() {
let val = toml! {
label = "ID"
property = "id"
nosuch = "foo"
};
assert_eq!(
&Column::try_from(&val).unwrap_err().to_string(),
": unknown table key `nosuch`"
);
}
#[test]
fn test_column_no_label() {
let val = toml! {
property = "id"
};
assert_eq!(
&Column::try_from(&val).unwrap_err().to_string(),
": `label` property is required"
);
}
#[test]
fn test_column_invalid_label() {
let val = toml! {
label = []
property = "id"
};
assert_eq!(
&Column::try_from(&val).unwrap_err().to_string(),
".label: not a string"
);
}
#[test]
fn test_column_no_property() {
let val = toml! {
label = "ID"
};
assert_eq!(
&Column::try_from(&val).unwrap_err().to_string(),
": `property` property is required"
);
}
#[test]
fn test_column_invalid_property() {
let val = toml! {
label = "ID"
property = []
};
assert_eq!(
&Column::try_from(&val).unwrap_err().to_string(),
".property: not a string"
);
}
#[test]
fn test_property() {
let val = toml::Value::String("uuid".to_owned());
let prop = Property::try_from(&val).unwrap();
assert_eq!(prop, Property::Uuid);
}
#[test]
fn test_property_invalid_type() {
let val = toml::Value::Array(vec![]);
assert_eq!(
&Property::try_from(&val).unwrap_err().to_string(),
": not a string"
);
}
#[test]
fn test_sort() {
let val = toml! {
ascending = false
sort_by = "id"
};
let sort = Sort::try_from(&val).unwrap();
assert_eq!(
sort,
Sort {
ascending: false,
sort_by: SortBy::Id,
}
);
}
#[test]
fn test_sort_no_ascending() {
let val = toml! {
sort_by = "id"
};
let sort = Sort::try_from(&val).unwrap();
assert_eq!(
sort,
Sort {
ascending: true,
sort_by: SortBy::Id,
}
);
}
#[test]
fn test_sort_unknown_prop() {
let val = toml! {
sort_by = "id"
nosuch = true
};
assert_eq!(
&Sort::try_from(&val).unwrap_err().to_string(),
": unknown table key `nosuch`"
);
}
#[test]
fn test_sort_no_sort_by() {
let val = toml! {
ascending = true
};
assert_eq!(
&Sort::try_from(&val).unwrap_err().to_string(),
": `sort_by` property is required"
);
}
#[test]
fn test_sort_invalid_ascending() {
let val = toml! {
sort_by = "id"
ascending = {}
};
assert_eq!(
&Sort::try_from(&val).unwrap_err().to_string(),
".ascending: not a boolean value"
);
}
#[test]
fn test_sort_invalid_sort_by() {
let val = toml! {
sort_by = {}
};
assert_eq!(
&Sort::try_from(&val).unwrap_err().to_string(),
".sort_by: not a string"
);
}
#[test]
fn test_sort_by() {
let val = toml::Value::String("uuid".to_string());
let prop = SortBy::try_from(&val).unwrap();
assert_eq!(prop, SortBy::Uuid);
}
#[test]
fn test_sort_by_unknown() {
let val = toml::Value::String("nosuch".to_string());
assert_eq!(
&SortBy::try_from(&val).unwrap_err().to_string(),
": unknown sort_by value `nosuch`"
);
}
#[test]
fn test_sort_by_invalid_type() {
let val = toml::Value::Array(vec![]);
assert_eq!(
&SortBy::try_from(&val).unwrap_err().to_string(),
": not a string"
);
}
}

View 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
View file

@ -0,0 +1,41 @@
use anyhow::{anyhow, bail, Result};
use toml::value::Table;
/// Check that the input is a table and contains no keys not in the given list, returning
/// the table.
pub(super) fn table_with_keys<'a>(cfg: &'a toml::Value, keys: &[&str]) -> Result<&'a Table> {
let table = cfg.as_table().ok_or_else(|| anyhow!("not a table"))?;
for tk in table.keys() {
if !keys.iter().any(|k| k == tk) {
bail!("unknown table key `{}`", tk);
}
}
Ok(table)
}
#[cfg(test)]
mod test {
use super::*;
use toml::toml;
#[test]
fn test_dissect_table_missing() {
let val = toml! { bar = true };
let diss = table_with_keys(&val, &["foo", "bar"]).unwrap();
assert_eq!(diss.get("bar"), Some(&toml::Value::Boolean(true)));
assert_eq!(diss.get("foo"), None);
}
#[test]
fn test_dissect_table_extra() {
let val = toml! { nosuch = 10 };
assert!(table_with_keys(&val, &["foo", "bar"]).is_err());
}
#[test]
fn test_dissect_table_not_a_table() {
let val = toml::Value::Array(vec![]);
assert!(table_with_keys(&val, &["foo", "bar"]).is_err());
}
}

View file

@ -42,7 +42,7 @@ impl Usage {
writeln!(w, "USAGE:\n {} [args]\n", command_name)?;
writeln!(w, "TaskChampion subcommands:")?;
for subcommand in self.subcommands.iter() {
subcommand.write_help(&mut w, summary)?;
subcommand.write_help(&mut w, command_name, summary)?;
}
writeln!(w, "Filter Expressions:\n")?;
writeln!(
@ -56,7 +56,7 @@ impl Usage {
)
)?;
for filter in self.filters.iter() {
filter.write_help(&mut w, summary)?;
filter.write_help(&mut w, command_name, summary)?;
}
writeln!(w, "Modifications:\n")?;
writeln!(
@ -70,10 +70,10 @@ impl Usage {
)
)?;
for modification in self.modifications.iter() {
modification.write_help(&mut w, summary)?;
modification.write_help(&mut w, command_name, summary)?;
}
if !summary {
writeln!(w, "\nSee `task help` for more detail")?;
writeln!(w, "\nSee `{} help` for more detail", command_name)?;
}
Ok(())
}
@ -108,13 +108,14 @@ pub(crate) struct Subcommand {
}
impl Subcommand {
fn write_help<W: Write>(&self, mut w: W, summary: bool) -> Result<()> {
fn write_help<W: Write>(&self, mut w: W, command_name: &str, summary: bool) -> Result<()> {
if summary {
writeln!(w, " task {} - {}", self.name, self.summary)?;
writeln!(w, " {} {} - {}", command_name, self.name, self.summary)?;
} else {
writeln!(
w,
" task {}\n{}",
" {} {}\n{}",
command_name,
self.syntax,
indented(self.description, " ")
)?;
@ -138,7 +139,7 @@ pub(crate) struct Filter {
}
impl Filter {
fn write_help<W: Write>(&self, mut w: W, summary: bool) -> Result<()> {
fn write_help<W: Write>(&self, mut w: W, _: &str, summary: bool) -> Result<()> {
if summary {
writeln!(w, " {} - {}", self.syntax, self.summary)?;
} else {
@ -168,7 +169,7 @@ pub(crate) struct Modification {
}
impl Modification {
fn write_help<W: Write>(&self, mut w: W, summary: bool) -> Result<()> {
fn write_help<W: Write>(&self, mut w: W, _: &str, summary: bool) -> Result<()> {
if summary {
writeln!(w, " {} - {}", self.syntax, self.summary)?;
} else {

View file

@ -1,13 +1,31 @@
use assert_cmd::prelude::*;
use predicates::prelude::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
// NOTE: This tests that the task binary is running and parsing arguments. The details of
// NOTE: This tests that the `ta` binary is running and parsing arguments. The details of
// subcommands are handled with unit tests.
/// These tests force config to be read via TASKCHAMPION_CONFIG so that a user's own config file
/// (in their homedir) does not interfere with tests.
fn test_cmd(dir: &TempDir) -> Result<Command, Box<dyn std::error::Error>> {
let config_filename = dir.path().join("config.toml");
fs::write(
config_filename.clone(),
format!("data_dir = {:?}", dir.path()),
)?;
let config_filename = config_filename.to_str().unwrap();
let mut cmd = Command::cargo_bin("ta")?;
cmd.env("TASKCHAMPION_CONFIG", config_filename);
Ok(cmd)
}
#[test]
fn help() -> Result<(), Box<dyn std::error::Error>> {
let mut cmd = Command::cargo_bin("task")?;
let dir = TempDir::new().unwrap();
let mut cmd = test_cmd(&dir)?;
cmd.arg("--help");
cmd.assert()
@ -19,7 +37,8 @@ fn help() -> Result<(), Box<dyn std::error::Error>> {
#[test]
fn version() -> Result<(), Box<dyn std::error::Error>> {
let mut cmd = Command::cargo_bin("task")?;
let dir = TempDir::new().unwrap();
let mut cmd = test_cmd(&dir)?;
cmd.arg("--version");
cmd.assert()
@ -31,7 +50,8 @@ fn version() -> Result<(), Box<dyn std::error::Error>> {
#[test]
fn invalid_option() -> Result<(), Box<dyn std::error::Error>> {
let mut cmd = Command::cargo_bin("task")?;
let dir = TempDir::new().unwrap();
let mut cmd = test_cmd(&dir)?;
cmd.arg("--no-such-option");
cmd.assert()

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 KiB

BIN
docs/assets/cgi/logo/logo_128.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
docs/assets/cgi/logo/logo_16.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
docs/assets/cgi/logo/logo_256.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
docs/assets/cgi/logo/logo_32.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
docs/assets/cgi/logo/logo_512.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

BIN
docs/assets/cgi/logo/logo_64.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View file

@ -1,20 +1,20 @@
# Summary
- [Welcome to TaskChampion](./welcome.md)
- [Installation](./installation.md)
* [Installation](./installation.md)
* [Using the Task Command](./using-task-command.md)
* [Configuration](./config-file.md)
* [Reports](./reports.md)
* [Tags](./tags.md)
* [Environment](./environment.md)
* [Synchronization](./task-sync.md)
* [Running the Sync Server](./running-sync-server.md)
* [Debugging](./debugging.md)
- [Internal Details](./internals.md)
- [Data Model](./data-model.md)
- [Replica Storage](./storage.md)
- [Task Database](./taskdb.md)
- [Tasks](./tasks.md)
- [Synchronization and the Sync Server](./sync.md)
- [Synchronization Model](./sync-model.md)
- [Server-Replica Protocol](./sync-protocol.md)
- [Planned Functionality](./plans.md)
* [Data Model](./data-model.md)
* [Replica Storage](./storage.md)
* [Task Database](./taskdb.md)
* [Tasks](./tasks.md)
* [Synchronization and the Sync Server](./sync.md)
* [Synchronization Model](./sync-model.md)
* [Server-Replica Protocol](./sync-protocol.md)
* [Planned Functionality](./plans.md)

View file

@ -1,15 +1,19 @@
# Configuration
The `task` command will work out-of-the-box with no configuration file, using default values.
The `ta` command will work out-of-the-box with no configuration file, using default values.
Configuration is read from `taskchampion.yaml` in your config directory.
Configuration is read from `taskchampion.toml` in your config directory.
On Linux systems, that directory is `~/.config`.
On OS X, it's `~/Library/Preferences`.
On Windows, it's `AppData/Roaming` in your home directory.
The path can be overridden by setting `$TASKCHAMPION_CONFIG`.
This can be overridden by setting `TASKCHAMPION_CONFIG` to the configuration filename.
Individual configuration parameters can be overridden by environment variables, converted to upper-case and prefixed with `TASKCHAMPION_`, e.g., `TASKCHAMPION_DATA_DIR`.
Nested configuration parameters such as `reports` cannot be overridden by environment variables.
The file format is [TOML](https://toml.io/).
For example:
```toml
data_dir = "/home/myuser/.tasks"
```
## Directories
@ -36,7 +40,15 @@ If using a remote server:
* `server_client_key` - Client key to identify this replica to the sync server (a UUID)
If not set, then sync is done to a local server.
# Reports
## Reports
* `reports` - a mapping of each report's name to its definition.
See [Reports](./reports.md) for details.
## Editing
As a shortcut, the simple, top-level configuration values can be edited from the command line:
```shell
ta config set data_dir /home/myuser/.taskchampion
```

View file

@ -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
View 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.

View file

@ -10,71 +10,70 @@ TaskChampion includes several "built-in" reports, as well as supporting custom r
The `next` report is the default, and lists all pending tasks:
```text
$ task
$ ta
Id Description Active Tags
1 learn about TaskChampion +next
2 buy wedding gift * +buy
3 plant tomatoes +garden
```
The `Id` column contains short numeric IDs that are assigned to pending tasks.
These IDs are easy to type, such as to mark task 2 done (`task 2 done`).
These IDs are easy to type, such as to mark task 2 done (`ta 2 done`).
The `list` report lists all tasks, with a similar set of columns.
## Custom Reports
Custom reports are defined in the configuration file's `reports` property.
Custom reports are defined in the configuration file's `reports` table.
This is a mapping from each report's name to its definition.
Each definition has the following properties:
* `filter` - criteria for the tasks to include in the report
* `sort` - how to order the tasks
* `filter` - criteria for the tasks to include in the report (optional)
* `sort` - how to order the tasks (optional)
* `columns` - the columns of information to display for each task
For example:
```toml
[reports.garden]
sort = [
{ sort_by = "description" }
]
filter = [
"status:pending",
"+garden"
]
columns = [
{ label = "ID", property = "id" },
{ label = "Description", property = "description" },
]
```
The filter is a list of filter arguments, just like those that can be used on the command line.
See the `task help` output for more details on this syntax.
For example:
See the `ta help` output for more details on this syntax.
It will be merged with any filters provided on the command line, when the report is invoked.
```yaml
reports:
garden:
filter:
- "status:pending"
- "+garden"
```
The sort order is defined by an array of objects containing a `sort_by` property and an optional `ascending` property.
The sort order is defined by an array of tables containing a `sort_by` property and an optional `ascending` property.
Tasks are compared by the first criterion, and if that is equal by the second, and so on.
For example:
```yaml
reports:
garden:
sort:
- sort_by: description
- sort_by: uuid
ascending: false
```
If `ascending` is given, it can be `true` for the default sort order, or `false` for the reverse.
In most cases tasks are just sorted by one criterion, but a more advanced example might look like:
```toml
[reports.garden]
sort = [
{ sort_by = "description" }
{ sort_by = "uuid", ascending = false }
]
...
```
The available values of `sort_by` are
(TODO: generate automatically)
Finally, the configuration specifies the list of columns to display in the `columns` property.
Each element has a `label` and a `property`:
```yaml
reports:
garden:
columns:
- label: Id
property: id
- label: Description
property: description
- label: Tags
property: tags
```
Finally, the `columns` configuration specifies the list of columns to display.
Each element has a `label` and a `property`, as shown in the example above.
The avaliable properties are:

View file

@ -125,4 +125,4 @@ Without synchronization, its list of pending operations would grow indefinitely,
So all replicas, even "singleton" replicas which do not replicate task data with any other replica, must synchronize periodically.
TaskChampion provides a `LocalServer` for this purpose.
It implements the `get_child_version` and `add_version` operations as described, storing data on-disk locally, all within the `task` binary.
It implements the `get_child_version` and `add_version` operations as described, storing data on-disk locally, all within the `ta` binary.

View file

@ -73,7 +73,7 @@ This value is passed with every request in the `X-Client-Id` header, in its dash
### AddVersion
The request is a `POST` to `<origin>/client/add-version/<parentVersionId>`.
The request is a `POST` to `<origin>/v1/client/add-version/<parentVersionId>`.
The request body contains the history segment, optionally encoded using any encoding supported by actix-web.
The content-type must be `application/vnd.taskchampion.history-segment`.
@ -87,7 +87,7 @@ Other error responses (4xx or 5xx) may be returned and should be treated appropr
### GetChildVersion
The request is a `GET` to `<origin>/client/get-child-version/<parentVersionId>`.
The request is a `GET` to `<origin>/v1/client/get-child-version/<parentVersionId>`.
The response is 404 NOT FOUND if no such version exists.
Otherwise, the response is a 200 OK.
The version's history segment is returned in the response body, with content-type `application/vnd.taskchampion.history-segment`.

View file

@ -4,7 +4,7 @@ Each task has a collection of associated tags.
Tags are short words that categorize tasks, typically written with a leading `+`, such as `+next` or `+jobsearch`.
Tags are useful for filtering tasks in reports or on the command line.
For example, when it's time to continue the job search, `task +jobsearch` will show pending tasks with the `jobsearch` tag.
For example, when it's time to continue the job search, `ta +jobsearch` will show pending tasks with the `jobsearch` tag.
## Allowed Tags

View file

@ -4,20 +4,46 @@ A single TaskChampion task database is known as a "replica".
A replica "synchronizes" its local information with other replicas via a sync server.
Many replicas can thus share the same task history.
This operation is triggered by running `task sync`.
This operation is triggered by running `ta sync`.
Typically this runs frequently in a cron task.
Synchronization is quick, especially if no changes have occurred.
Each replica expects to be synchronized frequently, even if no server is involved.
Without periodic syncs, the storage space used for the task database will grow quickly, and performance will suffer.
## Local Sync
By default, TaskChampion syncs to a "local server", as specified by the `server_dir` configuration parameter.
This defaults to `taskchampion-sync-server` in your [data directory](https://docs.rs/dirs-next/2.0.0/dirs_next/fn.data_dir.html), but can be customized in the configuration file.
## Remote Sync
For remote synchronization, you will need a few pieces of information.
From the server operator, you will need an origin and a client key.
Configure these with
```shell
ta config set server_origin "<origin from server operator>"
ta config set server_client_key "<client key from server operator>"
```
You will need to generate your own encryption secret.
This is used to encrypt your task history, so treat it as a password.
The following will use the `openssl` utility to generate a suitable value:
```shell
ta config set encryption_secret $(openssl rand -hex 35)
```
Every replica sharing a task history should have precisely the same configuration for `server_origin`, `server_client_key`, and `encryption_secret`.
Synchronizing a new replica to an existing task history is easy: begin with an empty replica, configured for the remote server, and run `task sync`.
### Adding a New Replica
Synchronizing a new replica to an existing task history is easy: begin with an empty replica, configured for the remote server, and run `ta sync`.
The replica will download the entire task history.
It is possible to switch a single replica to a remote server by simply configuring for the remote server and running `task sync`.
### Upgrading a Locally-Sync'd Replica
It is possible to switch a single replica to a remote server by simply configuring for the remote server and running `ta sync`.
The replica will upload the entire task history to the server.
Once this is complete, additional replicas can be configured with the same settings in order to share the task history.

View file

@ -1,9 +1,9 @@
# Using the Task Command
The main interface to your tasks is the `task` command, which supports various subcommands such as `add`, `modify`, `start`, and `done`.
The main interface to your tasks is the `ta` command, which supports various subcommands such as `add`, `modify`, `start`, and `done`.
Customizable [reports](./reports.md) are also available as subcommands, such as `next`.
The command reads a [configuration file](./config-file.md) for its settings, including where to find the task database.
And the `sync` subcommand [synchronizes tasks with a sync server](./task-sync.md).
You can find a list of all subcommands, as well as the built-in reports, with `task help`.
You can find a list of all subcommands, as well as the built-in reports, with `ta help`.
> NOTE: the `task` interface does not precisely match that of TaskWarrior.

View file

@ -1,7 +1,7 @@
# TaskChampion
TaskChampion is a personal task-tracking tool.
It works from the command line, with simple commands like `task add "fix the kitchen sink"`.
It works from the command line, with simple commands like `ta add "fix the kitchen sink"`.
It can synchronize tasks on multiple devices, and does so in an "offline" mode so you can update your tasks even when you can't reach the server.
If you've heard of [TaskWarrior](https://taskwarrior.org/), this tool is very similar, but with some different design choices and greater reliability.
@ -10,18 +10,18 @@ If you've heard of [TaskWarrior](https://taskwarrior.org/), this tool is very si
> NOTE: TaskChampion is still in development and not yet feature-complete.
> This section is limited to completed functionality.
Once you've [installed TaskChampion](./installation.md), your interface will be via the `task` command.
Once you've [installed TaskChampion](./installation.md), your interface will be via the `ta` command.
Start by adding a task:
```shell
$ task add learn how to use taskchampion
$ ta add learn how to use taskchampion
added task ba57deaf-f97b-4e9c-b9ab-04bc1ecb22b8
```
You can see all of your pending tasks with `task next`, or just `task` for short:
You can see all of your pending tasks with `ta next`, or just `ta` for short:
```shell
$ task
$ ta
Id Description Active Tags
1 learn how to use taskchampion
```
@ -29,13 +29,13 @@ $ task
Tell TaskChampion you're working on the task, using the shorthand id:
```shell
$ task start 1
$ ta start 1
```
and when you're done with the task, mark it as complete:
```shell
$ task done 1
$ ta done 1
```
## Synchronizing
@ -44,7 +44,7 @@ Even if you don't have a server, it's a good idea to sync your task database per
This acts as a backup and also enables some internal house-cleaning.
```shell
$ task sync
$ ta sync
```
Typically sync is run from a crontab, on whatever schedule fits your needs.
@ -57,7 +57,7 @@ server_client_key: "f8d4d09d-f6c7-4dd2-ab50-634ed20a3ff2"
server_origin: "https://taskchampion.example.com"
```
The next run of `task sync` will upload your task history to that server.
Configuring another device identically and running `task sync` will download that task history, and continue to stay in sync with subsequent runs of the command.
The next run of `ta sync` will upload your task history to that server.
Configuring another device identically and running `ta sync` will download that task history, and continue to stay in sync with subsequent runs of the command.
See [Usage](./usage.md) for more detailed information on using TaskChampion.
See [Usage](./using-task-command.md) for more detailed information on using TaskChampion.

View file

@ -19,7 +19,7 @@ const MAX_SIZE: usize = 100 * 1024 * 1024;
/// parent version ID in the `X-Parent-Version-Id` header.
///
/// Returns other 4xx or 5xx responses on other errors.
#[post("/client/add-version/{parent_version_id}")]
#[post("/v1/client/add-version/{parent_version_id}")]
pub(crate) async fn service(
req: HttpRequest,
server_state: web::Data<ServerState>,
@ -99,7 +99,7 @@ mod test {
let server_state = ServerState::new(server_box);
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
let uri = format!("/client/add-version/{}", parent_version_id);
let uri = format!("/v1/client/add-version/{}", parent_version_id);
let req = test::TestRequest::post()
.uri(&uri)
.header(
@ -136,7 +136,7 @@ mod test {
let server_state = ServerState::new(server_box);
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
let uri = format!("/client/add-version/{}", parent_version_id);
let uri = format!("/v1/client/add-version/{}", parent_version_id);
let req = test::TestRequest::post()
.uri(&uri)
.header(
@ -163,7 +163,7 @@ mod test {
let server_state = ServerState::new(server_box);
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
let uri = format!("/client/add-version/{}", parent_version_id);
let uri = format!("/v1/client/add-version/{}", parent_version_id);
let req = test::TestRequest::post()
.uri(&uri)
.header("Content-Type", "not/correct")
@ -182,7 +182,7 @@ mod test {
let server_state = ServerState::new(server_box);
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
let uri = format!("/client/add-version/{}", parent_version_id);
let uri = format!("/v1/client/add-version/{}", parent_version_id);
let req = test::TestRequest::post()
.uri(&uri)
.header(

View file

@ -13,7 +13,7 @@ use actix_web::{error, get, web, HttpRequest, HttpResponse, Result};
///
/// If no such child exists, returns a 404 with no content.
/// Returns other 4xx or 5xx responses on other errors.
#[get("/client/get-child-version/{parent_version_id}")]
#[get("/v1/client/get-child-version/{parent_version_id}")]
pub(crate) async fn service(
req: HttpRequest,
server_state: web::Data<ServerState>,
@ -68,7 +68,7 @@ mod test {
let server_state = ServerState::new(server_box);
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
let uri = format!("/client/get-child-version/{}", parent_version_id);
let uri = format!("/v1/client/get-child-version/{}", parent_version_id);
let req = test::TestRequest::get()
.uri(&uri)
.header("X-Client-Key", client_key.to_string())
@ -101,7 +101,7 @@ mod test {
let server_state = ServerState::new(server_box);
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
let uri = format!("/client/get-child-version/{}", parent_version_id);
let uri = format!("/v1/client/get-child-version/{}", parent_version_id);
let req = test::TestRequest::get()
.uri(&uri)
.header("X-Client-Key", client_key.to_string())
@ -126,7 +126,7 @@ mod test {
let server_state = ServerState::new(server_box);
let mut app = test::init_service(App::new().service(app_scope(server_state))).await;
let uri = format!("/client/get-child-version/{}", parent_version_id);
let uri = format!("/v1/client/get-child-version/{}", parent_version_id);
let req = test::TestRequest::get()
.uri(&uri)
.header("X-Client-Key", client_key.to_string())

View file

@ -4,6 +4,7 @@ use crate::storage::{Operation, Storage, TaskMap};
use crate::task::{Status, Task};
use crate::taskdb::TaskDb;
use crate::workingset::WorkingSet;
use anyhow::Context;
use chrono::Utc;
use log::trace;
use std::collections::HashMap;
@ -123,8 +124,10 @@ impl Replica {
/// this occurs, but without renumbering, so any newly-pending tasks should appear in
/// the working set.
pub fn sync(&mut self, server: &mut Box<dyn Server>) -> anyhow::Result<()> {
self.taskdb.sync(server)?;
self.taskdb.sync(server).context("Failed to synchronize")?;
self.rebuild_working_set(false)
.context("Failed to rebuild working set after sync")?;
Ok(())
}
/// Rebuild this replica's working set, based on whether tasks are pending or not. If

View file

@ -49,7 +49,10 @@ impl Server for RemoteServer {
parent_version_id: VersionId,
history_segment: HistorySegment,
) -> anyhow::Result<AddVersionResult> {
let url = format!("{}/client/add-version/{}", self.origin, parent_version_id);
let url = format!(
"{}/v1/client/add-version/{}",
self.origin, parent_version_id
);
let history_cleartext = HistoryCleartext {
parent_version_id,
history_segment,
@ -82,7 +85,7 @@ impl Server for RemoteServer {
parent_version_id: VersionId,
) -> anyhow::Result<GetVersionResult> {
let url = format!(
"{}/client/get-child-version/{}",
"{}/v1/client/get-child-version/{}",
self.origin, parent_version_id
);
match self

View file

@ -117,14 +117,14 @@ impl TaskDb {
{
let mut txn = self.storage.txn()?;
let mut new_ws = vec![];
let mut new_ws = vec![None]; // index 0 is always None
let mut seen = HashSet::new();
// The goal here is for existing working-set items to be "compressed' down to index 1, so
// we begin by scanning the current working set and inserting any tasks that should still
// be in the set into new_ws, implicitly dropping any tasks that are no longer in the
// working set.
for elt in txn.get_working_set()? {
for elt in txn.get_working_set()?.drain(1..) {
if let Some(uuid) = elt {
if let Some(task) = txn.get_task(uuid)? {
if in_working_set(&task) {
@ -144,14 +144,12 @@ impl TaskDb {
// if renumbering, clear the working set and re-add
if renumber {
txn.clear_working_set()?;
for elt in new_ws.drain(0..new_ws.len()) {
if let Some(uuid) = elt {
txn.add_to_working_set(uuid)?;
}
for elt in new_ws.drain(1..new_ws.len()).flatten() {
txn.add_to_working_set(elt)?;
}
} else {
// ..otherwise, just clear the None items determined above from the working set
for (i, elt) in new_ws.iter().enumerate() {
for (i, elt) in new_ws.iter().enumerate().skip(1) {
if elt.is_none() {
txn.set_working_set_item(i, None)?;
}

View file

@ -21,6 +21,10 @@ impl WorkingSet {
/// Create a new WorkingSet. Typically this is acquired via `replica.working_set()`
pub(crate) fn new(by_index: Vec<Option<Uuid>>) -> Self {
let mut by_uuid = HashMap::new();
// working sets are 1-indexed, so element 0 should always be None
assert!(by_index.is_empty() || by_index[0].is_none());
for (index, uuid) in by_index.iter().enumerate() {
if let Some(uuid) = uuid {
by_uuid.insert(*uuid, index);
@ -58,13 +62,7 @@ impl WorkingSet {
self.by_index
.iter()
.enumerate()
.filter_map(|(index, uuid)| {
if let Some(uuid) = uuid {
Some((index, *uuid))
} else {
None
}
})
.filter_map(|(index, uuid)| uuid.as_ref().map(|uuid| (index, *uuid)))
}
}