From 0852bfd195f3b916f9cb39cba409cf7b4ba675d3 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sat, 1 May 2021 12:49:49 -0400 Subject: [PATCH 01/34] Require a filter be specified for modifications This filter can either be `*` or some "real" filter. But an empty set of arguments no longer automatically matches all tasks. --- cli/src/argparse/filter.rs | 109 ++++++++++++++++++++++++++++----- cli/src/argparse/subcommand.rs | 34 +++++----- 2 files changed, 109 insertions(+), 34 deletions(-) diff --git a/cli/src/argparse/filter.rs b/cli/src/argparse/filter.rs index 49273999b..be63aa1af 100644 --- a/cli/src/argparse/filter.rs +++ b/cli/src/argparse/filter.rs @@ -1,8 +1,13 @@ -use super::args::{arg_matching, id_list, minus_tag, plus_tag, status_colon, TaskId}; +use super::args::{arg_matching, id_list, literal, minus_tag, plus_tag, status_colon, TaskId}; use super::ArgList; use crate::usage; use anyhow::bail; -use nom::{branch::alt, combinator::*, multi::fold_many0, IResult}; +use nom::{ + branch::alt, + combinator::*, + multi::{fold_many0, fold_many1}, + IResult, +}; use taskchampion::Status; /// A filter represents a selection of a particular set of tasks. @@ -85,7 +90,9 @@ impl Condition { } impl Filter { - pub(super) fn parse(input: ArgList) -> IResult { + /// Parse a filter that can include an empty set of args (meaning + /// all tasks) + pub(super) fn parse0(input: ArgList) -> IResult { fold_many0( Condition::parse, Filter { @@ -95,6 +102,30 @@ impl Filter { )(input) } + /// Parse a filter that must have at least one arg, which can be `all` + /// to mean all tasks + pub(super) fn parse1(input: ArgList) -> IResult { + alt(( + Filter::parse_all, + fold_many1( + Condition::parse, + Filter { + ..Default::default() + }, + |acc, arg| acc.with_arg(arg), + ), + ))(input) + } + + fn parse_all(input: ArgList) -> IResult { + fn to_filter(_: &str) -> Result { + Ok(Filter { + ..Default::default() + }) + } + map_res(arg_matching(literal("all")), to_filter)(input) + } + /// fold multiple filter args into a single Filter instance fn with_arg(mut self, cond: Condition) -> Filter { if let Condition::IdList(mut id_list) = cond { @@ -157,6 +188,13 @@ impl Filter { description: " Select tasks with the given status.", }); + u.filters.push(usage::Filter { + syntax: "all", + summary: "All tasks", + description: " + When specified alone for task-modification commands, `all` matches all tasks. + For example, `task all done` will mark all tasks as done.", + }); } } @@ -165,8 +203,8 @@ mod test { use super::*; #[test] - fn test_empty() { - let (input, filter) = Filter::parse(argv![]).unwrap(); + fn test_empty_parse0() { + let (input, filter) = Filter::parse0(argv![]).unwrap(); assert_eq!(input.len(), 0); assert_eq!( filter, @@ -176,9 +214,46 @@ mod test { ); } + #[test] + fn test_empty_parse1() { + // parse1 does not allow empty input + assert!(Filter::parse1(argv![]).is_err()); + } + + #[test] + fn test_all_parse0() { + let (input, _) = Filter::parse0(argv!["all"]).unwrap(); + assert_eq!(input.len(), 1); // did not parse "all" + } + + #[test] + fn test_all_parse1() { + let (input, filter) = Filter::parse1(argv!["all"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + ..Default::default() + } + ); + } + + #[test] + fn test_all_with_other_stuff() { + let (input, filter) = Filter::parse1(argv!["all", "+foo"]).unwrap(); + // filter ends after `all` + assert_eq!(input.len(), 1); + assert_eq!( + filter, + Filter { + ..Default::default() + } + ); + } + #[test] fn test_id_list_single() { - let (input, filter) = Filter::parse(argv!["1"]).unwrap(); + let (input, filter) = Filter::parse0(argv!["1"]).unwrap(); assert_eq!(input.len(), 0); assert_eq!( filter, @@ -190,7 +265,7 @@ mod test { #[test] fn test_id_list_commas() { - let (input, filter) = Filter::parse(argv!["1,2,3"]).unwrap(); + let (input, filter) = Filter::parse0(argv!["1,2,3"]).unwrap(); assert_eq!(input.len(), 0); assert_eq!( filter, @@ -206,7 +281,7 @@ mod test { #[test] fn test_id_list_multi_arg() { - let (input, filter) = Filter::parse(argv!["1,2", "3,4"]).unwrap(); + let (input, filter) = Filter::parse0(argv!["1,2", "3,4"]).unwrap(); assert_eq!(input.len(), 0); assert_eq!( filter, @@ -223,7 +298,7 @@ mod test { #[test] fn test_id_list_uuids() { - let (input, filter) = Filter::parse(argv!["1,abcd1234"]).unwrap(); + let (input, filter) = Filter::parse0(argv!["1,abcd1234"]).unwrap(); assert_eq!(input.len(), 0); assert_eq!( filter, @@ -238,7 +313,7 @@ mod test { #[test] fn test_tags() { - let (input, filter) = Filter::parse(argv!["1", "+yes", "-no"]).unwrap(); + let (input, filter) = Filter::parse0(argv!["1", "+yes", "-no"]).unwrap(); assert_eq!(input.len(), 0); assert_eq!( filter, @@ -254,7 +329,7 @@ mod test { #[test] fn test_status() { - let (input, filter) = Filter::parse(argv!["status:completed", "status:pending"]).unwrap(); + let (input, filter) = Filter::parse0(argv!["status:completed", "status:pending"]).unwrap(); assert_eq!(input.len(), 0); assert_eq!( filter, @@ -269,8 +344,8 @@ mod test { #[test] fn intersect_idlist_idlist() { - let left = Filter::parse(argv!["1,2", "+yes"]).unwrap().1; - let right = Filter::parse(argv!["2,3", "+no"]).unwrap().1; + let left = Filter::parse0(argv!["1,2", "+yes"]).unwrap().1; + let right = Filter::parse0(argv!["2,3", "+no"]).unwrap().1; let both = left.intersect(right); assert_eq!( both, @@ -289,8 +364,8 @@ mod test { #[test] fn intersect_idlist_alltasks() { - let left = Filter::parse(argv!["1,2", "+yes"]).unwrap().1; - let right = Filter::parse(argv!["+no"]).unwrap().1; + let left = Filter::parse0(argv!["1,2", "+yes"]).unwrap().1; + let right = Filter::parse0(argv!["+no"]).unwrap().1; let both = left.intersect(right); assert_eq!( both, @@ -308,8 +383,8 @@ mod test { #[test] fn intersect_alltasks_alltasks() { - let left = Filter::parse(argv!["+yes"]).unwrap().1; - let right = Filter::parse(argv!["+no"]).unwrap().1; + let left = Filter::parse0(argv!["+yes"]).unwrap().1; + let right = Filter::parse0(argv!["+no"]).unwrap().1; let both = left.intersect(right); assert_eq!( both, diff --git a/cli/src/argparse/subcommand.rs b/cli/src/argparse/subcommand.rs index 5609192b7..53d67556f 100644 --- a/cli/src/argparse/subcommand.rs +++ b/cli/src/argparse/subcommand.rs @@ -217,7 +217,7 @@ impl Modify { } map_res( tuple(( - Filter::parse, + Filter::parse1, alt(( arg_matching(literal("modify")), arg_matching(literal("prepend")), @@ -235,47 +235,47 @@ impl Modify { fn get_usage(u: &mut usage::Usage) { u.subcommands.push(usage::Subcommand { name: "modify", - syntax: "[filter] modify [modification]", + syntax: " modify [modification]", summary: "Modify tasks", description: " - Modify all tasks matching the filter.", + Modify all tasks matching the required filter.", }); u.subcommands.push(usage::Subcommand { name: "prepend", - syntax: "[filter] prepend [modification]", + syntax: " prepend [modification]", summary: "Prepend task description", description: " - Modify all tasks matching the filter by inserting the given description before each + Modify all tasks matching the required filter by inserting the given description before each task's description.", }); u.subcommands.push(usage::Subcommand { name: "append", - syntax: "[filter] append [modification]", + syntax: " append [modification]", summary: "Append task description", description: " - Modify all tasks matching the filter by adding the given description to the end + Modify all tasks matching the required filter by adding the given description to the end of each task's description.", }); u.subcommands.push(usage::Subcommand { name: "start", - syntax: "[filter] start [modification]", + syntax: " start [modification]", summary: "Start tasks", description: " - Start all tasks matching the filter, additionally applying any given modifications." + Start all tasks matching the required filter, additionally applying any given modifications." }); u.subcommands.push(usage::Subcommand { name: "stop", - syntax: "[filter] stop [modification]", + syntax: " stop [modification]", summary: "Stop tasks", description: " - Stop all tasks matching the filter, additionally applying any given modifications.", + Stop all tasks matching the required filter, additionally applying any given modifications.", }); u.subcommands.push(usage::Subcommand { name: "done", - syntax: "[filter] done [modification]", + syntax: " done [modification]", summary: "Mark tasks as completed", description: " - Mark all tasks matching the filter as completed, additionally applying any given + Mark all tasks matching the required filter as completed, additionally applying any given modifications.", }); } @@ -293,14 +293,14 @@ impl Report { } // allow the filter expression before or after the report name alt(( - map_res(pair(arg_matching(report_name), Filter::parse), |input| { + map_res(pair(arg_matching(report_name), Filter::parse0), |input| { to_subcommand(input.1, input.0) }), - map_res(pair(Filter::parse, arg_matching(report_name)), |input| { + map_res(pair(Filter::parse0, arg_matching(report_name)), |input| { to_subcommand(input.0, input.1) }), // default to a "next" report - map_res(Filter::parse, |input| to_subcommand(input, "next")), + map_res(Filter::parse0, |input| to_subcommand(input, "next")), ))(input) } @@ -335,7 +335,7 @@ impl Info { } map_res( pair( - Filter::parse, + Filter::parse1, alt(( arg_matching(literal("info")), arg_matching(literal("debug")), From bb7130f96041e5c22e4559374dd5fdb9a6cd3190 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Mon, 3 May 2021 17:57:04 -0400 Subject: [PATCH 02/34] Support multiple exit codes ..with more specific error enums. --- Cargo.lock | 1 + POLICY.md | 10 +++--- cli/Cargo.toml | 1 + cli/src/argparse/command.rs | 18 +++++++--- cli/src/bin/ta.rs | 9 +++-- cli/src/errors.rs | 59 +++++++++++++++++++++++++++++++ cli/src/invocation/cmd/add.rs | 2 +- cli/src/invocation/cmd/config.rs | 2 +- cli/src/invocation/cmd/gc.rs | 2 +- cli/src/invocation/cmd/help.rs | 2 +- cli/src/invocation/cmd/info.rs | 2 +- cli/src/invocation/cmd/modify.rs | 2 +- cli/src/invocation/cmd/report.rs | 2 +- cli/src/invocation/cmd/sync.rs | 2 +- cli/src/invocation/cmd/version.rs | 2 +- cli/src/invocation/mod.rs | 2 +- cli/src/invocation/report.rs | 2 +- cli/src/lib.rs | 7 ++-- cli/tests/cli.rs | 3 +- taskchampion/src/errors.rs | 7 ++-- taskchampion/src/lib.rs | 1 + taskchampion/src/replica.rs | 2 +- taskchampion/src/taskdb.rs | 6 ++-- 23 files changed, 112 insertions(+), 34 deletions(-) create mode 100644 cli/src/errors.rs diff --git a/Cargo.lock b/Cargo.lock index 8a50e2d2a..6853f373a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2176,6 +2176,7 @@ dependencies = [ "tempfile", "termcolor", "textwrap 0.13.4", + "thiserror", "toml", "toml_edit", ] diff --git a/POLICY.md b/POLICY.md index 9f9af590b..fb673155d 100644 --- a/POLICY.md +++ b/POLICY.md @@ -35,12 +35,10 @@ 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. +- `0` - No errors, normal exit. +- `1` - Generic error. +- `2` - Never used to avoid conflicts with Bash. +- `3` - Command-line Syntax Error. # Security diff --git a/cli/Cargo.toml b/cli/Cargo.toml index d66cc63b9..a3a5ad20c 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -8,6 +8,7 @@ version = "0.3.0" dirs-next = "^2.0.0" env_logger = "^0.8.3" anyhow = "1.0" +thiserror = "1.0" log = "^0.4.14" nom = "^6.1.2" prettytable-rs = "^0.8.0" diff --git a/cli/src/argparse/command.rs b/cli/src/argparse/command.rs index 79a715ec7..891a4525f 100644 --- a/cli/src/argparse/command.rs +++ b/cli/src/argparse/command.rs @@ -1,6 +1,5 @@ use super::args::*; use super::{ArgList, Subcommand}; -use anyhow::bail; use nom::{combinator::*, sequence::*, Err, IResult}; /// A command is the overall command that the CLI should execute. @@ -29,13 +28,22 @@ impl Command { } /// Parse a command from the given list of strings. - pub fn from_argv(argv: &[&str]) -> anyhow::Result { + pub fn from_argv(argv: &[&str]) -> Result { match Command::parse(argv) { Ok((&[], cmd)) => Ok(cmd), - Ok((trailing, _)) => bail!("command line has trailing arguments: {:?}", trailing), + Ok((trailing, _)) => Err(crate::Error::for_arguments(format!( + "command line has trailing arguments: {:?}", + trailing + ))), Err(Err::Incomplete(_)) => unreachable!(), - Err(Err::Error(e)) => bail!("command line not recognized: {:?}", e), - Err(Err::Failure(e)) => bail!("command line not recognized: {:?}", e), + Err(Err::Error(e)) => Err(crate::Error::for_arguments(format!( + "command line not recognized: {:?}", + e + ))), + Err(Err::Failure(e)) => Err(crate::Error::for_arguments(format!( + "command line not recognized: {:?}", + e + ))), } } } diff --git a/cli/src/bin/ta.rs b/cli/src/bin/ta.rs index ecf529be3..efdee99da 100644 --- a/cli/src/bin/ta.rs +++ b/cli/src/bin/ta.rs @@ -1,8 +1,11 @@ use std::process::exit; pub fn main() { - if let Err(err) = taskchampion_cli::main() { - eprintln!("{:?}", err); - exit(1); + match taskchampion_cli::main() { + Ok(_) => exit(0), + Err(e) => { + eprintln!("{:?}", e); + exit(e.exit_status()); + } } } diff --git a/cli/src/errors.rs b/cli/src/errors.rs new file mode 100644 index 000000000..16ac96285 --- /dev/null +++ b/cli/src/errors.rs @@ -0,0 +1,59 @@ +use taskchampion::Error as TcError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("Command-Line Syntax Error: {0}")] + Arguments(String), + + #[error(transparent)] + TaskChampion(#[from] TcError), + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl Error { + /// Construct a new command-line argument error + pub(crate) fn for_arguments(msg: S) -> Self { + Error::Arguments(msg.to_string()) + } + + /// Determine the exit status for this error, as documented. + pub fn exit_status(&self) -> i32 { + match *self { + Error::Arguments(_) => 3, + _ => 1, + } + } +} + +impl From for Error { + fn from(err: std::io::Error) -> Self { + let err: anyhow::Error = err.into(); + Error::Other(err) + } +} + +#[cfg(test)] +mod test { + use super::*; + use anyhow::anyhow; + + #[test] + fn test_exit_status() { + let mut err: Error; + + err = anyhow!("uhoh").into(); + assert_eq!(err.exit_status(), 1); + + err = Error::Arguments("uhoh".to_string()); + assert_eq!(err.exit_status(), 3); + + err = std::io::Error::last_os_error().into(); + assert_eq!(err.exit_status(), 1); + + err = TcError::Database("uhoh".to_string()).into(); + assert_eq!(err.exit_status(), 1); + } +} diff --git a/cli/src/invocation/cmd/add.rs b/cli/src/invocation/cmd/add.rs index 8a4456282..abee1bf4d 100644 --- a/cli/src/invocation/cmd/add.rs +++ b/cli/src/invocation/cmd/add.rs @@ -6,7 +6,7 @@ pub(crate) fn execute( w: &mut W, replica: &mut Replica, modification: Modification, -) -> anyhow::Result<()> { +) -> Result<(), crate::Error> { let description = match modification.description { DescriptionMod::Set(ref s) => s.clone(), _ => "(no description)".to_owned(), diff --git a/cli/src/invocation/cmd/config.rs b/cli/src/invocation/cmd/config.rs index 0f34defae..2b57aa52d 100644 --- a/cli/src/invocation/cmd/config.rs +++ b/cli/src/invocation/cmd/config.rs @@ -6,7 +6,7 @@ pub(crate) fn execute( w: &mut W, config_operation: ConfigOperation, settings: &Settings, -) -> anyhow::Result<()> { +) -> Result<(), crate::Error> { match config_operation { ConfigOperation::Set(key, value) => { let filename = settings.set(&key, &value)?; diff --git a/cli/src/invocation/cmd/gc.rs b/cli/src/invocation/cmd/gc.rs index 775b3096f..9b14b9fbb 100644 --- a/cli/src/invocation/cmd/gc.rs +++ b/cli/src/invocation/cmd/gc.rs @@ -1,7 +1,7 @@ use taskchampion::Replica; use termcolor::WriteColor; -pub(crate) fn execute(w: &mut W, replica: &mut Replica) -> anyhow::Result<()> { +pub(crate) fn execute(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> { log::debug!("rebuilding working set"); replica.rebuild_working_set(true)?; writeln!(w, "garbage collected.")?; diff --git a/cli/src/invocation/cmd/help.rs b/cli/src/invocation/cmd/help.rs index 421c140a7..2f81a08f8 100644 --- a/cli/src/invocation/cmd/help.rs +++ b/cli/src/invocation/cmd/help.rs @@ -5,7 +5,7 @@ pub(crate) fn execute( w: &mut W, command_name: String, summary: bool, -) -> anyhow::Result<()> { +) -> Result<(), crate::Error> { let usage = Usage::new(); usage.write_help(w, command_name.as_ref(), summary)?; Ok(()) diff --git a/cli/src/invocation/cmd/info.rs b/cli/src/invocation/cmd/info.rs index 1f90b79f3..5d26213d1 100644 --- a/cli/src/invocation/cmd/info.rs +++ b/cli/src/invocation/cmd/info.rs @@ -10,7 +10,7 @@ pub(crate) fn execute( replica: &mut Replica, filter: Filter, debug: bool, -) -> anyhow::Result<()> { +) -> Result<(), crate::Error> { let working_set = replica.working_set()?; for task in filtered_tasks(replica, &filter)? { diff --git a/cli/src/invocation/cmd/modify.rs b/cli/src/invocation/cmd/modify.rs index 06b4ccc0e..6f17f0dba 100644 --- a/cli/src/invocation/cmd/modify.rs +++ b/cli/src/invocation/cmd/modify.rs @@ -8,7 +8,7 @@ pub(crate) fn execute( replica: &mut Replica, filter: Filter, modification: Modification, -) -> anyhow::Result<()> { +) -> Result<(), crate::Error> { for task in filtered_tasks(replica, &filter)? { let mut task = task.into_mut(replica); diff --git a/cli/src/invocation/cmd/report.rs b/cli/src/invocation/cmd/report.rs index 7123f0353..ab079af28 100644 --- a/cli/src/invocation/cmd/report.rs +++ b/cli/src/invocation/cmd/report.rs @@ -10,7 +10,7 @@ pub(crate) fn execute( settings: &Settings, report_name: String, filter: Filter, -) -> anyhow::Result<()> { +) -> Result<(), crate::Error> { display_report(w, replica, settings, report_name, filter) } diff --git a/cli/src/invocation/cmd/sync.rs b/cli/src/invocation/cmd/sync.rs index 2e9400642..ce213a5ba 100644 --- a/cli/src/invocation/cmd/sync.rs +++ b/cli/src/invocation/cmd/sync.rs @@ -5,7 +5,7 @@ pub(crate) fn execute( w: &mut W, replica: &mut Replica, server: &mut Box, -) -> anyhow::Result<()> { +) -> Result<(), crate::Error> { replica.sync(server)?; writeln!(w, "sync complete.")?; Ok(()) diff --git a/cli/src/invocation/cmd/version.rs b/cli/src/invocation/cmd/version.rs index baef94161..5ff2fea57 100644 --- a/cli/src/invocation/cmd/version.rs +++ b/cli/src/invocation/cmd/version.rs @@ -1,6 +1,6 @@ use termcolor::{ColorSpec, WriteColor}; -pub(crate) fn execute(w: &mut W) -> anyhow::Result<()> { +pub(crate) fn execute(w: &mut W) -> Result<(), crate::Error> { write!(w, "TaskChampion ")?; w.set_color(ColorSpec::new().set_bold(true))?; writeln!(w, "{}", env!("CARGO_PKG_VERSION"))?; diff --git a/cli/src/invocation/mod.rs b/cli/src/invocation/mod.rs index a9723fe9a..985c4d99e 100644 --- a/cli/src/invocation/mod.rs +++ b/cli/src/invocation/mod.rs @@ -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: Settings) -> anyhow::Result<()> { +pub(crate) fn invoke(command: Command, settings: Settings) -> Result<(), crate::Error> { log::debug!("command: {:?}", command); log::debug!("settings: {:?}", settings); diff --git a/cli/src/invocation/report.rs b/cli/src/invocation/report.rs index 36d15574a..512866381 100644 --- a/cli/src/invocation/report.rs +++ b/cli/src/invocation/report.rs @@ -80,7 +80,7 @@ pub(super) fn display_report( settings: &Settings, report_name: String, filter: Filter, -) -> anyhow::Result<()> { +) -> Result<(), crate::Error> { let mut t = Table::new(); let working_set = replica.working_set()?; diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 2890e4b9f..6996e55f6 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -38,23 +38,26 @@ use std::string::FromUtf8Error; mod macros; mod argparse; +mod errors; mod invocation; mod settings; mod table; mod usage; +pub(crate) use errors::Error; 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<()> { +pub fn main() -> Result<(), Error> { env_logger::init(); // parse the command line into a vector of &str, failing if // there are invalid utf-8 sequences. let argv: Vec = std::env::args_os() .map(|oss| String::from_utf8(oss.into_vec())) - .collect::>()?; + .collect::>() + .map_err(|_| Error::for_arguments("arguments must be valid utf-8"))?; let argv: Vec<&str> = argv.iter().map(|s| s.as_ref()).collect(); // parse the command line diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs index 7eabe2c77..6325a6d3e 100644 --- a/cli/tests/cli.rs +++ b/cli/tests/cli.rs @@ -56,7 +56,8 @@ fn invalid_option() -> Result<(), Box> { cmd.arg("--no-such-option"); cmd.assert() .failure() - .stderr(predicate::str::contains("command line not recognized")); + .stderr(predicate::str::contains("command line not recognized")) + .code(predicate::eq(3)); Ok(()) } diff --git a/taskchampion/src/errors.rs b/taskchampion/src/errors.rs index 9e2a712a5..44bad9881 100644 --- a/taskchampion/src/errors.rs +++ b/taskchampion/src/errors.rs @@ -1,6 +1,9 @@ use thiserror::Error; + #[derive(Debug, Error, Eq, PartialEq, Clone)] +#[non_exhaustive] +/// Errors returned from taskchampion operations pub enum Error { - #[error("Task Database Error: {}", _0)] - DbError(String), + #[error("Task Database Error: {0}")] + Database(String), } diff --git a/taskchampion/src/lib.rs b/taskchampion/src/lib.rs index da61235d9..a05b1ab7b 100644 --- a/taskchampion/src/lib.rs +++ b/taskchampion/src/lib.rs @@ -40,6 +40,7 @@ mod taskdb; mod utils; mod workingset; +pub use errors::Error; pub use replica::Replica; pub use server::{Server, ServerConfig}; pub use storage::StorageConfig; diff --git a/taskchampion/src/replica.rs b/taskchampion/src/replica.rs index 1e0a47382..361476951 100644 --- a/taskchampion/src/replica.rs +++ b/taskchampion/src/replica.rs @@ -113,7 +113,7 @@ impl Replica { // check that it already exists; this is a convenience check, as the task may already exist // when this Create operation is finally sync'd with operations from other replicas if self.taskdb.get_task(uuid)?.is_none() { - return Err(Error::DbError(format!("Task {} does not exist", uuid)).into()); + return Err(Error::Database(format!("Task {} does not exist", uuid)).into()); } self.taskdb.apply(Operation::Delete { uuid })?; trace!("task {} deleted", uuid); diff --git a/taskchampion/src/taskdb.rs b/taskchampion/src/taskdb.rs index ef5fb6c8d..b373bc582 100644 --- a/taskchampion/src/taskdb.rs +++ b/taskchampion/src/taskdb.rs @@ -49,12 +49,12 @@ impl TaskDb { Operation::Create { uuid } => { // insert if the task does not already exist if !txn.create_task(*uuid)? { - return Err(Error::DbError(format!("Task {} already exists", uuid)).into()); + return Err(Error::Database(format!("Task {} already exists", uuid)).into()); } } Operation::Delete { ref uuid } => { if !txn.delete_task(*uuid)? { - return Err(Error::DbError(format!("Task {} does not exist", uuid)).into()); + return Err(Error::Database(format!("Task {} does not exist", uuid)).into()); } } Operation::Update { @@ -71,7 +71,7 @@ impl TaskDb { }; txn.set_task(*uuid, task)?; } else { - return Err(Error::DbError(format!("Task {} does not exist", uuid)).into()); + return Err(Error::Database(format!("Task {} does not exist", uuid)).into()); } } } From 00089639fe21fd5f8c62f2b91f445c302a0edf08 Mon Sep 17 00:00:00 2001 From: dbr Date: Fri, 21 May 2021 16:31:25 +1000 Subject: [PATCH 03/34] Include git HEAD rev in version output Closes #241 --- cli/Cargo.toml | 2 ++ cli/build.rs | 34 +++++++++++++++++++++++++++++++ cli/src/invocation/cmd/version.rs | 7 ++++++- 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 cli/build.rs diff --git a/cli/Cargo.toml b/cli/Cargo.toml index d66cc63b9..23f68e12e 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -4,6 +4,8 @@ edition = "2018" name = "taskchampion-cli" version = "0.3.0" +build = "build.rs" + [dependencies] dirs-next = "^2.0.0" env_logger = "^0.8.3" diff --git a/cli/build.rs b/cli/build.rs new file mode 100644 index 000000000..3c87576f9 --- /dev/null +++ b/cli/build.rs @@ -0,0 +1,34 @@ +use std::process::Command; + +fn main() { + // Query HEAD revision and expose as $TC_GIT_REV during build + // + // Adapted from https://stackoverflow.com/questions/43753491 + let cmd = Command::new("git") + .args(&["rev-parse", "--short", "HEAD"]) + .spawn() + // Wait for process to exit + .and_then(|cmd| cmd.wait_with_output()) + // Handle error if failed to launch git + .map_err(|_e| println!("cargo:warning=Failed to run 'git' to determine HEAD rev")) + // Remap to Some/None for simpler error handling + .ok() + // Handle command failing + .and_then(|o| { + if o.status.success() { + Some(o) + } else { + println!( + "cargo:warning='git' exited with non-zero exit code while determining HEAD rev" + ); + None + } + }) + // Get output as UTF-8 string + .map(|out| String::from_utf8(out.stdout).expect("Invalid output in stdout")); + + // Only output git rev if successful + if let Some(h) = cmd { + println!("cargo:rustc-env=TC_GIT_REV={}", h); + } +} diff --git a/cli/src/invocation/cmd/version.rs b/cli/src/invocation/cmd/version.rs index baef94161..aebac3f5b 100644 --- a/cli/src/invocation/cmd/version.rs +++ b/cli/src/invocation/cmd/version.rs @@ -3,8 +3,13 @@ use termcolor::{ColorSpec, WriteColor}; pub(crate) fn execute(w: &mut W) -> anyhow::Result<()> { write!(w, "TaskChampion ")?; w.set_color(ColorSpec::new().set_bold(true))?; - writeln!(w, "{}", env!("CARGO_PKG_VERSION"))?; + write!(w, "{}", env!("CARGO_PKG_VERSION"))?; w.reset()?; + + if let Some(h) = option_env!("TC_GIT_REV") { + write!(w, " (git rev: {})", h)?; + } + writeln!(w)?; Ok(()) } From 8ba72d19dfde68043b614b7f5f897c9b463be159 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Fri, 21 May 2021 09:07:35 -0400 Subject: [PATCH 04/34] Add CODEOWNERS pointing to @dbr and me --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..f1bb9bff4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @dbr @djmitche From 45db886f2a1f86460f4088597242058fb2e9125b Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Fri, 21 May 2021 22:39:10 -0400 Subject: [PATCH 05/34] add 'ta config path' --- cli/src/argparse/config.rs | 26 +++++++++++++++++--------- cli/src/invocation/cmd/config.rs | 7 +++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/cli/src/argparse/config.rs b/cli/src/argparse/config.rs index 924164564..209000a4e 100644 --- a/cli/src/argparse/config.rs +++ b/cli/src/argparse/config.rs @@ -1,13 +1,15 @@ use super::args::{any, arg_matching, literal}; use super::ArgList; use crate::usage; -use nom::{combinator::*, sequence::*, IResult}; +use nom::{branch::alt, combinator::*, sequence::*, IResult}; #[derive(Debug, PartialEq)] /// A config operation pub(crate) enum ConfigOperation { /// Set a configuration value Set(String, String), + /// Show configuration path + Path, } impl ConfigOperation { @@ -15,14 +17,20 @@ impl ConfigOperation { fn set_to_op(input: (&str, &str, &str)) -> Result { 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) + fn path_to_op(_: &str) -> Result { + Ok(ConfigOperation::Path) + } + alt(( + map_res( + tuple(( + arg_matching(literal("set")), + arg_matching(any), + arg_matching(any), + )), + set_to_op, + ), + map_res(arg_matching(literal("path")), path_to_op), + ))(input) } pub(super) fn get_usage(u: &mut usage::Usage) { diff --git a/cli/src/invocation/cmd/config.rs b/cli/src/invocation/cmd/config.rs index 2b57aa52d..fc8aa6a3f 100644 --- a/cli/src/invocation/cmd/config.rs +++ b/cli/src/invocation/cmd/config.rs @@ -19,6 +19,13 @@ pub(crate) fn execute( writeln!(w, "{:?}.", filename)?; w.set_color(ColorSpec::new().set_bold(false))?; } + ConfigOperation::Path => { + if let Some(ref filename) = settings.filename { + writeln!(w, "{}", filename.to_string_lossy())?; + } else { + return Err(anyhow::anyhow!("No configuration filename found").into()); + } + } } Ok(()) } From b944e278808c4cd8281c3cf90a3b0ab94c94b223 Mon Sep 17 00:00:00 2001 From: dbr Date: Mon, 24 May 2021 19:44:32 +1000 Subject: [PATCH 06/34] Make 'cargo run' run ta binary --- cli/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 07b0e6aca..1c0e2822d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -6,6 +6,9 @@ version = "0.3.0" build = "build.rs" +# Run 'ta' when doing 'cargo run' at repo root +default-run = "ta" + [dependencies] dirs-next = "^2.0.0" env_logger = "^0.8.3" From 09558f9329359eda4fc809d2cc4cb4afb6d42538 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Tue, 11 May 2021 21:45:32 +0000 Subject: [PATCH 07/34] Substitute usage information into the documentation This will simplify keeping documentation in sync with the code. --- .github/workflows/publish-docs.yml | 20 + .github/workflows/rust-tests.yml | 20 + Cargo.lock | 844 ++++++++++++++++++++++++++++- README.md | 5 + RELEASING.md | 2 +- build-docs.sh | 31 ++ cli/Cargo.toml | 15 + cli/src/argparse/modification.rs | 4 +- cli/src/bin/usage-docs.rs | 50 ++ cli/src/lib.rs | 3 + cli/src/settings/mod.rs | 2 +- cli/src/settings/report.rs | 31 ++ cli/src/usage.rs | 136 ++++- docs/README.md | 7 + docs/book.toml | 3 + docs/build.sh | 23 - docs/src/SUMMARY.md | 4 +- docs/src/filters.md | 9 + docs/src/modifications.md | 5 + docs/src/reports.md | 9 +- docs/src/using-task-command.md | 9 +- 21 files changed, 1177 insertions(+), 55 deletions(-) create mode 100755 build-docs.sh create mode 100644 cli/src/bin/usage-docs.rs delete mode 100755 docs/build.sh create mode 100644 docs/src/filters.md create mode 100644 docs/src/modifications.md diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index a9fed18ba..27140a996 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -17,6 +17,26 @@ jobs: with: mdbook-version: 'latest' + - name: Cache cargo registry + uses: actions/cache@v1 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v1 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Create usage-docs plugin + run: cargo build -p taskchampion-cli --features usage-docs --bin usage-docs + - run: mdbook build docs - name: Deploy diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index 8d9eba149..ab56e3c39 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -75,5 +75,25 @@ jobs: with: mdbook-version: 'latest' + - name: Cache cargo registry + uses: actions/cache@v1 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v1 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Create usage-docs plugin + run: cargo build -p taskchampion-cli --features usage-docs --bin usage-docs + - run: mdbook test docs - run: mdbook build docs diff --git a/Cargo.lock b/Cargo.lock index 6853f373a..144f8e56f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ dependencies = [ "actix-service", "actix-threadpool", "actix-utils", - "base64", + "base64 0.13.0", "bitflags", "brotli2", "bytes 0.5.6", @@ -76,8 +76,8 @@ dependencies = [ "regex", "serde", "serde_json", - "serde_urlencoded", - "sha-1", + "serde_urlencoded 0.7.0", + "sha-1 0.9.4", "slab", "time 0.2.26", ] @@ -264,7 +264,7 @@ dependencies = [ "regex", "serde", "serde_json", - "serde_urlencoded", + "serde_urlencoded 0.7.0", "socket2", "time 0.2.26", "tinyvec", @@ -297,6 +297,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "ammonia" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee7d6eb157f337c5cedc95ddf17f0cbc36d36eb7763c8e0d1c1aeb3722f6279" +dependencies = [ + "html5ever", + "lazy_static", + "maplit", + "markup5ever_rcdom", + "matches", + "tendril", + "url", +] + [[package]] name = "ansi_term" version = "0.11.0" @@ -382,7 +397,7 @@ dependencies = [ "actix-http", "actix-rt 1.1.1", "actix-service", - "base64", + "base64 0.13.0", "bytes 0.5.6", "cfg-if 1.0.0", "derive_more", @@ -393,7 +408,7 @@ dependencies = [ "rand 0.7.3", "serde", "serde_json", - "serde_urlencoded", + "serde_urlencoded 0.7.0", ] [[package]] @@ -402,6 +417,12 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4521f3e3d031370679b3b140beb36dfe4801b09ac77e30c61941f97df3ef28b" +[[package]] +name = "base64" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" + [[package]] name = "base64" version = "0.13.0" @@ -452,13 +473,34 @@ dependencies = [ "constant_time_eq", ] +[[package]] +name = "block-buffer" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" +dependencies = [ + "block-padding", + "byte-tools", + "byteorder", + "generic-array 0.12.4", +] + [[package]] name = "block-buffer" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" dependencies = [ - "generic-array", + "generic-array 0.14.4", +] + +[[package]] +name = "block-padding" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5" +dependencies = [ + "byte-tools", ] [[package]] @@ -499,6 +541,12 @@ version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63396b8a4b9de3f4fdfb320ab6080762242f66a8ef174c49d8e19b674db4cdbe" +[[package]] +name = "byte-tools" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" + [[package]] name = "byteorder" version = "1.4.3" @@ -693,13 +741,22 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" +[[package]] +name = "digest" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" +dependencies = [ + "generic-array 0.12.4", +] + [[package]] name = "digest" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" dependencies = [ - "generic-array", + "generic-array 0.14.4", ] [[package]] @@ -746,12 +803,33 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "dtoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56899898ce76aaf4a0f24d914c97ea6ed976d42fec6ad33fcbb0a1103e07b2b0" + [[package]] name = "either" version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +[[package]] +name = "elasticlunr-rs" +version = "2.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "959fbc9a6ebced545cbe365fdce5e25c6ab7683f2ca4ecc9fb9d0db663bf73d5" +dependencies = [ + "lazy_static", + "regex", + "serde", + "serde_derive", + "serde_json", + "strum", + "strum_macros", +] + [[package]] name = "encode_unicode" version = "0.3.6" @@ -779,6 +857,19 @@ dependencies = [ "syn", ] +[[package]] +name = "env_logger" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" +dependencies = [ + "atty", + "humantime 1.3.0", + "log", + "regex", + "termcolor", +] + [[package]] name = "env_logger" version = "0.8.3" @@ -786,12 +877,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17392a012ea30ef05a610aa97dfb49496e71c9f676b27879922ea5bdf60d9d3f" dependencies = [ "atty", - "humantime", + "humantime 2.1.0", "log", "regex", "termcolor", ] +[[package]] +name = "fake-simd" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" + +[[package]] +name = "filetime" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d34cfa13a63ae058bfa601fe9e313bbdb3746427c1459185464ce0fcf62e1e8" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "redox_syscall 0.2.6", + "winapi 0.3.9", +] + [[package]] name = "flate2" version = "1.0.20" @@ -829,6 +938,25 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ab7d1bd1bd33cc98b0889831b72da23c0aa4df9cec7e0702f46ecea04b35db6" +dependencies = [ + "bitflags", + "fsevent-sys", +] + +[[package]] +name = "fsevent-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f41b048a94555da0f42f1d632e2e19510084fb8e303b0daa2816e733fb3644a0" +dependencies = [ + "libc", +] + [[package]] name = "fuchsia-zircon" version = "0.3.3" @@ -851,6 +979,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7" +[[package]] +name = "futf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c9c1ce3fa9336301af935ab852c437817d14cd33690446569392e65170aac3b" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.14" @@ -952,6 +1090,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + [[package]] name = "generic-array" version = "0.14.4" @@ -962,6 +1109,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -984,6 +1140,21 @@ dependencies = [ "wasi 0.10.2+wasi-snapshot-preview1", ] +[[package]] +name = "gitignore" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78aa90e4620c1498ac434c06ba6e521b525794bbdacf085d490cc794b4a2f9a4" +dependencies = [ + "glob", +] + +[[package]] +name = "glob" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" + [[package]] name = "h2" version = "0.2.7" @@ -1004,12 +1175,51 @@ dependencies = [ "tracing-futures", ] +[[package]] +name = "handlebars" +version = "3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4498fc115fa7d34de968184e473529abb40eeb6be8bc5f7faba3d08c316cb3e3" +dependencies = [ + "log", + "pest", + "pest_derive", + "quick-error 2.0.0", + "serde", + "serde_json", +] + [[package]] name = "hashbrown" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" +[[package]] +name = "headers" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0b7591fb62902706ae8e7aaff416b1b0fa2c0fd0878b46dc13baa3712d8a855" +dependencies = [ + "base64 0.13.0", + "bitflags", + "bytes 1.0.1", + "headers-core", + "http", + "mime", + "sha-1 0.9.4", + "time 0.1.43", +] + +[[package]] +name = "headers-core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7f66481bfee273957b1f20485a4ff3362987f85b2c236580d81b4eb7a326429" +dependencies = [ + "http", +] + [[package]] name = "heck" version = "0.3.2" @@ -1039,6 +1249,20 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "html5ever" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aafcf38a1a36118242d29b92e1b08ef84e67e4a5ed06e0a80be20e6a32bfed6b" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "http" version = "0.2.4" @@ -1050,18 +1274,67 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d5ff830006f7646652e057693569bfe0d51760c0085a071769d142a205111b" +dependencies = [ + "bytes 0.5.6", + "http", +] + [[package]] name = "httparse" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1ce40d6fc9764887c2fdc7305c3dcc429ba11ff981c1509416afd5697e4437" +[[package]] +name = "httpdate" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494b4d60369511e7dea41cf646832512a94e542f68bb9c49e54518e0f468eb47" + +[[package]] +name = "humantime" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f" +dependencies = [ + "quick-error 1.2.3", +] + [[package]] name = "humantime" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "hyper" +version = "0.13.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a6f157065790a3ed2f88679250419b5cdd96e714a0d65f7797fd337186e96bb" +dependencies = [ + "bytes 0.5.6", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project 1.0.7", + "socket2", + "tokio 0.2.25", + "tower-service", + "tracing", + "want", +] + [[package]] name = "idna" version = "0.2.3" @@ -1083,6 +1356,35 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inotify" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4816c66d2c8ae673df83366c18341538f234a26d65a9ecea5c348b453ac1d02f" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "input_buffer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a8a95243d5a0398cae618ec29477c6e3cb631152be5c19481f80bc71559754" +dependencies = [ + "bytes 0.5.6", +] + [[package]] name = "instant" version = "0.1.9" @@ -1163,6 +1465,12 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "lexical-core" version = "0.7.5" @@ -1261,6 +1569,44 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a24f40fb03852d1cdd84330cddcaf98e9ec08a7b7768e952fad3b4cf048ec8fd" +dependencies = [ + "log", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f015da43bcd8d4f144559a3423f4591d69b8ce0652c905374da7205df336ae2b" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + [[package]] name = "match_cfg" version = "0.1.0" @@ -1273,6 +1619,38 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" +[[package]] +name = "mdbook" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed4060ccf332a0479df37e84c8435ad20be737d5337c3a90fa1b3b0d480a3a0" +dependencies = [ + "ammonia", + "anyhow", + "chrono", + "clap", + "elasticlunr-rs", + "env_logger 0.7.1", + "futures-util", + "gitignore", + "handlebars", + "lazy_static", + "log", + "memchr", + "notify", + "open", + "pulldown-cmark", + "regex", + "serde", + "serde_derive", + "serde_json", + "shlex", + "tempfile", + "tokio 0.2.25", + "toml", + "warp", +] + [[package]] name = "memchr" version = "2.3.4" @@ -1285,6 +1663,16 @@ version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +[[package]] +name = "mime_guess" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2684d4c2e97d99848d30b324b00c8fcc7e5c897b7cbb5819b09e7c90e8baf212" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.4.4" @@ -1327,6 +1715,18 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "mio-extras" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52403fe290012ce777c4626790c8951324a2b9e3316b3143779c72b029742f19" +dependencies = [ + "lazycell", + "log", + "mio 0.6.23", + "slab", +] + [[package]] name = "mio-uds" version = "0.6.8" @@ -1370,6 +1770,12 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" + [[package]] name = "nom" version = "6.1.2" @@ -1389,6 +1795,24 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "notify" +version = "4.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae03c8c853dba7bfd23e571ff0cff7bc9dceb40a4cd684cd1681824183f45257" +dependencies = [ + "bitflags", + "filetime", + "fsevent", + "fsevent-sys", + "inotify", + "libc", + "mio 0.6.23", + "mio-extras", + "walkdir", + "winapi 0.3.9", +] + [[package]] name = "ntapi" version = "0.3.6" @@ -1433,12 +1857,28 @@ version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3" +[[package]] +name = "opaque-debug" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" + [[package]] name = "opaque-debug" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "open" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1711eb4b31ce4ad35b0f316d8dfba4fe5c7ad601c448446d84aae7a896627b20" +dependencies = [ + "which", + "winapi 0.3.9", +] + [[package]] name = "parking_lot" version = "0.11.1" @@ -1470,6 +1910,87 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" +[[package]] +name = "pest" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" +dependencies = [ + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "833d1ae558dc601e9a60366421196a8d94bc0ac980476d0b67e1d0988d72b2d0" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b8db626e31e5b81787b9783425769681b347011cc59471e33ea46d2ea0cf55" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54be6e404f5317079812fc8f9f5279de376d8856929e21c184ecf6bbd692a11d" +dependencies = [ + "maplit", + "pest", + "sha-1 0.8.2", +] + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared", + "rand 0.7.3", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "0.4.28" @@ -1540,6 +2061,12 @@ version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "predicates" version = "1.0.7" @@ -1630,6 +2157,18 @@ version = "2.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b7f4a129bb3754c25a4e04032a90173c68f85168f77118ac4cb4936e7f06f92" +[[package]] +name = "pulldown-cmark" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca36dea94d187597e104a5c8e4b07576a8a45aa5db48a65e12940d3eb7461f55" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "unicase", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -1668,6 +2207,7 @@ dependencies = [ "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc 0.2.0", + "rand_pcg", ] [[package]] @@ -1738,6 +2278,15 @@ dependencies = [ "rand_core 0.6.2", ] +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + [[package]] name = "rand_xorshift" version = "0.3.0" @@ -1870,7 +2419,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" dependencies = [ - "base64", + "base64 0.13.0", "blake2b_simd", "constant_time_eq", "crossbeam-utils", @@ -1891,7 +2440,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064fd21ff87c6e87ed4506e68beb42459caa4a0e2eb144932e6776768556980b" dependencies = [ - "base64", + "base64 0.13.0", "log", "ring", "sct", @@ -1916,6 +2465,21 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6a9290e3c9cf0f18145ef7ffa62d68ee0bf5fcd651017e586dc7fd5da448c2" + [[package]] name = "scopeguard" version = "1.1.0" @@ -1978,6 +2542,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ec5d77e2d4c73717816afac02670d5c4f534ea95ed430442cad02e7a6e32c97" +dependencies = [ + "dtoa", + "itoa", + "serde", + "url", +] + [[package]] name = "serde_urlencoded" version = "0.7.0" @@ -1990,17 +2566,29 @@ dependencies = [ "serde", ] +[[package]] +name = "sha-1" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d94d0bede923b3cea61f3f1ff57ff8cdfd77b400fb8f9998949e0cf04163df" +dependencies = [ + "block-buffer 0.7.3", + "digest 0.8.1", + "fake-simd", + "opaque-debug 0.2.3", +] + [[package]] name = "sha-1" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfebf75d25bd900fd1e7d11501efab59bc846dbc76196839663e6637bba9f25f" dependencies = [ - "block-buffer", + "block-buffer 0.9.0", "cfg-if 1.0.0", "cpuid-bool", - "digest", - "opaque-debug", + "digest 0.9.0", + "opaque-debug 0.3.0", ] [[package]] @@ -2009,6 +2597,12 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2579985fda508104f7587689507983eadd6a6e84dd35d6d115361f530916fa0d" +[[package]] +name = "shlex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a568c8f2cd051a4d283bd6eb0343ac214c1b0f1ac19f93e1175b2dee38c73d" + [[package]] name = "signal-hook-registry" version = "1.3.0" @@ -2018,6 +2612,12 @@ dependencies = [ "libc", ] +[[package]] +name = "siphasher" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbce6d4507c7e4a3962091436e56e95290cb71fa302d0d270e32130b75fbff27" + [[package]] name = "slab" version = "0.4.2" @@ -2117,12 +2717,55 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" +[[package]] +name = "string_cache" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb1139b5353f96e429e1a5e19fbaf663bddedaa06d1dbd49f82e352601209a" +dependencies = [ + "lazy_static", + "new_debug_unreachable", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f24c8e5e19d22a726626f1a5e16fe15b132dcf21d10177fa5a45ce7962996b97" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" +[[package]] +name = "strum" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57bd81eb48f4c437cadc685403cad539345bf703d78e63707418431cecd4522b" + +[[package]] +name = "strum_macros" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87c85aa3f8ea653bfd3ddf25f7ee357ee4d204731f6aa9ad04002306f6e2774c" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "syn" version = "1.0.69" @@ -2167,11 +2810,13 @@ dependencies = [ "assert_cmd", "atty", "dirs-next", - "env_logger", + "env_logger 0.8.3", "log", + "mdbook", "nom", "predicates", "prettytable-rs", + "serde_json", "taskchampion", "tempfile", "termcolor", @@ -2189,7 +2834,7 @@ dependencies = [ "actix-web", "anyhow", "clap", - "env_logger", + "env_logger 0.8.3", "futures", "kv", "log", @@ -2212,6 +2857,17 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "tendril" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9ef557cb397a4f0a5a3a628f06515f78563f2209e64d47055d9dc6052bf5e33" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "term" version = "0.5.2" @@ -2373,6 +3029,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6703a273949a90131b290be1fe7b039d0fc884aa1935860dfcbe056f28cd8092" dependencies = [ "bytes 0.5.6", + "fnv", "futures-core", "iovec", "lazy_static", @@ -2383,6 +3040,7 @@ dependencies = [ "pin-project-lite 0.1.12", "signal-hook-registry", "slab", + "tokio-macros", "winapi 0.3.9", ] @@ -2402,6 +3060,30 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "tokio-macros" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e44da00bfc73a25f814cd8d7e57a68a5c31b74b3152a0a1d1f590c97ed06265a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e878ad426ca286e4dcae09cbd4e1973a7f8987d97570e2469703dd7f5720c" +dependencies = [ + "futures-util", + "log", + "pin-project 0.4.28", + "tokio 0.2.25", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.3.1" @@ -2436,6 +3118,12 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "tower-service" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" + [[package]] name = "tracing" version = "0.1.25" @@ -2512,12 +3200,52 @@ dependencies = [ "trust-dns-proto", ] +[[package]] +name = "try-lock" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" + +[[package]] +name = "tungstenite" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0308d80d86700c5878b9ef6321f020f29b1bb9d5ff3cab25e75e23f3a492a23" +dependencies = [ + "base64 0.12.3", + "byteorder", + "bytes 0.5.6", + "http", + "httparse", + "input_buffer", + "log", + "rand 0.7.3", + "sha-1 0.9.4", + "url", + "utf-8", +] + [[package]] name = "typenum" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06" +[[package]] +name = "ucd-trie" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" + +[[package]] +name = "unicase" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.5" @@ -2575,7 +3303,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fbeb1aabb07378cf0e084971a74f24241273304653184f54cdce113c0d7df1b" dependencies = [ - "base64", + "base64 0.13.0", "chunked_transfer", "log", "once_cell", @@ -2597,6 +3325,18 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a1f0175e03a0973cf4afd476bef05c26e228520400eb1fd473ad417b1c00ffb" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "uuid" version = "0.8.2" @@ -2634,6 +3374,54 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi 0.3.9", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] +name = "warp" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f41be6df54c97904af01aa23e613d4521eed7ab23537cede692d4058f6449407" +dependencies = [ + "bytes 0.5.6", + "futures", + "headers", + "http", + "hyper", + "log", + "mime", + "mime_guess", + "pin-project 0.4.28", + "scoped-tls", + "serde", + "serde_json", + "serde_urlencoded 0.6.1", + "tokio 0.2.25", + "tokio-tungstenite", + "tower-service", + "tracing", + "tracing-futures", + "urlencoding", +] + [[package]] name = "wasi" version = "0.9.0+wasi-snapshot-preview1" @@ -2729,6 +3517,16 @@ dependencies = [ "webpki", ] +[[package]] +name = "which" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55551e42cbdf2ce2bedd2203d0cc08dba002c27510f86dab6d0ce304cba3dfe" +dependencies = [ + "either", + "libc", +] + [[package]] name = "widestring" version = "0.4.3" @@ -2802,3 +3600,15 @@ name = "wyz" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" + +[[package]] +name = "xml5ever" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1b52e6e8614d4a58b8e70cf51ec0cc21b256ad8206708bcff8139b5bbd6a59" +dependencies = [ + "log", + "mac", + "markup5ever", + "time 0.1.43", +] diff --git a/README.md b/README.md index 6a0715ccb..1b4e1d664 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,8 @@ There are three crates here: * [taskchampion-cli](./cli) - the command-line binary * [taskchampion-sync-server](./sync-server) - the server against which `task sync` operates +## Documentation Generation + +The `mdbook` configuration contains a "preprocessor" implemented in the `taskchampion-cli` crate in order to reflect CLI usage information into the generated book. +Tihs preprocessor is not built by default. +To (re)build it, run `cargo build -p taskchampion-cli --features usage-docs --bin usage-docs`. diff --git a/RELEASING.md b/RELEASING.md index 160f2c230..e58e51da7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -10,7 +10,7 @@ 1. Run `git tag vX.Y.Z` 1. Run `git push upstream` 1. Run `git push --tags upstream` -1. Run `( cd docs; ./build.sh )` +1. Run `( ./build-docs.sh )` 1. Run `(cd taskchampion; cargo publish)` (note that the other crates do not get published) 1. Navigate to the tag in the GitHub releases UI and create a release with general comments about the changes in the release 1. Upload `./target/release/task` and `./target/release/task-sync-server` to the release diff --git a/build-docs.sh b/build-docs.sh new file mode 100755 index 000000000..b03c22ab9 --- /dev/null +++ b/build-docs.sh @@ -0,0 +1,31 @@ +#! /bin/bash + +REMOTE=origin + +set -e + +if ! [ -f "docs/src/SUMMARY.md" ]; then + echo "Run this from the root of the repo" + exit 1 +fi + +# build the latest version of the mdbook plugin +cargo build -p taskchampion-cli --features usage-docs --bin usage-docs + +# create a worktree of this repo, with the `gh-pages` branch checked out +if ! [ -d ./docs/tmp ]; then + git worktree add docs/tmp gh-pages +fi + +# update the wortree +(cd docs/tmp && git pull $REMOTE gh-pages) + +# remove all files in the worktree and regenerate the book there +rm -rf docs/tmp/* +mdbook build docs +cp -rp docs/book/* docs/tmp + +# add everything in the worktree, commit, and push +(cd docs/tmp && git add -A) +(cd docs/tmp && git commit -am "update docs") +(cd docs/tmp && git push $REMOTE gh-pages:gh-pages) diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 07b0e6aca..868565a88 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -20,6 +20,10 @@ atty = "^0.2.14" toml = "^0.5.8" toml_edit = "^0.2.0" +# only needed for usage-docs +mdbook = { version = "0.4", optional = true } +serde_json = { version = "*", optional = true } + [dependencies.taskchampion] path = "../taskchampion" @@ -27,3 +31,14 @@ path = "../taskchampion" assert_cmd = "^1.0.3" predicates = "^1.0.7" tempfile = "3" + +[features] +usage-docs = [ "mdbook", "serde_json" ] + +[[bin]] +name = "ta" + +[[bin]] +# this is an mdbook plugin and only needed when running `mdbook` +name = "usage-docs" +required-features = [ "usage-docs" ] diff --git a/cli/src/argparse/modification.rs b/cli/src/argparse/modification.rs index d449f1ef6..9628f2352 100644 --- a/cli/src/argparse/modification.rs +++ b/cli/src/argparse/modification.rs @@ -115,7 +115,9 @@ impl Modification { summary: "Set description", description: " Set the task description. Multiple arguments are combined into a single - space-separated description.", + space-separated description. To avoid surprises from shell quoting, prefer + to use a single quoted argument, for example `ta 19 modify \"return library + books\"`", }); u.modifications.push(usage::Modification { syntax: "+TAG", diff --git a/cli/src/bin/usage-docs.rs b/cli/src/bin/usage-docs.rs new file mode 100644 index 000000000..f78179c71 --- /dev/null +++ b/cli/src/bin/usage-docs.rs @@ -0,0 +1,50 @@ +use mdbook::book::{Book, BookItem}; +use mdbook::errors::Error; +use mdbook::preprocess::{CmdPreprocessor, PreprocessorContext}; +use std::io; +use std::process; +use taskchampion_cli::Usage; + +/// This is a simple mdbook preprocessor designed to substitute information from the usage +/// into the documentation. +fn main() -> anyhow::Result<()> { + // cheap way to detect the "supports" arg + if std::env::args().len() > 1 { + // sure, whatever, we support it all + process::exit(0); + } + + let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())?; + + if ctx.mdbook_version != mdbook::MDBOOK_VERSION { + eprintln!( + "Warning: This mdbook preprocessor was built against version {} of mdbook, \ + but we're being called from version {}", + mdbook::MDBOOK_VERSION, + ctx.mdbook_version + ); + } + + let processed_book = process(&ctx, book)?; + serde_json::to_writer(io::stdout(), &processed_book)?; + + Ok(()) +} + +fn process(_ctx: &PreprocessorContext, mut book: Book) -> Result { + let usage = Usage::new(); + + book.for_each_mut(|sect| { + if let BookItem::Chapter(ref mut chapter) = sect { + let new_content = usage.substitute_docs(&chapter.content).unwrap(); + if new_content != chapter.content { + eprintln!( + "Substituting usage in {:?}", + chapter.source_path.as_ref().unwrap() + ); + } + chapter.content = new_content; + } + }); + Ok(book) +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 6996e55f6..ba56704f0 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -47,6 +47,9 @@ mod usage; pub(crate) use errors::Error; use settings::Settings; +// used by the `generate` command +pub use usage::Usage; + /// 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() -> Result<(), Error> { diff --git a/cli/src/settings/mod.rs b/cli/src/settings/mod.rs index 896de38b8..c6a6ddd2f 100644 --- a/cli/src/settings/mod.rs +++ b/cli/src/settings/mod.rs @@ -7,5 +7,5 @@ mod report; mod settings; mod util; -pub(crate) use report::{Column, Property, Report, Sort, SortBy}; +pub(crate) use report::{get_usage, Column, Property, Report, Sort, SortBy}; pub(crate) use settings::Settings; diff --git a/cli/src/settings/report.rs b/cli/src/settings/report.rs index 959494e5d..f73dfe58d 100644 --- a/cli/src/settings/report.rs +++ b/cli/src/settings/report.rs @@ -2,6 +2,7 @@ use crate::argparse::{Condition, Filter}; use crate::settings::util::table_with_keys; +use crate::usage::{self, Usage}; use anyhow::{anyhow, bail, Result}; use std::convert::{TryFrom, TryInto}; @@ -30,6 +31,7 @@ pub(crate) struct Column { /// Task property to display in a report #[derive(Clone, Debug, PartialEq)] pub(crate) enum Property { + // NOTE: when adding a property here, add it to get_usage, below, as well. /// The task's ID, either working-set index or Uuid if not in the working set Id, @@ -59,6 +61,7 @@ pub(crate) struct Sort { /// Task property to sort by #[derive(Clone, Debug, PartialEq)] pub(crate) enum SortBy { + // NOTE: when adding a property here, add it to get_usage, below, as well. /// The task's ID, either working-set index or a UUID prefix; working /// set tasks sort before others. Id, @@ -212,6 +215,34 @@ impl TryFrom<&toml::Value> for SortBy { } } +pub(crate) fn get_usage(u: &mut Usage) { + u.report_properties.push(usage::ReportProperty { + name: "id", + as_sort_by: Some("Sort by the task's shorthand ID"), + as_column: Some("The task's shorthand ID"), + }); + u.report_properties.push(usage::ReportProperty { + name: "uuid", + as_sort_by: Some("Sort by the task's full UUID"), + as_column: Some("The task's full UUID"), + }); + u.report_properties.push(usage::ReportProperty { + name: "active", + as_sort_by: None, + as_column: Some("`*` if the task is active (started)"), + }); + u.report_properties.push(usage::ReportProperty { + name: "description", + as_sort_by: Some("Sort by the task's description"), + as_column: Some("The task's description"), + }); + u.report_properties.push(usage::ReportProperty { + name: "tags", + as_sort_by: None, + as_column: Some("The task's tags"), + }); +} + #[cfg(test)] mod test { use super::*; diff --git a/cli/src/usage.rs b/cli/src/usage.rs index f1bacf674..59a2ba982 100644 --- a/cli/src/usage.rs +++ b/cli/src/usage.rs @@ -2,26 +2,31 @@ //! a way that puts the source of that documentation near its implementation. use crate::argparse; -use std::io::{Result, Write}; +use crate::settings; +use anyhow::Result; +use std::io::Write; + +#[cfg(feature = "usage-docs")] +use std::fmt::Write as FmtWrite; /// A top-level structure containing usage/help information for the entire CLI. #[derive(Debug, Default)] -pub(crate) struct Usage { +pub struct Usage { pub(crate) subcommands: Vec, pub(crate) filters: Vec, pub(crate) modifications: Vec, + pub(crate) report_properties: Vec, } impl Usage { /// Get a new, completely-filled-out usage object - pub(crate) fn new() -> Self { + pub fn new() -> Self { let mut rv = Self { ..Default::default() }; argparse::get_usage(&mut rv); - - // TODO: sort subcommands + settings::get_usage(&mut rv); rv } @@ -77,6 +82,62 @@ impl Usage { } Ok(()) } + + #[cfg(feature = "usage-docs")] + /// Substitute strings matching + /// + /// ```text + /// + /// ``` + /// + /// With the appropriate documentation. + pub fn substitute_docs(&self, content: &str) -> Result { + // this is not efficient, but it doesn't need to be + let mut lines = content.lines(); + let mut w = String::new(); + + const DOC_HEADER_PREFIX: &str = ""; + + for line in lines { + if line.starts_with(DOC_HEADER_PREFIX) && line.ends_with(DOC_HEADER_SUFFIX) { + let doc_type = &line[DOC_HEADER_PREFIX.len()..line.len() - DOC_HEADER_SUFFIX.len()]; + + match doc_type { + "subcommands" => { + for subcommand in self.subcommands.iter() { + subcommand.write_markdown(&mut w)?; + } + } + "filters" => { + for filter in self.filters.iter() { + filter.write_markdown(&mut w)?; + } + } + "modifications" => { + for modification in self.modifications.iter() { + modification.write_markdown(&mut w)?; + } + } + "report-columns" => { + for prop in self.report_properties.iter() { + prop.write_column_markdown(&mut w)?; + } + } + "report-sort-by" => { + for prop in self.report_properties.iter() { + prop.write_sort_by_markdown(&mut w)?; + } + } + _ => anyhow::bail!("Unkonwn doc type {}", doc_type), + } + } else { + writeln!(w, "{}", line)?; + } + } + + Ok(w) + } } /// wrap an indented string @@ -122,6 +183,15 @@ impl Subcommand { } Ok(()) } + + #[cfg(feature = "usage-docs")] + fn write_markdown(&self, mut w: W) -> Result<()> { + writeln!(w, "### `ta {}` - {}", self.name, self.summary)?; + writeln!(w, "```shell\nta {}\n```", self.syntax)?; + writeln!(w, "{}", indented(self.description, ""))?; + writeln!(w)?; + Ok(()) + } } /// Usage documentation for a filter argument @@ -152,6 +222,15 @@ impl Filter { } Ok(()) } + + #[cfg(feature = "usage-docs")] + fn write_markdown(&self, mut w: W) -> Result<()> { + writeln!(w, "* `{}` - {}", self.syntax, self.summary)?; + writeln!(w)?; + writeln!(w, "{}", indented(self.description, " "))?; + writeln!(w)?; + Ok(()) + } } /// Usage documentation for a modification argument @@ -182,4 +261,51 @@ impl Modification { } Ok(()) } + + #[cfg(feature = "usage-docs")] + fn write_markdown(&self, mut w: W) -> Result<()> { + writeln!(w, "* `{}` - {}", self.syntax, self.summary)?; + writeln!(w)?; + writeln!(w, "{}", indented(self.description, " "))?; + writeln!(w)?; + Ok(()) + } +} + +/// Usage documentation for a report property (which may be used for sorting, as a column, or +/// both). +#[derive(Debug, Default)] +pub(crate) struct ReportProperty { + /// Name of the property + pub(crate) name: &'static str, + + /// Usage description for sorting, if any + pub(crate) as_sort_by: Option<&'static str>, + + /// Usage description as a column, if any + pub(crate) as_column: Option<&'static str>, +} + +impl ReportProperty { + #[cfg(feature = "usage-docs")] + fn write_sort_by_markdown(&self, mut w: W) -> Result<()> { + if let Some(as_sort_by) = self.as_sort_by { + writeln!(w, "* `{}`", self.name)?; + writeln!(w)?; + writeln!(w, "{}", indented(as_sort_by, " "))?; + writeln!(w)?; + } + Ok(()) + } + + #[cfg(feature = "usage-docs")] + fn write_column_markdown(&self, mut w: W) -> Result<()> { + if let Some(as_column) = self.as_column { + writeln!(w, "* `{}`", self.name)?; + writeln!(w)?; + writeln!(w, "{}", indented(as_column, " "))?; + writeln!(w)?; + } + Ok(()) + } } diff --git a/docs/README.md b/docs/README.md index 7aaa35c16..586cf2663 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,10 @@ This is an [mdbook](https://rust-lang.github.io/mdBook/index.html) book. Minor modifications can be made without installing the mdbook tool, as the content is simple Markdown. Changes are verified on pull requests. + +To build the docs locally, you will need to build `usage-docs`: + +``` +cargo build -p taskchampion-cli --feature usage-docs --bin usage-docs +mdbook build docs/ +``` diff --git a/docs/book.toml b/docs/book.toml index 7e2fa9820..3ab678ad0 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -7,3 +7,6 @@ title = "TaskChampion" [output.html] default-theme = "ayu" + +[preprocessor.usage-docs] +command = "target/debug/usage-docs" diff --git a/docs/build.sh b/docs/build.sh deleted file mode 100755 index 06dc662c7..000000000 --- a/docs/build.sh +++ /dev/null @@ -1,23 +0,0 @@ -#! /bin/bash - -REMOTE=origin - -set -e - -if ! [ -f "./src/SUMMARY.md" ]; then - echo "Run this from the docs/ dir" - exit 1 -fi - -if ! [ -d ./tmp ]; then - git worktree add tmp gh-pages -fi - -(cd tmp && git pull $REMOTE gh-pages) - -rm -rf tmp/* -mdbook build -cp -rp book/* tmp -(cd tmp && git add -A) -(cd tmp && git commit -am "update docs") -(cd tmp && git push $REMOTE gh-pages:gh-pages) diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index f69280b27..822dc4f7f 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -3,9 +3,11 @@ - [Welcome to TaskChampion](./welcome.md) * [Installation](./installation.md) * [Using the Task Command](./using-task-command.md) - * [Configuration](./config-file.md) * [Reports](./reports.md) * [Tags](./tags.md) + * [Filters](./filters.md) + * [Modifications](./modifications.md) + * [Configuration](./config-file.md) * [Environment](./environment.md) * [Synchronization](./task-sync.md) * [Running the Sync Server](./running-sync-server.md) diff --git a/docs/src/filters.md b/docs/src/filters.md new file mode 100644 index 000000000..e2be669d7 --- /dev/null +++ b/docs/src/filters.md @@ -0,0 +1,9 @@ +# Filters + +Filters are used to select specific tasks for reports or to specify tasks to be modified. +When more than one filter is given, only tasks which match all of the filters are selected. +When no filter is given, the command implicitly selects all tasks. + +Filters can have the following forms: + + diff --git a/docs/src/modifications.md b/docs/src/modifications.md new file mode 100644 index 000000000..017969951 --- /dev/null +++ b/docs/src/modifications.md @@ -0,0 +1,5 @@ +# Modifications + +Modifications can have the following forms: + + diff --git a/docs/src/reports.md b/docs/src/reports.md index 05026da6f..4f106e35b 100644 --- a/docs/src/reports.md +++ b/docs/src/reports.md @@ -49,9 +49,8 @@ columns = [ ] ``` -The filter is a list of filter arguments, just like those that can be used on the command line. -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. +The `filter` property is a list of [filters](./filters.md). +It will be merged with any filters provided on the command line when the report is invoked. 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. @@ -70,11 +69,11 @@ sort = [ The available values of `sort_by` are -(TODO: generate automatically) + 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: -(TODO: generate automatically) + diff --git a/docs/src/using-task-command.md b/docs/src/using-task-command.md index d2e7f0ca0..355dcc898 100644 --- a/docs/src/using-task-command.md +++ b/docs/src/using-task-command.md @@ -4,6 +4,13 @@ The main interface to your tasks is the `ta` command, which supports various sub 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 `ta help`. > NOTE: the `task` interface does not precisely match that of TaskWarrior. + +## Subcommands + +The sections below describe each subcommand of the `ta` command. +The syntax of `[filter]` is defined in [filters](./filters.md), and that of `[modification]` in [modifications](./modifications.md). +You can also find a summary of all subcommands, as well as filters, built-in reports, and so on, with `ta help`. + + From d4f669ed6e492585de63b200b3c503d73d298c7a Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sun, 30 May 2021 16:04:24 -0400 Subject: [PATCH 08/34] Define and test an MSRV --- .../workflows/{rust-tests.yml => checks.yml} | 31 ++----------- .github/workflows/tests.yml | 43 +++++++++++++++++++ taskchampion/src/lib.rs | 4 ++ 3 files changed, 50 insertions(+), 28 deletions(-) rename .github/workflows/{rust-tests.yml => checks.yml} (73%) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/checks.yml similarity index 73% rename from .github/workflows/rust-tests.yml rename to .github/workflows/checks.yml index ab56e3c39..ea268986d 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/checks.yml @@ -8,8 +8,9 @@ on: types: [opened, reopened, synchronize] jobs: - test: + clippy: runs-on: ubuntu-latest + name: "Clippy" steps: - uses: actions/checkout@v1 @@ -26,37 +27,10 @@ jobs: path: target key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - - uses: actions-rs/cargo@v1.0.1 with: command: check - - name: test - run: cargo test - - clippy: - runs-on: ubuntu-latest - needs: test - - steps: - - uses: actions/checkout@v1 - - - name: Cache cargo registry - uses: actions/cache@v1 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo build - uses: actions/cache@v1 - with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - - run: rustup component add clippy - uses: actions-rs/clippy-check@v1 @@ -66,6 +40,7 @@ jobs: mdbook: runs-on: ubuntu-latest + name: "Documentation" steps: - uses: actions/checkout@v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..e61b5afdc --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,43 @@ +name: taskchampion + +on: + push: + branches: + - main + pull_request: + types: [opened, reopened, synchronize] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + rust: + - "1.47" # MSRV + - "stable" + + name: "Test - Rust ${{ matrix.rust }}" + + steps: + - uses: actions/checkout@v1 + + - name: Cache cargo registry + uses: actions/cache@v1 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-${{ matrix.rust }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v1 + with: + path: target + key: ${{ runner.os }}-${{ matrix.rust }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - uses: actions-rs/toolchain@v1 + with: + toolchain: "${{ matrix.rust }}" + override: true + + - name: test + run: cargo test diff --git a/taskchampion/src/lib.rs b/taskchampion/src/lib.rs index a05b1ab7b..665fe3a64 100644 --- a/taskchampion/src/lib.rs +++ b/taskchampion/src/lib.rs @@ -29,6 +29,10 @@ Users can define their own server impelementations. See the [TaskChampion Book](http://taskchampion.github.com/taskchampion) for more information about the design and usage of the tool. +# Minimum Supported Rust Version + +This crate supports Rust version 1.47 and higher. + */ mod errors; From 9e3646bf842b12015607da3268ace0714c0a8d2d Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sun, 30 May 2021 16:36:20 -0400 Subject: [PATCH 09/34] Summarize tasks nicely in console output --- cli/src/invocation/cmd/modify.rs | 10 +++++++--- cli/src/invocation/mod.rs | 13 ++++++++++++- cli/src/invocation/modify.rs | 6 +----- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/cli/src/invocation/cmd/modify.rs b/cli/src/invocation/cmd/modify.rs index 6f17f0dba..c3fee9a40 100644 --- a/cli/src/invocation/cmd/modify.rs +++ b/cli/src/invocation/cmd/modify.rs @@ -1,5 +1,5 @@ use crate::argparse::{Filter, Modification}; -use crate::invocation::{apply_modification, filtered_tasks}; +use crate::invocation::{apply_modification, filtered_tasks, summarize_task}; use taskchampion::Replica; use termcolor::WriteColor; @@ -12,7 +12,11 @@ pub(crate) fn execute( for task in filtered_tasks(replica, &filter)? { let mut task = task.into_mut(replica); - apply_modification(w, &mut task, &modification)?; + apply_modification(&mut task, &modification)?; + + let task = task.into_immut(); + let summary = summarize_task(replica, &task)?; + writeln!(w, "modified task {}", summary)?; } Ok(()) @@ -51,7 +55,7 @@ mod test { assert_eq!( w.into_string(), - format!("modified task {}\n", task.get_uuid()) + format!("modified task 1 - new description\n") ); } } diff --git a/cli/src/invocation/mod.rs b/cli/src/invocation/mod.rs index 985c4d99e..80c49db75 100644 --- a/cli/src/invocation/mod.rs +++ b/cli/src/invocation/mod.rs @@ -2,7 +2,7 @@ use crate::argparse::{Command, Subcommand}; use crate::settings::Settings; -use taskchampion::{Replica, Server, ServerConfig, StorageConfig, Uuid}; +use taskchampion::{Replica, Server, ServerConfig, StorageConfig, Task, Uuid}; use termcolor::{ColorChoice, StandardStream}; mod cmd; @@ -149,3 +149,14 @@ fn get_writer() -> StandardStream { ColorChoice::Never }) } + +/// Summarize a task in a single line +fn summarize_task(replica: &mut Replica, task: &Task) -> anyhow::Result { + let ws = replica.working_set()?; + let uuid = task.get_uuid(); + if let Some(id) = ws.by_uuid(uuid) { + Ok(format!("{} - {}", id, task.get_description())) + } else { + Ok(format!("{} - {}", uuid, task.get_description())) + } +} diff --git a/cli/src/invocation/modify.rs b/cli/src/invocation/modify.rs index b67620788..cbbb65e1c 100644 --- a/cli/src/invocation/modify.rs +++ b/cli/src/invocation/modify.rs @@ -1,11 +1,9 @@ use crate::argparse::{DescriptionMod, Modification}; use std::convert::TryInto; use taskchampion::TaskMut; -use termcolor::WriteColor; /// Apply the given modification -pub(super) fn apply_modification( - w: &mut W, +pub(super) fn apply_modification( task: &mut TaskMut, modification: &Modification, ) -> anyhow::Result<()> { @@ -42,7 +40,5 @@ pub(super) fn apply_modification( task.remove_tag(&tag)?; } - writeln!(w, "modified task {}", task.get_uuid())?; - Ok(()) } From e81a078506110ef69a10df5416b095855d71a14b Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sun, 30 May 2021 19:01:16 -0400 Subject: [PATCH 10/34] Create SECURITY.md based on POLICY.md --- POLICY.md | 10 +--------- SECURITY.md | 11 +++++++++++ 2 files changed, 12 insertions(+), 9 deletions(-) create mode 100644 SECURITY.md diff --git a/POLICY.md b/POLICY.md index fb673155d..3d84cbb82 100644 --- a/POLICY.md +++ b/POLICY.md @@ -42,12 +42,4 @@ Considered to be part of the API policy. # 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. +See [SECURITY.md](./SECURITY.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..9d8d975d9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# 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 kindly 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. From e977fb294c0b14cf6e65e289cda367caef560b39 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sun, 23 May 2021 18:16:11 -0400 Subject: [PATCH 11/34] Implement modifying tasks' "wait" value --- Cargo.lock | 2 ++ cli/Cargo.toml | 2 ++ cli/src/argparse/args.rs | 37 +++++++++++++++++++ cli/src/argparse/mod.rs | 7 ++++ cli/src/argparse/modification.rs | 62 +++++++++++++++++++++++++++++--- cli/src/invocation/modify.rs | 4 +++ docs/src/tasks.md | 1 + taskchampion/src/task.rs | 55 ++++++++++++++++++++++++++++ 8 files changed, 166 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 144f8e56f..6499dfbb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2809,8 +2809,10 @@ dependencies = [ "anyhow", "assert_cmd", "atty", + "chrono", "dirs-next", "env_logger 0.8.3", + "lazy_static", "log", "mdbook", "nom", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 591d068a9..53c26dd2b 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -22,6 +22,8 @@ termcolor = "^1.1.2" atty = "^0.2.14" toml = "^0.5.8" toml_edit = "^0.2.0" +chrono = "*" +lazy_static = "1" # only needed for usage-docs mdbook = { version = "0.4", optional = true } diff --git a/cli/src/argparse/args.rs b/cli/src/argparse/args.rs index a902fd690..fceea572a 100644 --- a/cli/src/argparse/args.rs +++ b/cli/src/argparse/args.rs @@ -1,5 +1,7 @@ //! Parsers for argument lists -- arrays of strings use super::ArgList; +use super::NOW; +use chrono::prelude::*; use nom::bytes::complete::tag as nomtag; use nom::{ branch::*, @@ -67,6 +69,30 @@ pub(super) fn status_colon(input: &str) -> IResult<&str, Status> { map_res(colon_prefixed("status"), to_status)(input) } +/// Recognizes timestamps +pub(super) fn timestamp(input: &str) -> IResult<&str, DateTime> { + // TODO: full relative date language supported by TW + fn nn_d_to_timestamp(input: &str) -> Result, ()> { + // TODO: don't unwrap + Ok(*NOW + chrono::Duration::days(input.parse().unwrap())) + } + map_res(terminated(digit1, char('d')), nn_d_to_timestamp)(input) +} + +/// Recognizes `wait:` to None and `wait:` to `Some(ts)` +pub(super) fn wait_colon(input: &str) -> IResult<&str, Option>> { + fn to_wait(input: DateTime) -> Result>, ()> { + Ok(Some(input)) + } + fn to_none(_: &str) -> Result>, ()> { + Ok(None) + } + preceded( + nomtag("wait:"), + alt((map_res(timestamp, to_wait), map_res(nomtag(""), to_none))), + )(input) +} + /// Recognizes a comma-separated list of TaskIds pub(super) fn id_list(input: &str) -> IResult<&str, Vec> { fn hex_n(n: usize) -> impl Fn(&str) -> IResult<&str, &str> { @@ -237,6 +263,17 @@ mod test { assert!(minus_tag("-1abc").is_err()); } + #[test] + fn test_wait() { + assert_eq!(wait_colon("wait:").unwrap(), ("", None)); + + let one_day = *NOW + chrono::Duration::days(1); + assert_eq!(wait_colon("wait:1d").unwrap(), ("", Some(one_day))); + + let one_day = *NOW + chrono::Duration::days(1); + assert_eq!(wait_colon("wait:1d2").unwrap(), ("2", Some(one_day))); + } + #[test] fn test_literal() { assert_eq!(literal("list")("list").unwrap().1, "list"); diff --git a/cli/src/argparse/mod.rs b/cli/src/argparse/mod.rs index 88de59046..f837b0ecf 100644 --- a/cli/src/argparse/mod.rs +++ b/cli/src/argparse/mod.rs @@ -31,9 +31,16 @@ pub(crate) use modification::{DescriptionMod, Modification}; pub(crate) use subcommand::Subcommand; use crate::usage::Usage; +use chrono::prelude::*; +use lazy_static::lazy_static; type ArgList<'a> = &'a [&'a str]; +lazy_static! { + // A static value of NOW to make tests easier + pub(super) static ref NOW: DateTime = Utc::now(); +} + pub(crate) fn get_usage(usage: &mut Usage) { Subcommand::get_usage(usage); Filter::get_usage(usage); diff --git a/cli/src/argparse/modification.rs b/cli/src/argparse/modification.rs index 9628f2352..5b3cac3df 100644 --- a/cli/src/argparse/modification.rs +++ b/cli/src/argparse/modification.rs @@ -1,6 +1,7 @@ -use super::args::{any, arg_matching, minus_tag, plus_tag}; +use super::args::{any, arg_matching, minus_tag, plus_tag, wait_colon}; use super::ArgList; use crate::usage; +use chrono::prelude::*; use nom::{branch::alt, combinator::*, multi::fold_many0, IResult}; use std::collections::HashSet; use taskchampion::Status; @@ -36,6 +37,9 @@ pub struct Modification { /// Set the status pub status: Option, + /// Set (or, with `Some(None)`, clear) the wait timestamp + pub wait: Option>>, + /// Set the "active" state, that is, start (true) or stop (false) the task. pub active: Option, @@ -51,6 +55,7 @@ enum ModArg<'a> { Description(&'a str), PlusTag(&'a str), MinusTag(&'a str), + Wait(Option>), } impl Modification { @@ -71,6 +76,9 @@ impl Modification { ModArg::MinusTag(tag) => { acc.remove_tags.insert(tag.to_owned()); } + ModArg::Wait(wait) => { + acc.wait = Some(wait); + } } acc } @@ -78,6 +86,7 @@ impl Modification { alt(( Self::plus_tag, Self::minus_tag, + Self::wait, // this must come last Self::description, )), @@ -109,6 +118,13 @@ impl Modification { map_res(arg_matching(minus_tag), to_modarg)(input) } + fn wait(input: ArgList) -> IResult { + fn to_modarg(input: Option>) -> Result, ()> { + Ok(ModArg::Wait(input)) + } + map_res(arg_matching(wait_colon), to_modarg)(input) + } + pub(super) fn get_usage(u: &mut usage::Usage) { u.modifications.push(usage::Modification { syntax: "DESCRIPTION", @@ -122,14 +138,25 @@ impl Modification { u.modifications.push(usage::Modification { syntax: "+TAG", summary: "Tag task", - description: " - Add the given tag to the task.", + description: "Add the given tag to the task.", }); u.modifications.push(usage::Modification { syntax: "-TAG", summary: "Un-tag task", + description: "Remove the given tag from the task.", + }); + u.modifications.push(usage::Modification { + syntax: "status:{pending,completed,deleted}", + summary: "Set the task's status", + description: "Set the status of the task explicitly.", + }); + u.modifications.push(usage::Modification { + syntax: "wait:", + summary: "Set or unset the task's wait time", description: " - Remove the given tag from the task.", + Set the time before which the task is not actionable and + should not be shown in reports. With `wait:`, the time + is un-set.", }); } } @@ -137,6 +164,7 @@ impl Modification { #[cfg(test)] mod test { use super::*; + use crate::argparse::NOW; #[test] fn test_empty() { @@ -176,6 +204,32 @@ mod test { ); } + #[test] + fn test_set_wait() { + let (input, modification) = Modification::parse(argv!["wait:2d"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + wait: Some(Some(*NOW + chrono::Duration::days(2))), + ..Default::default() + } + ); + } + + #[test] + fn test_unset_wait() { + let (input, modification) = Modification::parse(argv!["wait:"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + wait: Some(None), + ..Default::default() + } + ); + } + #[test] fn test_multi_arg_description() { let (input, modification) = Modification::parse(argv!["new", "desc", "fun"]).unwrap(); diff --git a/cli/src/invocation/modify.rs b/cli/src/invocation/modify.rs index cbbb65e1c..7ef6d758c 100644 --- a/cli/src/invocation/modify.rs +++ b/cli/src/invocation/modify.rs @@ -40,5 +40,9 @@ pub(super) fn apply_modification( task.remove_tag(&tag)?; } + if let Some(wait) = modification.wait { + task.set_wait(wait)?; + } + Ok(()) } diff --git a/docs/src/tasks.md b/docs/src/tasks.md index 8bdf8fa72..ae354c62c 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -32,6 +32,7 @@ The following keys, and key formats, are defined: * `modified` - the time of the last modification of this task * `start.` - either an empty string (representing work on the task to the task that has not been stopped) or a timestamp (representing the time that work stopped) * `tag.` - indicates this task has tag `` (value is an empty string) +* `wait` - indicates the time before which this task should be hidden, as it is not actionable The following are not yet implemented: diff --git a/taskchampion/src/task.rs b/taskchampion/src/task.rs index 07bb19909..2cebf11ae 100644 --- a/taskchampion/src/task.rs +++ b/taskchampion/src/task.rs @@ -211,6 +211,20 @@ impl Task { .unwrap_or("") } + /// Get the wait time. If this value is set, it will be returned, even + /// if it is in the past. + pub fn get_wait(&self) -> Option> { + self.get_timestamp("wait") + } + + /// Determine whether this task is waiting now. + pub fn is_waiting(&self) -> bool { + if let Some(ts) = self.get_wait() { + return ts > Utc::now(); + } + false + } + /// Determine whether this task is active -- that is, that it has been started /// and not stopped. pub fn is_active(&self) -> bool { @@ -275,6 +289,10 @@ impl<'r> TaskMut<'r> { self.set_string("description", Some(description)) } + pub fn set_wait(&mut self, wait: Option>) -> anyhow::Result<()> { + self.set_timestamp("wait", wait) + } + pub fn set_modified(&mut self, modified: DateTime) -> anyhow::Result<()> { self.set_timestamp("modified", Some(modified)) } @@ -452,6 +470,43 @@ mod test { assert!(!task.is_active()); } + #[test] + fn test_wait_not_set() { + let task = Task::new(Uuid::new_v4(), TaskMap::new()); + + assert!(!task.is_waiting()); + assert_eq!(task.get_wait(), None); + } + + #[test] + fn test_wait_in_past() { + let ts = Utc.ymd(1970, 1, 1).and_hms(0, 0, 0); + let task = Task::new( + Uuid::new_v4(), + vec![(String::from("wait"), format!("{}", ts.timestamp()))] + .drain(..) + .collect(), + ); + dbg!(&task); + + assert!(!task.is_waiting()); + assert_eq!(task.get_wait(), Some(ts)); + } + + #[test] + fn test_wait_in_future() { + let ts = Utc.ymd(3000, 1, 1).and_hms(0, 0, 0); + let task = Task::new( + Uuid::new_v4(), + vec![(String::from("wait"), format!("{}", ts.timestamp()))] + .drain(..) + .collect(), + ); + + assert!(task.is_waiting()); + assert_eq!(task.get_wait(), Some(ts)); + } + #[test] fn test_has_tag() { let task = Task::new( From cf078e123390dc02481b83993ea1b83eb2f9e4d9 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sun, 23 May 2021 18:23:37 -0400 Subject: [PATCH 12/34] add 'wait' to the info output --- cli/src/invocation/cmd/info.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/src/invocation/cmd/info.rs b/cli/src/invocation/cmd/info.rs index 5d26213d1..f450b7a6f 100644 --- a/cli/src/invocation/cmd/info.rs +++ b/cli/src/invocation/cmd/info.rs @@ -36,6 +36,9 @@ pub(crate) fn execute( tags.sort(); t.add_row(row![b->"Tags", tags.join(" ")]); } + if task.is_waiting() { + t.add_row(row![b->"Wait", task.get_wait().unwrap()]); + } } t.print(w)?; } From 1aae7e059d4875a08ec28416f9daff6fd7267e97 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sun, 23 May 2021 18:36:45 -0400 Subject: [PATCH 13/34] Add wait to reports, for display and sorting --- cli/src/invocation/report.rs | 53 ++++++++++++++++++++++++++++++++++++ cli/src/settings/report.rs | 13 +++++++++ cli/src/settings/settings.rs | 4 +++ 3 files changed, 70 insertions(+) diff --git a/cli/src/invocation/report.rs b/cli/src/invocation/report.rs index 512866381..cf4008562 100644 --- a/cli/src/invocation/report.rs +++ b/cli/src/invocation/report.rs @@ -27,6 +27,7 @@ fn sort_tasks(tasks: &mut Vec, report: &Report, working_set: &WorkingSet) } SortBy::Uuid => a.get_uuid().cmp(&b.get_uuid()), SortBy::Description => a.get_description().cmp(b.get_description()), + SortBy::Wait => a.get_wait().cmp(&b.get_wait()), }; // If this sort property is equal, go on to the next.. if ord == Ordering::Equal { @@ -71,6 +72,13 @@ fn task_column(task: &Task, column: &Column, working_set: &WorkingSet) -> String tags.sort(); tags.join(" ") } + Property::Wait => { + if task.is_waiting() { + task.get_wait().unwrap().format("%Y-%m-%d").to_string() + } else { + "".to_owned() + } + } } } @@ -124,6 +132,7 @@ mod test { use super::*; use crate::invocation::test::*; use crate::settings::Sort; + use chrono::prelude::*; use std::convert::TryInto; use taskchampion::{Status, Uuid}; @@ -217,6 +226,50 @@ mod test { assert_eq!(got_uuids, exp_uuids); } + #[test] + fn sorting_by_wait() { + let mut replica = test_replica(); + let uuids = create_tasks(&mut replica); + + replica + .get_task(uuids[0]) + .unwrap() + .unwrap() + .into_mut(&mut replica) + .set_wait(Some(Utc::now() + chrono::Duration::days(2))) + .unwrap(); + + replica + .get_task(uuids[1]) + .unwrap() + .unwrap() + .into_mut(&mut replica) + .set_wait(Some(Utc::now() + chrono::Duration::days(3))) + .unwrap(); + + let working_set = replica.working_set().unwrap(); + + let report = Report { + sort: vec![Sort { + ascending: true, + sort_by: SortBy::Wait, + }], + ..Default::default() + }; + + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let got_uuids: Vec<_> = tasks.iter().map(|t| t.get_uuid()).collect(); + + let exp_uuids = vec![ + uuids[2], // no wait + uuids[0], // wait:2d + uuids[1], // wait:3d + ]; + + assert_eq!(got_uuids, exp_uuids); + } + #[test] fn sorting_by_multiple() { let mut replica = test_replica(); diff --git a/cli/src/settings/report.rs b/cli/src/settings/report.rs index f73dfe58d..1911bf84e 100644 --- a/cli/src/settings/report.rs +++ b/cli/src/settings/report.rs @@ -46,6 +46,9 @@ pub(crate) enum Property { /// The task's tags Tags, + + /// The task's wait date + Wait, } /// A sorting criterion for a sort operation. @@ -71,6 +74,9 @@ pub(crate) enum SortBy { /// The task's description Description, + + /// The task's wait date + Wait, } // Conversions from settings::Settings. @@ -174,6 +180,7 @@ impl TryFrom<&toml::Value> for Property { "active" => Property::Active, "description" => Property::Description, "tags" => Property::Tags, + "wait" => Property::Wait, _ => bail!(": unknown property {}", s), }) } @@ -210,6 +217,7 @@ impl TryFrom<&toml::Value> for SortBy { "id" => SortBy::Id, "uuid" => SortBy::Uuid, "description" => SortBy::Description, + "wait" => SortBy::Wait, _ => bail!(": unknown sort_by value `{}`", s), }) } @@ -231,6 +239,11 @@ pub(crate) fn get_usage(u: &mut Usage) { as_sort_by: None, as_column: Some("`*` if the task is active (started)"), }); + u.report_properties.push(usage::ReportProperty { + name: "wait", + as_sort_by: Some("Sort by the task's wait date, with non-waiting tasks first"), + as_column: Some("Wait date of the task"), + }); u.report_properties.push(usage::ReportProperty { name: "description", as_sort_by: Some("Sort by the task's description"), diff --git a/cli/src/settings/settings.rs b/cli/src/settings/settings.rs index b17750fcb..955520299 100644 --- a/cli/src/settings/settings.rs +++ b/cli/src/settings/settings.rs @@ -218,6 +218,10 @@ impl Default for Settings { label: "tags".to_owned(), property: Property::Tags, }, + Column { + label: "wait".to_owned(), + property: Property::Wait, + }, ], filter: Default::default(), }, From 288f29d9d55865436ddbf7ed8adc12b755dd9790 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sat, 29 May 2021 13:50:56 -0400 Subject: [PATCH 14/34] refactor argparse::args into submodules --- cli/src/argparse/args.rs | 346 -------------------------- cli/src/argparse/args/arg_matching.rs | 51 ++++ cli/src/argparse/args/colon.rs | 92 +++++++ cli/src/argparse/args/idlist.rs | 139 +++++++++++ cli/src/argparse/args/misc.rs | 41 +++ cli/src/argparse/args/mod.rs | 13 + cli/src/argparse/args/tags.rs | 56 +++++ 7 files changed, 392 insertions(+), 346 deletions(-) delete mode 100644 cli/src/argparse/args.rs create mode 100644 cli/src/argparse/args/arg_matching.rs create mode 100644 cli/src/argparse/args/colon.rs create mode 100644 cli/src/argparse/args/idlist.rs create mode 100644 cli/src/argparse/args/misc.rs create mode 100644 cli/src/argparse/args/mod.rs create mode 100644 cli/src/argparse/args/tags.rs diff --git a/cli/src/argparse/args.rs b/cli/src/argparse/args.rs deleted file mode 100644 index fceea572a..000000000 --- a/cli/src/argparse/args.rs +++ /dev/null @@ -1,346 +0,0 @@ -//! Parsers for argument lists -- arrays of strings -use super::ArgList; -use super::NOW; -use chrono::prelude::*; -use nom::bytes::complete::tag as nomtag; -use nom::{ - branch::*, - character::complete::*, - combinator::*, - error::{Error, ErrorKind}, - multi::*, - sequence::*, - Err, IResult, -}; -use std::convert::TryFrom; -use taskchampion::{Status, Tag, Uuid}; - -/// A task identifier, as given in a filter command-line expression -#[derive(Debug, PartialEq, Clone)] -pub(crate) enum TaskId { - /// A small integer identifying a working-set task - WorkingSetId(usize), - - /// A full Uuid specifically identifying a task - Uuid(Uuid), - - /// A prefix of a Uuid - PartialUuid(String), -} - -/// Recognizes any argument -pub(super) fn any(input: &str) -> IResult<&str, &str> { - rest(input) -} - -/// Recognizes a report name -pub(super) fn report_name(input: &str) -> IResult<&str, &str> { - all_consuming(recognize(pair(alpha1, alphanumeric0)))(input) -} - -/// Recognizes a literal string -pub(super) fn literal(literal: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { - move |input: &str| all_consuming(nomtag(literal))(input) -} - -/// Recognizes a colon-prefixed pair -pub(super) fn colon_prefixed(prefix: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { - fn to_suffix<'a>(input: (&'a str, char, &'a str)) -> Result<&'a str, ()> { - Ok(input.2) - } - move |input: &str| { - map_res( - all_consuming(tuple((nomtag(prefix), char(':'), any))), - to_suffix, - )(input) - } -} - -/// Recognizes `status:{pending,completed,deleted}` -pub(super) fn status_colon(input: &str) -> IResult<&str, Status> { - fn to_status(input: &str) -> Result { - match input { - "pending" => Ok(Status::Pending), - "completed" => Ok(Status::Completed), - "deleted" => Ok(Status::Deleted), - _ => Err(()), - } - } - map_res(colon_prefixed("status"), to_status)(input) -} - -/// Recognizes timestamps -pub(super) fn timestamp(input: &str) -> IResult<&str, DateTime> { - // TODO: full relative date language supported by TW - fn nn_d_to_timestamp(input: &str) -> Result, ()> { - // TODO: don't unwrap - Ok(*NOW + chrono::Duration::days(input.parse().unwrap())) - } - map_res(terminated(digit1, char('d')), nn_d_to_timestamp)(input) -} - -/// Recognizes `wait:` to None and `wait:` to `Some(ts)` -pub(super) fn wait_colon(input: &str) -> IResult<&str, Option>> { - fn to_wait(input: DateTime) -> Result>, ()> { - Ok(Some(input)) - } - fn to_none(_: &str) -> Result>, ()> { - Ok(None) - } - preceded( - nomtag("wait:"), - alt((map_res(timestamp, to_wait), map_res(nomtag(""), to_none))), - )(input) -} - -/// Recognizes a comma-separated list of TaskIds -pub(super) fn id_list(input: &str) -> IResult<&str, Vec> { - fn hex_n(n: usize) -> impl Fn(&str) -> IResult<&str, &str> { - move |input: &str| recognize(many_m_n(n, n, one_of(&b"0123456789abcdefABCDEF"[..])))(input) - } - fn uuid(input: &str) -> Result { - Ok(TaskId::Uuid(Uuid::parse_str(input).map_err(|_| ())?)) - } - fn partial_uuid(input: &str) -> Result { - Ok(TaskId::PartialUuid(input.to_owned())) - } - fn working_set_id(input: &str) -> Result { - Ok(TaskId::WorkingSetId(input.parse().map_err(|_| ())?)) - } - all_consuming(separated_list1( - char(','), - alt(( - map_res( - recognize(tuple(( - hex_n(8), - char('-'), - hex_n(4), - char('-'), - hex_n(4), - char('-'), - hex_n(4), - char('-'), - hex_n(12), - ))), - uuid, - ), - map_res( - recognize(tuple(( - hex_n(8), - char('-'), - hex_n(4), - char('-'), - hex_n(4), - char('-'), - hex_n(4), - ))), - partial_uuid, - ), - map_res( - recognize(tuple((hex_n(8), char('-'), hex_n(4), char('-'), hex_n(4)))), - partial_uuid, - ), - map_res( - recognize(tuple((hex_n(8), char('-'), hex_n(4)))), - partial_uuid, - ), - map_res(hex_n(8), partial_uuid), - // note that an 8-decimal-digit value will be treated as a UUID - map_res(digit1, working_set_id), - )), - ))(input) -} - -/// Recognizes a tag prefixed with `+` and returns the tag value -pub(super) fn plus_tag(input: &str) -> IResult<&str, &str> { - fn to_tag(input: (char, &str)) -> Result<&str, ()> { - Ok(input.1) - } - map_res( - all_consuming(tuple(( - char('+'), - recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())), - ))), - to_tag, - )(input) -} - -/// Recognizes a tag prefixed with `-` and returns the tag value -pub(super) fn minus_tag(input: &str) -> IResult<&str, &str> { - fn to_tag(input: (char, &str)) -> Result<&str, ()> { - Ok(input.1) - } - map_res( - all_consuming(tuple(( - char('-'), - recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())), - ))), - to_tag, - )(input) -} - -/// Consume a single argument from an argument list that matches the given string parser (one -/// of the other functions in this module). The given parser must consume the entire input. -pub(super) fn arg_matching<'a, O, F>(f: F) -> impl Fn(ArgList<'a>) -> IResult -where - F: Fn(&'a str) -> IResult<&'a str, O>, -{ - move |input: ArgList<'a>| { - if let Some(arg) = input.get(0) { - return match f(arg) { - Ok(("", rv)) => Ok((&input[1..], rv)), - // single-arg parsers must consume the entire arg - Ok((unconsumed, _)) => panic!("unconsumed argument input {}", unconsumed), - // single-arg parsers are all complete parsers - Err(Err::Incomplete(_)) => unreachable!(), - // for error and failure, rewrite to an error at this position in the arugment list - Err(Err::Error(Error { input: _, code })) => Err(Err::Error(Error { input, code })), - Err(Err::Failure(Error { input: _, code })) => { - Err(Err::Failure(Error { input, code })) - } - }; - } - - Err(Err::Error(Error { - input, - // since we're using nom's built-in Error, our choices here are limited, but tihs - // occurs when there's no argument where one is expected, so Eof seems appropriate - code: ErrorKind::Eof, - })) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_arg_matching() { - assert_eq!( - arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(), - (argv!["bar"], "foo") - ); - assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err()); - } - - #[test] - fn test_colon_prefixed() { - assert_eq!(colon_prefixed("foo")("foo:abc").unwrap().1, "abc"); - assert_eq!(colon_prefixed("foo")("foo:").unwrap().1, ""); - assert!(colon_prefixed("foo")("foo").is_err()); - } - - #[test] - fn test_status_colon() { - assert_eq!(status_colon("status:pending").unwrap().1, Status::Pending); - assert_eq!( - status_colon("status:completed").unwrap().1, - Status::Completed - ); - assert_eq!(status_colon("status:deleted").unwrap().1, Status::Deleted); - assert!(status_colon("status:foo").is_err()); - assert!(status_colon("status:complete").is_err()); - assert!(status_colon("status").is_err()); - } - - #[test] - fn test_plus_tag() { - assert_eq!(plus_tag("+abc").unwrap().1, "abc"); - assert_eq!(plus_tag("+abc123").unwrap().1, "abc123"); - assert!(plus_tag("-abc123").is_err()); - assert!(plus_tag("+abc123 ").is_err()); - assert!(plus_tag(" +abc123").is_err()); - assert!(plus_tag("+1abc").is_err()); - } - - #[test] - fn test_minus_tag() { - assert_eq!(minus_tag("-abc").unwrap().1, "abc"); - assert_eq!(minus_tag("-abc123").unwrap().1, "abc123"); - assert!(minus_tag("+abc123").is_err()); - assert!(minus_tag("-abc123 ").is_err()); - assert!(minus_tag(" -abc123").is_err()); - assert!(minus_tag("-1abc").is_err()); - } - - #[test] - fn test_wait() { - assert_eq!(wait_colon("wait:").unwrap(), ("", None)); - - let one_day = *NOW + chrono::Duration::days(1); - assert_eq!(wait_colon("wait:1d").unwrap(), ("", Some(one_day))); - - let one_day = *NOW + chrono::Duration::days(1); - assert_eq!(wait_colon("wait:1d2").unwrap(), ("2", Some(one_day))); - } - - #[test] - fn test_literal() { - assert_eq!(literal("list")("list").unwrap().1, "list"); - assert!(literal("list")("listicle").is_err()); - assert!(literal("list")(" list ").is_err()); - assert!(literal("list")("LiSt").is_err()); - assert!(literal("list")("denylist").is_err()); - } - - #[test] - fn test_id_list_single() { - assert_eq!(id_list("123").unwrap().1, vec![TaskId::WorkingSetId(123)]); - } - - #[test] - fn test_id_list_uuids() { - assert_eq!( - id_list("12341234").unwrap().1, - vec![TaskId::PartialUuid(s!("12341234"))] - ); - assert_eq!( - id_list("1234abcd").unwrap().1, - vec![TaskId::PartialUuid(s!("1234abcd"))] - ); - assert_eq!( - id_list("abcd1234").unwrap().1, - vec![TaskId::PartialUuid(s!("abcd1234"))] - ); - assert_eq!( - id_list("abcd1234-1234").unwrap().1, - vec![TaskId::PartialUuid(s!("abcd1234-1234"))] - ); - assert_eq!( - id_list("abcd1234-1234-2345").unwrap().1, - vec![TaskId::PartialUuid(s!("abcd1234-1234-2345"))] - ); - assert_eq!( - id_list("abcd1234-1234-2345-3456").unwrap().1, - vec![TaskId::PartialUuid(s!("abcd1234-1234-2345-3456"))] - ); - assert_eq!( - id_list("abcd1234-1234-2345-3456-0123456789ab").unwrap().1, - vec![TaskId::Uuid( - Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap() - )] - ); - } - - #[test] - fn test_id_list_invalid_partial_uuids() { - assert!(id_list("abcd123").is_err()); - assert!(id_list("abcd12345").is_err()); - assert!(id_list("abcd1234-").is_err()); - assert!(id_list("abcd1234-123").is_err()); - assert!(id_list("abcd1234-1234-").is_err()); - assert!(id_list("abcd1234-12345-").is_err()); - assert!(id_list("abcd1234-1234-2345-3456-0123456789ab-").is_err()); - } - - #[test] - fn test_id_list_uuids_mixed() { - assert_eq!(id_list("abcd1234,abcd1234-1234,abcd1234-1234-2345,abcd1234-1234-2345-3456,abcd1234-1234-2345-3456-0123456789ab").unwrap().1, - vec![TaskId::PartialUuid(s!("abcd1234")), - TaskId::PartialUuid(s!("abcd1234-1234")), - TaskId::PartialUuid(s!("abcd1234-1234-2345")), - TaskId::PartialUuid(s!("abcd1234-1234-2345-3456")), - TaskId::Uuid(Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap()), - ]); - } -} diff --git a/cli/src/argparse/args/arg_matching.rs b/cli/src/argparse/args/arg_matching.rs new file mode 100644 index 000000000..e1161738e --- /dev/null +++ b/cli/src/argparse/args/arg_matching.rs @@ -0,0 +1,51 @@ +use crate::argparse::ArgList; +use nom::{ + error::{Error, ErrorKind}, + Err, IResult, +}; + +/// Consume a single argument from an argument list that matches the given string parser (one +/// of the other functions in this module). The given parser must consume the entire input. +pub(crate) fn arg_matching<'a, O, F>(f: F) -> impl Fn(ArgList<'a>) -> IResult +where + F: Fn(&'a str) -> IResult<&'a str, O>, +{ + move |input: ArgList<'a>| { + if let Some(arg) = input.get(0) { + return match f(arg) { + Ok(("", rv)) => Ok((&input[1..], rv)), + // single-arg parsers must consume the entire arg + Ok((unconsumed, _)) => panic!("unconsumed argument input {}", unconsumed), + // single-arg parsers are all complete parsers + Err(Err::Incomplete(_)) => unreachable!(), + // for error and failure, rewrite to an error at this position in the arugment list + Err(Err::Error(Error { input: _, code })) => Err(Err::Error(Error { input, code })), + Err(Err::Failure(Error { input: _, code })) => { + Err(Err::Failure(Error { input, code })) + } + }; + } + + Err(Err::Error(Error { + input, + // since we're using nom's built-in Error, our choices here are limited, but tihs + // occurs when there's no argument where one is expected, so Eof seems appropriate + code: ErrorKind::Eof, + })) + } +} + +#[cfg(test)] +mod test { + use super::super::*; + use super::*; + + #[test] + fn test_arg_matching() { + assert_eq!( + arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(), + (argv!["bar"], "foo") + ); + assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err()); + } +} diff --git a/cli/src/argparse/args/colon.rs b/cli/src/argparse/args/colon.rs new file mode 100644 index 000000000..8dde7c74c --- /dev/null +++ b/cli/src/argparse/args/colon.rs @@ -0,0 +1,92 @@ +use super::any; +use crate::argparse::NOW; +use chrono::prelude::*; +use nom::bytes::complete::tag as nomtag; +use nom::{branch::*, character::complete::*, combinator::*, sequence::*, IResult}; +use taskchampion::Status; + +/// Recognizes a colon-prefixed pair +fn colon_prefixed(prefix: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { + fn to_suffix<'a>(input: (&'a str, char, &'a str)) -> Result<&'a str, ()> { + Ok(input.2) + } + move |input: &str| { + map_res( + all_consuming(tuple((nomtag(prefix), char(':'), any))), + to_suffix, + )(input) + } +} + +/// Recognizes `status:{pending,completed,deleted}` +pub(crate) fn status_colon(input: &str) -> IResult<&str, Status> { + fn to_status(input: &str) -> Result { + match input { + "pending" => Ok(Status::Pending), + "completed" => Ok(Status::Completed), + "deleted" => Ok(Status::Deleted), + _ => Err(()), + } + } + map_res(colon_prefixed("status"), to_status)(input) +} + +/// Recognizes timestamps +pub(crate) fn timestamp(input: &str) -> IResult<&str, DateTime> { + // TODO: full relative date language supported by TW + fn nn_d_to_timestamp(input: &str) -> Result, ()> { + // TODO: don't unwrap + Ok(*NOW + chrono::Duration::days(input.parse().unwrap())) + } + map_res(terminated(digit1, char('d')), nn_d_to_timestamp)(input) +} + +/// Recognizes `wait:` to None and `wait:` to `Some(ts)` +pub(crate) fn wait_colon(input: &str) -> IResult<&str, Option>> { + fn to_wait(input: DateTime) -> Result>, ()> { + Ok(Some(input)) + } + fn to_none(_: &str) -> Result>, ()> { + Ok(None) + } + preceded( + nomtag("wait:"), + alt((map_res(timestamp, to_wait), map_res(nomtag(""), to_none))), + )(input) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_colon_prefixed() { + assert_eq!(colon_prefixed("foo")("foo:abc").unwrap().1, "abc"); + assert_eq!(colon_prefixed("foo")("foo:").unwrap().1, ""); + assert!(colon_prefixed("foo")("foo").is_err()); + } + + #[test] + fn test_status_colon() { + assert_eq!(status_colon("status:pending").unwrap().1, Status::Pending); + assert_eq!( + status_colon("status:completed").unwrap().1, + Status::Completed + ); + assert_eq!(status_colon("status:deleted").unwrap().1, Status::Deleted); + assert!(status_colon("status:foo").is_err()); + assert!(status_colon("status:complete").is_err()); + assert!(status_colon("status").is_err()); + } + + #[test] + fn test_wait() { + assert_eq!(wait_colon("wait:").unwrap(), ("", None)); + + let one_day = *NOW + chrono::Duration::days(1); + assert_eq!(wait_colon("wait:1d").unwrap(), ("", Some(one_day))); + + let one_day = *NOW + chrono::Duration::days(1); + assert_eq!(wait_colon("wait:1d2").unwrap(), ("2", Some(one_day))); + } +} diff --git a/cli/src/argparse/args/idlist.rs b/cli/src/argparse/args/idlist.rs new file mode 100644 index 000000000..f8c09ae04 --- /dev/null +++ b/cli/src/argparse/args/idlist.rs @@ -0,0 +1,139 @@ +use nom::{branch::*, character::complete::*, combinator::*, multi::*, sequence::*, IResult}; +use taskchampion::Uuid; + +/// A task identifier, as given in a filter command-line expression +#[derive(Debug, PartialEq, Clone)] +pub(crate) enum TaskId { + /// A small integer identifying a working-set task + WorkingSetId(usize), + + /// A full Uuid specifically identifying a task + Uuid(Uuid), + + /// A prefix of a Uuid + PartialUuid(String), +} + +/// Recognizes a comma-separated list of TaskIds +pub(crate) fn id_list(input: &str) -> IResult<&str, Vec> { + fn hex_n(n: usize) -> impl Fn(&str) -> IResult<&str, &str> { + move |input: &str| recognize(many_m_n(n, n, one_of(&b"0123456789abcdefABCDEF"[..])))(input) + } + fn uuid(input: &str) -> Result { + Ok(TaskId::Uuid(Uuid::parse_str(input).map_err(|_| ())?)) + } + fn partial_uuid(input: &str) -> Result { + Ok(TaskId::PartialUuid(input.to_owned())) + } + fn working_set_id(input: &str) -> Result { + Ok(TaskId::WorkingSetId(input.parse().map_err(|_| ())?)) + } + all_consuming(separated_list1( + char(','), + alt(( + map_res( + recognize(tuple(( + hex_n(8), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + char('-'), + hex_n(12), + ))), + uuid, + ), + map_res( + recognize(tuple(( + hex_n(8), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + ))), + partial_uuid, + ), + map_res( + recognize(tuple((hex_n(8), char('-'), hex_n(4), char('-'), hex_n(4)))), + partial_uuid, + ), + map_res( + recognize(tuple((hex_n(8), char('-'), hex_n(4)))), + partial_uuid, + ), + map_res(hex_n(8), partial_uuid), + // note that an 8-decimal-digit value will be treated as a UUID + map_res(digit1, working_set_id), + )), + ))(input) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_id_list_single() { + assert_eq!(id_list("123").unwrap().1, vec![TaskId::WorkingSetId(123)]); + } + + #[test] + fn test_id_list_uuids() { + assert_eq!( + id_list("12341234").unwrap().1, + vec![TaskId::PartialUuid(s!("12341234"))] + ); + assert_eq!( + id_list("1234abcd").unwrap().1, + vec![TaskId::PartialUuid(s!("1234abcd"))] + ); + assert_eq!( + id_list("abcd1234").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234"))] + ); + assert_eq!( + id_list("abcd1234-1234").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234-1234"))] + ); + assert_eq!( + id_list("abcd1234-1234-2345").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234-1234-2345"))] + ); + assert_eq!( + id_list("abcd1234-1234-2345-3456").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234-1234-2345-3456"))] + ); + assert_eq!( + id_list("abcd1234-1234-2345-3456-0123456789ab").unwrap().1, + vec![TaskId::Uuid( + Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap() + )] + ); + } + + #[test] + fn test_id_list_invalid_partial_uuids() { + assert!(id_list("abcd123").is_err()); + assert!(id_list("abcd12345").is_err()); + assert!(id_list("abcd1234-").is_err()); + assert!(id_list("abcd1234-123").is_err()); + assert!(id_list("abcd1234-1234-").is_err()); + assert!(id_list("abcd1234-12345-").is_err()); + assert!(id_list("abcd1234-1234-2345-3456-0123456789ab-").is_err()); + } + + #[test] + fn test_id_list_uuids_mixed() { + assert_eq!(id_list("abcd1234,abcd1234-1234,abcd1234-1234-2345,abcd1234-1234-2345-3456,abcd1234-1234-2345-3456-0123456789ab").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234")), + TaskId::PartialUuid(s!("abcd1234-1234")), + TaskId::PartialUuid(s!("abcd1234-1234-2345")), + TaskId::PartialUuid(s!("abcd1234-1234-2345-3456")), + TaskId::Uuid(Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap()), + ]); + } +} diff --git a/cli/src/argparse/args/misc.rs b/cli/src/argparse/args/misc.rs new file mode 100644 index 000000000..006a0b939 --- /dev/null +++ b/cli/src/argparse/args/misc.rs @@ -0,0 +1,41 @@ +use nom::bytes::complete::tag as nomtag; +use nom::{character::complete::*, combinator::*, sequence::*, IResult}; + +/// Recognizes any argument +pub(crate) fn any(input: &str) -> IResult<&str, &str> { + rest(input) +} + +/// Recognizes a report name +pub(crate) fn report_name(input: &str) -> IResult<&str, &str> { + all_consuming(recognize(pair(alpha1, alphanumeric0)))(input) +} + +/// Recognizes a literal string +pub(crate) fn literal(literal: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { + move |input: &str| all_consuming(nomtag(literal))(input) +} + +#[cfg(test)] +mod test { + use super::super::*; + use super::*; + + #[test] + fn test_arg_matching() { + assert_eq!( + arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(), + (argv!["bar"], "foo") + ); + assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err()); + } + + #[test] + fn test_literal() { + assert_eq!(literal("list")("list").unwrap().1, "list"); + assert!(literal("list")("listicle").is_err()); + assert!(literal("list")(" list ").is_err()); + assert!(literal("list")("LiSt").is_err()); + assert!(literal("list")("denylist").is_err()); + } +} diff --git a/cli/src/argparse/args/mod.rs b/cli/src/argparse/args/mod.rs new file mode 100644 index 000000000..8beaf08c1 --- /dev/null +++ b/cli/src/argparse/args/mod.rs @@ -0,0 +1,13 @@ +//! Parsers for single arguments (strings) + +mod arg_matching; +mod colon; +mod idlist; +mod misc; +mod tags; + +pub(crate) use arg_matching::arg_matching; +pub(crate) use colon::{status_colon, wait_colon}; +pub(crate) use idlist::{id_list, TaskId}; +pub(crate) use misc::{any, literal, report_name}; +pub(crate) use tags::{minus_tag, plus_tag}; diff --git a/cli/src/argparse/args/tags.rs b/cli/src/argparse/args/tags.rs new file mode 100644 index 000000000..8c2cbd9c1 --- /dev/null +++ b/cli/src/argparse/args/tags.rs @@ -0,0 +1,56 @@ +use nom::{character::complete::*, combinator::*, sequence::*, IResult}; +use std::convert::TryFrom; +use taskchampion::Tag; + +/// Recognizes a tag prefixed with `+` and returns the tag value +pub(crate) fn plus_tag(input: &str) -> IResult<&str, &str> { + fn to_tag(input: (char, &str)) -> Result<&str, ()> { + Ok(input.1) + } + map_res( + all_consuming(tuple(( + char('+'), + recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())), + ))), + to_tag, + )(input) +} + +/// Recognizes a tag prefixed with `-` and returns the tag value +pub(crate) fn minus_tag(input: &str) -> IResult<&str, &str> { + fn to_tag(input: (char, &str)) -> Result<&str, ()> { + Ok(input.1) + } + map_res( + all_consuming(tuple(( + char('-'), + recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())), + ))), + to_tag, + )(input) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_plus_tag() { + assert_eq!(plus_tag("+abc").unwrap().1, "abc"); + assert_eq!(plus_tag("+abc123").unwrap().1, "abc123"); + assert!(plus_tag("-abc123").is_err()); + assert!(plus_tag("+abc123 ").is_err()); + assert!(plus_tag(" +abc123").is_err()); + assert!(plus_tag("+1abc").is_err()); + } + + #[test] + fn test_minus_tag() { + assert_eq!(minus_tag("-abc").unwrap().1, "abc"); + assert_eq!(minus_tag("-abc123").unwrap().1, "abc123"); + assert!(minus_tag("+abc123").is_err()); + assert!(minus_tag("-abc123 ").is_err()); + assert!(minus_tag(" -abc123").is_err()); + assert!(minus_tag("-1abc").is_err()); + } +} From 0259a5e2e223a9e20090708d45821576cb19137e Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sat, 29 May 2021 18:39:13 -0400 Subject: [PATCH 15/34] parse durations and timestamps --- Cargo.lock | 74 ++++- cli/Cargo.toml | 4 +- cli/src/argparse/args/colon.rs | 17 +- cli/src/argparse/args/mod.rs | 3 + cli/src/argparse/args/time.rs | 529 +++++++++++++++++++++++++++++++ cli/src/argparse/mod.rs | 6 +- cli/src/argparse/modification.rs | 2 +- docs/src/SUMMARY.md | 1 + docs/src/time.md | 36 +++ 9 files changed, 649 insertions(+), 23 deletions(-) create mode 100644 cli/src/argparse/args/time.rs create mode 100644 docs/src/time.md diff --git a/Cargo.lock b/Cargo.lock index 6499dfbb7..d5b7e61c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1415,6 +1415,15 @@ dependencies = [ "winreg", ] +[[package]] +name = "iso8601-duration" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b51dd97fa24074214b9eb14da518957573f4dec3189112610ae1ccec9ac464" +dependencies = [ + "nom 5.1.2", +] + [[package]] name = "itoa" version = "0.4.7" @@ -1776,6 +1785,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" +[[package]] +name = "nom" +version = "5.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af" +dependencies = [ + "lexical-core", + "memchr", + "version_check", +] + [[package]] name = "nom" version = "6.1.2" @@ -2413,6 +2433,19 @@ dependencies = [ "serde", ] +[[package]] +name = "rstest" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "041bb0202c14f6a158bbbf086afb03d0c6e975c2dec7d4912f8061ed44f290af" +dependencies = [ + "cfg-if 1.0.0", + "proc-macro2", + "quote", + "rustc_version 0.3.3", + "syn", +] + [[package]] name = "rust-argon2" version = "0.8.3" @@ -2431,7 +2464,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" dependencies = [ - "semver", + "semver 0.9.0", +] + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", ] [[package]] @@ -2502,7 +2544,16 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" dependencies = [ - "semver-parser", + "semver-parser 0.7.0", +] + +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser 0.10.2", ] [[package]] @@ -2511,6 +2562,15 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" +[[package]] +name = "semver-parser" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0bef5b7f9e0df16536d3961cfb6e84331c065b4066afb39768d0e319411f7" +dependencies = [ + "pest", +] + [[package]] name = "serde" version = "1.0.125" @@ -2675,7 +2735,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5" dependencies = [ "discard", - "rustc_version", + "rustc_version 0.2.3", "stdweb-derive", "stdweb-internal-macros", "stdweb-internal-runtime", @@ -2768,9 +2828,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.69" +version = "1.0.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48fe99c6bd8b1cc636890bcc071842de909d902c81ac7dab53ba33c421ab8ffb" +checksum = "a1e8cdbefb79a9a5a65e0db8b47b723ee907b7c7f8496c76a1770b5c310bab82" dependencies = [ "proc-macro2", "quote", @@ -2812,12 +2872,14 @@ dependencies = [ "chrono", "dirs-next", "env_logger 0.8.3", + "iso8601-duration", "lazy_static", "log", "mdbook", - "nom", + "nom 6.1.2", "predicates", "prettytable-rs", + "rstest", "serde_json", "taskchampion", "tempfile", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 53c26dd2b..05cfcb20c 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -22,8 +22,9 @@ termcolor = "^1.1.2" atty = "^0.2.14" toml = "^0.5.8" toml_edit = "^0.2.0" -chrono = "*" +chrono = "0.4" lazy_static = "1" +iso8601-duration = "0.1" # only needed for usage-docs mdbook = { version = "0.4", optional = true } @@ -36,6 +37,7 @@ path = "../taskchampion" assert_cmd = "^1.0.3" predicates = "^1.0.7" tempfile = "3" +rstest = "0.10" [features] usage-docs = [ "mdbook", "serde_json" ] diff --git a/cli/src/argparse/args/colon.rs b/cli/src/argparse/args/colon.rs index 8dde7c74c..ecf1af6c9 100644 --- a/cli/src/argparse/args/colon.rs +++ b/cli/src/argparse/args/colon.rs @@ -1,4 +1,4 @@ -use super::any; +use super::{any, timestamp}; use crate::argparse::NOW; use chrono::prelude::*; use nom::bytes::complete::tag as nomtag; @@ -31,16 +31,6 @@ pub(crate) fn status_colon(input: &str) -> IResult<&str, Status> { map_res(colon_prefixed("status"), to_status)(input) } -/// Recognizes timestamps -pub(crate) fn timestamp(input: &str) -> IResult<&str, DateTime> { - // TODO: full relative date language supported by TW - fn nn_d_to_timestamp(input: &str) -> Result, ()> { - // TODO: don't unwrap - Ok(*NOW + chrono::Duration::days(input.parse().unwrap())) - } - map_res(terminated(digit1, char('d')), nn_d_to_timestamp)(input) -} - /// Recognizes `wait:` to None and `wait:` to `Some(ts)` pub(crate) fn wait_colon(input: &str) -> IResult<&str, Option>> { fn to_wait(input: DateTime) -> Result>, ()> { @@ -51,7 +41,10 @@ pub(crate) fn wait_colon(input: &str) -> IResult<&str, Option>> { } preceded( nomtag("wait:"), - alt((map_res(timestamp, to_wait), map_res(nomtag(""), to_none))), + alt(( + map_res(timestamp(*NOW, Local), to_wait), + map_res(nomtag(""), to_none), + )), )(input) } diff --git a/cli/src/argparse/args/mod.rs b/cli/src/argparse/args/mod.rs index 8beaf08c1..f7124fa29 100644 --- a/cli/src/argparse/args/mod.rs +++ b/cli/src/argparse/args/mod.rs @@ -5,9 +5,12 @@ mod colon; mod idlist; mod misc; mod tags; +mod time; pub(crate) use arg_matching::arg_matching; pub(crate) use colon::{status_colon, wait_colon}; pub(crate) use idlist::{id_list, TaskId}; pub(crate) use misc::{any, literal, report_name}; pub(crate) use tags::{minus_tag, plus_tag}; +#[allow(unused_imports)] +pub(crate) use time::{duration, timestamp}; diff --git a/cli/src/argparse/args/time.rs b/cli/src/argparse/args/time.rs new file mode 100644 index 000000000..f17ebd880 --- /dev/null +++ b/cli/src/argparse/args/time.rs @@ -0,0 +1,529 @@ +use chrono::{prelude::*, Duration}; +use iso8601_duration::Duration as IsoDuration; +use lazy_static::lazy_static; +use nom::{ + branch::*, + bytes::complete::*, + character::complete::*, + character::*, + combinator::*, + error::{Error, ErrorKind}, + multi::*, + sequence::*, + Err, IResult, +}; +use std::str::FromStr; + +// https://taskwarrior.org/docs/dates.html +// https://taskwarrior.org/docs/named_dates.html +// https://taskwarrior.org/docs/durations.html + +/// A case for matching durations. If `.3` is true, then the value can be used +/// without a prefix, e.g., `minute`. If false, it cannot, e.g., `minutes` +#[derive(Debug)] +struct DurationCase(&'static str, Duration, bool); + +// https://github.com/GothenburgBitFactory/libshared/blob/9a5f24e2acb38d05afb8f8e316a966dee196a42a/src/Duration.cpp#L50 +// TODO: use const when chrono supports it +lazy_static! { + static ref DURATION_CASES: Vec = vec![ + DurationCase("annual", Duration::days(365), true), + DurationCase("biannual", Duration::days(730), true), + DurationCase("bimonthly", Duration::days(61), true), + DurationCase("biweekly", Duration::days(14), true), + DurationCase("biyearly", Duration::days(730), true), + DurationCase("daily", Duration::days(1), true), + DurationCase("days", Duration::days(1), false), + DurationCase("day", Duration::days(1), true), + DurationCase("d", Duration::days(1), false), + DurationCase("fortnight", Duration::days(14), true), + DurationCase("hours", Duration::hours(1), false), + DurationCase("hour", Duration::hours(1), true), + DurationCase("hrs", Duration::hours(1), false), + DurationCase("hr", Duration::hours(1), true), + DurationCase("h", Duration::hours(1), false), + DurationCase("minutes", Duration::minutes(1), false), + DurationCase("minute", Duration::minutes(1), true), + DurationCase("mins", Duration::minutes(1), false), + DurationCase("min", Duration::minutes(1), true), + DurationCase("monthly", Duration::days(30), true), + DurationCase("months", Duration::days(30), false), + DurationCase("month", Duration::days(30), true), + DurationCase("mnths", Duration::days(30), false), + DurationCase("mths", Duration::days(30), false), + DurationCase("mth", Duration::days(30), true), + DurationCase("mos", Duration::days(30), false), + DurationCase("mo", Duration::days(30), true), + DurationCase("m", Duration::days(30), false), + DurationCase("quarterly", Duration::days(91), true), + DurationCase("quarters", Duration::days(91), false), + DurationCase("quarter", Duration::days(91), true), + DurationCase("qrtrs", Duration::days(91), false), + DurationCase("qrtr", Duration::days(91), true), + DurationCase("qtrs", Duration::days(91), false), + DurationCase("qtr", Duration::days(91), true), + DurationCase("q", Duration::days(91), false), + DurationCase("semiannual", Duration::days(183), true), + DurationCase("sennight", Duration::days(14), false), + DurationCase("seconds", Duration::seconds(1), false), + DurationCase("second", Duration::seconds(1), true), + DurationCase("secs", Duration::seconds(1), false), + DurationCase("sec", Duration::seconds(1), true), + DurationCase("s", Duration::seconds(1), false), + DurationCase("weekdays", Duration::days(1), true), + DurationCase("weekly", Duration::days(7), true), + DurationCase("weeks", Duration::days(7), false), + DurationCase("week", Duration::days(7), true), + DurationCase("wks", Duration::days(7), false), + DurationCase("wk", Duration::days(7), true), + DurationCase("w", Duration::days(7), false), + DurationCase("yearly", Duration::days(365), true), + DurationCase("years", Duration::days(365), false), + DurationCase("year", Duration::days(365), true), + DurationCase("yrs", Duration::days(365), false), + DurationCase("yr", Duration::days(365), true), + DurationCase("y", Duration::days(365), false), + ]; +} + +/// Parses suffixes like 'min', and 'd'; standalone is true if there is no numeric prefix, in which +/// case plurals (like `days`) are not matched. +fn duration_suffix(has_prefix: bool) -> impl Fn(&str) -> IResult<&str, Duration> { + move |input: &str| { + // Rust wants this to have a default value, but it is not actually used + // because DURATION_CASES has at least one case with case.2 == `true` + let mut res = Err(Err::Failure(Error::new(input, ErrorKind::Tag))); + for case in DURATION_CASES.iter() { + if !case.2 && !has_prefix { + // this case requires a prefix, and input does not have one + continue; + } + res = tag(case.0)(input); + match res { + Ok((i, _)) => { + return Ok((i, case.1)); + } + Err(Err::Error(_)) => { + // recoverable error + continue; + } + Err(e) => { + // irrecoverable error + return Err(e); + } + } + } + + // return the last error + Err(res.unwrap_err()) + } +} +/// Calculate the multiplier for a decimal prefix; this uses integer math +/// where possible, falling back to floating-point math on seconds +fn decimal_prefix_multiplier(input: &str) -> IResult<&str, f64> { + map_res( + // recognize NN or NN.NN + alt((recognize(tuple((digit1, char('.'), digit1))), digit1)), + |input: &str| -> Result::Err> { + let mul = input.parse::()?; + Ok(mul) + }, + )(input) +} + +/// Parse an iso8601 duration, converting it to a [`chrono::Duration`] on the assumption +/// that a year is 365 days and a month is 30 days. +fn iso8601_dur(input: &str) -> IResult<&str, Duration> { + if let Ok(iso_dur) = IsoDuration::parse(input) { + // iso8601_duration uses f32, but f32 underflows seconds for values as small as + // a year. So we upgrade to f64 immediately. f64 has a 53-bit mantissa which can + // represent almost 300 million years without underflow, so it should be adequate. + let days = iso_dur.year as f64 * 365.0 + iso_dur.month as f64 * 30.0 + iso_dur.day as f64; + let hours = days * 24.0 + iso_dur.hour as f64; + let mins = hours * 60.0 + iso_dur.minute as f64; + let secs = mins * 60.0 + iso_dur.second as f64; + let dur = Duration::seconds(secs as i64); + Ok((&input[input.len()..], dur)) + } else { + Err(Err::Error(Error::new(input, ErrorKind::Tag))) + } +} + +/// Recognizes durations +pub(crate) fn duration(input: &str) -> IResult<&str, Duration> { + alt(( + map_res( + tuple(( + decimal_prefix_multiplier, + multispace0, + duration_suffix(true), + )), + |input: (f64, &str, Duration)| -> Result { + // `as i64` is saturating, so for large offsets this will + // just pick an imprecise very-futuristic date + let secs = (input.0 * input.2.num_seconds() as f64) as i64; + Ok(Duration::seconds(secs)) + }, + ), + duration_suffix(false), + iso8601_dur, + ))(input) +} + +/// Parse a rfc3339 datestamp +fn rfc3339_timestamp(input: &str) -> IResult<&str, DateTime> { + if let Ok(dt) = DateTime::parse_from_rfc3339(input) { + // convert to UTC and truncate seconds + let dt = dt.with_timezone(&Utc).trunc_subsecs(0); + Ok((&input[input.len()..], dt)) + } else { + Err(Err::Error(Error::new(input, ErrorKind::Tag))) + } +} + +fn named_date( + now: DateTime, + local: Tz, +) -> impl Fn(&str) -> IResult<&str, DateTime> { + move |input: &str| { + let local_today = now.with_timezone(&local).date(); + let remaining = &input[input.len()..]; + match input { + "yesterday" => Ok((remaining, local_today - Duration::days(1))), + "today" => Ok((remaining, local_today)), + "tomorrow" => Ok((remaining, local_today + Duration::days(1))), + // TODO: lots more! + _ => Err(Err::Error(Error::new(input, ErrorKind::Tag))), + } + .map(|(rem, dt)| (rem, dt.and_hms(0, 0, 0).with_timezone(&Utc))) + } +} + +/// recognize a digit +fn digit(input: &str) -> IResult<&str, char> { + satisfy(|c| is_digit(c as u8))(input) +} + +/// Parse yyyy-mm-dd as the given date, at the local midnight +fn yyyy_mm_dd(local: Tz) -> impl Fn(&str) -> IResult<&str, DateTime> { + move |input: &str| { + fn parse_int(input: &str) -> Result::Err> { + input.parse::() + } + map_res( + tuple(( + map_res(recognize(count(digit, 4)), parse_int::), + char('-'), + map_res(recognize(many_m_n(1, 2, digit)), parse_int::), + char('-'), + map_res(recognize(many_m_n(1, 2, digit)), parse_int::), + )), + |input: (i32, char, u32, char, u32)| -> Result, ()> { + // try to convert, handling out-of-bounds months or days as an error + let ymd = match local.ymd_opt(input.0, input.2, input.4) { + chrono::LocalResult::Single(ymd) => Ok(ymd), + _ => Err(()), + }?; + Ok(ymd.and_hms(0, 0, 0).with_timezone(&Utc)) + }, + )(input) + } +} + +/// Recognizes timestamps +pub(crate) fn timestamp( + now: DateTime, + local: Tz, +) -> impl Fn(&str) -> IResult<&str, DateTime> { + move |input: &str| { + alt(( + // relative time + map_res( + duration, + |duration: Duration| -> Result, ()> { Ok(now + duration) }, + ), + rfc3339_timestamp, + yyyy_mm_dd(local), + value(now, tag("now")), + named_date(now, local), + ))(input) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::argparse::NOW; + use rstest::rstest; + + const M: i64 = 60; + const H: i64 = M * 60; + const DAY: i64 = H * 24; + const MONTH: i64 = DAY * 30; + const YEAR: i64 = DAY * 365; + + // TODO: use const when chrono supports it + lazy_static! { + // India standard time (not an even multiple of hours) + static ref IST: FixedOffset = FixedOffset::east(5 * 3600 + 30 * 60); + // Utc, but as a FixedOffset TimeZone impl + static ref UTC_FO: FixedOffset = FixedOffset::east(0); + // Hawaii + static ref HST: FixedOffset = FixedOffset::west(10 * 3600); + } + + /// test helper to ensure that the entire input is consumed + fn complete_duration(input: &str) -> IResult<&str, Duration> { + all_consuming(duration)(input) + } + + /// test helper to ensure that the entire input is consumed + fn complete_timestamp( + now: DateTime, + local: Tz, + ) -> impl Fn(&str) -> IResult<&str, DateTime> { + move |input: &str| all_consuming(timestamp(now, local))(input) + } + + /// Shorthand day and time + fn dt(y: i32, m: u32, d: u32, hh: u32, mm: u32, ss: u32) -> DateTime { + Utc.ymd(y, m, d).and_hms(hh, mm, ss) + } + + /// Local day and time, parameterized on the timezone + fn ldt( + y: i32, + m: u32, + d: u32, + hh: u32, + mm: u32, + ss: u32, + ) -> Box DateTime> { + Box::new(move |tz| tz.ymd(y, m, d).and_hms(hh, mm, ss).with_timezone(&Utc)) + } + + fn ld(y: i32, m: u32, d: u32) -> Box DateTime> { + ldt(y, m, d, 0, 0, 0) + } + + #[rstest] + #[case::rel_hours_0(dt(2021, 5, 29, 1, 30, 0), "0h", dt(2021, 5, 29, 1, 30, 0))] + #[case::rel_hours_05(dt(2021, 5, 29, 1, 30, 0), "0.5h", dt(2021, 5, 29, 2, 0, 0))] + #[case::rel_hours_no_prefix(dt(2021, 5, 29, 1, 30, 0), "hour", dt(2021, 5, 29, 2, 30, 0))] + #[case::rel_hours_5(dt(2021, 5, 29, 1, 30, 0), "5h", dt(2021, 5, 29, 6, 30, 0))] + #[case::rel_days_0(dt(2021, 5, 29, 1, 30, 0), "0d", dt(2021, 5, 29, 1, 30, 0))] + #[case::rel_days_10(dt(2021, 5, 29, 1, 30, 0), "10d", dt(2021, 6, 8, 1, 30, 0))] + #[case::rfc3339_datetime(*NOW, "2019-10-12T07:20:50.12Z", dt(2019, 10, 12, 7, 20, 50))] + #[case::now(*NOW, "now", *NOW)] + /// Cases where the `local` parameter is ignored + fn test_nonlocal_timestamp( + #[case] now: DateTime, + #[case] input: &'static str, + #[case] output: DateTime, + ) { + let (_, res) = complete_timestamp(now, *IST)(input).unwrap(); + assert_eq!(res, output, "parsing {:?}", input); + } + + #[rstest] + /// Cases where the `local` parameter matters + #[case::yyyy_mm_dd(ld(2000, 1, 1), "2021-01-01", ld(2021, 1, 1))] + #[case::yyyy_m_d(ld(2000, 1, 1), "2021-1-1", ld(2021, 1, 1))] + #[case::yesterday(ld(2021, 3, 1), "yesterday", ld(2021, 2, 28))] + #[case::yesterday_from_evening(ldt(2021, 3, 1, 21, 30, 30), "yesterday", ld(2021, 2, 28))] + #[case::today(ld(2021, 3, 1), "today", ld(2021, 3, 1))] + #[case::today_from_evening(ldt(2021, 3, 1, 21, 30, 30), "today", ld(2021, 3, 1))] + #[case::tomorrow(ld(2021, 3, 1), "tomorrow", ld(2021, 3, 2))] + #[case::tomorow_from_evening(ldt(2021, 3, 1, 21, 30, 30), "tomorrow", ld(2021, 3, 2))] + fn test_local_timestamp( + #[case] now: Box DateTime>, + #[values(*IST, *UTC_FO, *HST)] tz: FixedOffset, + #[case] input: &str, + #[case] output: Box DateTime>, + ) { + let now = now(tz); + let output = output(tz); + let (_, res) = complete_timestamp(now, tz)(input).unwrap(); + assert_eq!( + res, output, + "parsing {:?} relative to {:?} in timezone {:?}", + input, now, tz + ); + } + + #[rstest] + #[case::rfc3339_datetime_bad_month(*NOW, "2019-10-99T07:20:50.12Z")] + #[case::yyyy_mm_dd_bad_month(*NOW, "2019-10-99")] + fn test_timestamp_err(#[case] now: DateTime, #[case] input: &'static str) { + let res = complete_timestamp(now, Utc)(input); + assert!( + res.is_err(), + "expected error parsing {:?}, got {:?}", + input, + res.unwrap() + ); + } + + // All test cases from + // https://github.com/GothenburgBitFactory/libshared/blob/9a5f24e2acb38d05afb8f8e316a966dee196a42a/test/duration.t.cpp#L136 + #[rstest] + #[case("0seconds", 0)] + #[case("2 seconds", 2)] + #[case("10seconds", 10)] + #[case("1.5seconds", 1)] + #[case("0second", 0)] + #[case("2 second", 2)] + #[case("10second", 10)] + #[case("1.5second", 1)] + #[case("0s", 0)] + #[case("2 s", 2)] + #[case("10s", 10)] + #[case("1.5s", 1)] + #[case("0minutes", 0)] + #[case("2 minutes", 2 * M)] + #[case("10minutes", 10 * M)] + #[case("1.5minutes", M + 30)] + #[case("0minute", 0)] + #[case("2 minute", 2 * M)] + #[case("10minute", 10 * M)] + #[case("1.5minute", M + 30)] + #[case("0min", 0)] + #[case("2 min", 2 * M)] + #[case("10min", 10 * M)] + #[case("1.5min", M + 30)] + #[case("0hours", 0)] + #[case("2 hours", 2 * H)] + #[case("10hours", 10 * H)] + #[case("1.5hours", H + 30 * M)] + #[case("0hour", 0)] + #[case("2 hour", 2 * H)] + #[case("10hour", 10 * H)] + #[case("1.5hour", H + 30 * M)] + #[case("0h", 0)] + #[case("2 h", 2 * H)] + #[case("10h", 10 * H)] + #[case("1.5h", H + 30 * M)] + #[case("weekdays", DAY)] + #[case("daily", DAY)] + #[case("0days", 0)] + #[case("2 days", 2 * DAY)] + #[case("10days", 10 * DAY)] + #[case("1.5days", DAY + 12 * H)] + #[case("0day", 0)] + #[case("2 day", 2 * DAY)] + #[case("10day", 10 * DAY)] + #[case("1.5day", DAY + 12 * H)] + #[case("0d", 0)] + #[case("2 d", 2 * DAY)] + #[case("10d", 10 * DAY)] + #[case("1.5d", DAY + 12 * H)] + #[case("weekly", 7 * DAY)] + #[case("0weeks", 0)] + #[case("2 weeks", 14 * DAY)] + #[case("10weeks", 70 * DAY)] + #[case("1.5weeks", 10 * DAY + 12 * H)] + #[case("0week", 0)] + #[case("2 week", 14 * DAY)] + #[case("10week", 70 * DAY)] + #[case("1.5week", 10 * DAY + 12 * H)] + #[case("0w", 0)] + #[case("2 w", 14 * DAY)] + #[case("10w", 70 * DAY)] + #[case("1.5w", 10 * DAY + 12 * H)] + #[case("monthly", 30 * DAY)] + #[case("0months", 0)] + #[case("2 months", 60 * DAY)] + #[case("10months", 300 * DAY)] + #[case("1.5months", 45 * DAY)] + #[case("0month", 0)] + #[case("2 month", 60 * DAY)] + #[case("10month", 300 * DAY)] + #[case("1.5month", 45 * DAY)] + #[case("0mo", 0)] + #[case("2 mo", 60 * DAY)] + #[case("10mo", 300 * DAY)] + #[case("1.5mo", 45 * DAY)] + #[case("quarterly", 91 * DAY)] + #[case("0quarters", 0)] + #[case("2 quarters", 182 * DAY)] + #[case("10quarters", 910 * DAY)] + #[case("1.5quarters", 136 * DAY + 12 * H)] + #[case("0quarter", 0)] + #[case("2 quarter", 182 * DAY)] + #[case("10quarter", 910 * DAY)] + #[case("1.5quarter", 136 * DAY + 12 * H)] + #[case("0q", 0)] + #[case("2 q", 182 * DAY)] + #[case("10q", 910 * DAY)] + #[case("1.5q", 136 * DAY + 12 * H)] + #[case("yearly", YEAR)] + #[case("0years", 0)] + #[case("2 years", 2 * YEAR)] + #[case("10years", 10 * YEAR)] + #[case("1.5years", 547 * DAY + 12 * H)] + #[case("0year", 0)] + #[case("2 year", 2 * YEAR)] + #[case("10year", 10 * YEAR)] + #[case("1.5year", 547 * DAY + 12 * H)] + #[case("0y", 0)] + #[case("2 y", 2 * YEAR)] + #[case("10y", 10 * YEAR)] + #[case("1.5y", 547 * DAY + 12 * H)] + #[case("annual", YEAR)] + #[case("biannual", 2 * YEAR)] + #[case("bimonthly", 61 * DAY)] + #[case("biweekly", 14 * DAY)] + #[case("biyearly", 2 * YEAR)] + #[case("fortnight", 14 * DAY)] + #[case("semiannual", 183 * DAY)] + #[case("0sennight", 0)] + #[case("2 sennight", 28 * DAY)] + #[case("10sennight", 140 * DAY)] + #[case("1.5sennight", 21 * DAY)] + fn test_duration_units(#[case] input: &'static str, #[case] seconds: i64) { + let (_, res) = complete_duration(input).expect(input); + assert_eq!(res.num_seconds(), seconds, "parsing {}", input); + } + + #[rstest] + #[case("years")] + #[case("minutes")] + #[case("eons")] + #[case("P1S")] // missing T + #[case("p1y")] // lower-case + fn test_duration_errors(#[case] input: &'static str) { + let res = complete_duration(input); + assert!( + res.is_err(), + "did not get expected error parsing duration {:?}; got {:?}", + input, + res.unwrap() + ); + } + + // https://github.com/GothenburgBitFactory/libshared/blob/9a5f24e2acb38d05afb8f8e316a966dee196a42a/test/duration.t.cpp#L115 + #[rstest] + #[case("P1Y", YEAR)] + #[case("P1M", MONTH)] + #[case("P1D", DAY)] + #[case("P1Y1M", YEAR + MONTH)] + #[case("P1Y1D", YEAR + DAY)] + #[case("P1M1D", MONTH + DAY)] + #[case("P1Y1M1D", YEAR + MONTH + DAY)] + #[case("PT1H", H)] + #[case("PT1M", M)] + #[case("PT1S", 1)] + #[case("PT1H1M", H + M)] + #[case("PT1H1S", H + 1)] + #[case("PT1M1S", M + 1)] + #[case("PT1H1M1S", H + M + 1)] + #[case("P1Y1M1DT1H1M1S", YEAR + MONTH + DAY + H + M + 1)] + #[case("PT24H", DAY)] + #[case("PT40000000S", 40000000)] + #[case("PT3600S", H)] + #[case("PT60M", H)] + fn test_duration_8601(#[case] input: &'static str, #[case] seconds: i64) { + let (_, res) = complete_duration(input).expect(input); + assert_eq!(res.num_seconds(), seconds, "parsing {}", input); + } +} diff --git a/cli/src/argparse/mod.rs b/cli/src/argparse/mod.rs index f837b0ecf..7f2607631 100644 --- a/cli/src/argparse/mod.rs +++ b/cli/src/argparse/mod.rs @@ -34,13 +34,13 @@ use crate::usage::Usage; use chrono::prelude::*; use lazy_static::lazy_static; -type ArgList<'a> = &'a [&'a str]; - lazy_static! { // A static value of NOW to make tests easier - pub(super) static ref NOW: DateTime = Utc::now(); + pub(crate) static ref NOW: DateTime = Utc::now(); } +type ArgList<'a> = &'a [&'a str]; + pub(crate) fn get_usage(usage: &mut Usage) { Subcommand::get_usage(usage); Filter::get_usage(usage); diff --git a/cli/src/argparse/modification.rs b/cli/src/argparse/modification.rs index 5b3cac3df..dcdc3d303 100644 --- a/cli/src/argparse/modification.rs +++ b/cli/src/argparse/modification.rs @@ -156,7 +156,7 @@ impl Modification { description: " Set the time before which the task is not actionable and should not be shown in reports. With `wait:`, the time - is un-set.", + is un-set. See the documentation for the timestamp syntax.", }); } } diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 822dc4f7f..568eb57cf 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -7,6 +7,7 @@ * [Tags](./tags.md) * [Filters](./filters.md) * [Modifications](./modifications.md) + * [Dates and Durations](./time.md) * [Configuration](./config-file.md) * [Environment](./environment.md) * [Synchronization](./task-sync.md) diff --git a/docs/src/time.md b/docs/src/time.md new file mode 100644 index 000000000..c6dc5e282 --- /dev/null +++ b/docs/src/time.md @@ -0,0 +1,36 @@ +## Timestamps + +Times may be specified in a wide variety of convenient formats. + + * [RFC3339](https://datatracker.ietf.org/doc/html/rfc3339) timestamps, such as `2019-10-12 07:20:50.12Z` + * A date of the format `YYYY-MM-DD` is interpreted as _local_ midnight on the given date. + Single-digit month and day are accepted, but the year must contain four digits. + * `now` refers to the exact current time + * `yesterday`, `today`, and `tomorrow` refer to _local_ midnight on the given day + * Any duration (described below) may be used as a timestamp, and is considered relative to the current time. + +Times are stored internally as UTC. + +## Durations + +Durations can be given in a dizzying array of units. +Each can be preceded by a whole number or a decimal multiplier, e.g., `3days`. +The multiplier is optional with the singular forms of the units; for example `day` is allowed. +Some of the units allow an adjectival form, such as `daily` or `annually`; this form is more readable in some cases, but otherwise has the same meaning. + + * `s`, `sec`, `secs`, `second`, or `seconds` + * `min`, `mins`, `minute`, or `minutes` (note that `m` is a month!) + * `h`, `hr`, `hrs`, `hour`, or `hours` + * `d`, `day`, `days`, `daily`, or `weekdays` (note, weekdays includes weekends!) + * `w`, `wk`, `wks`, `week`, `weeks`, or `weekly` + * `biweekly`, `fornight` or `sennight` (14 days) + * `m`, `mo`, `mos`, `mth`, `mths`, `mnths`, `month`, `months`, or `monthly` (always 30 days, regardless of calendar month) + * `binmonthly` (61 days) + * `q`, `qtr`, `qtrs`, `qrtr`, `qrtrs`, `quarter`, `quarters`, or `quarterly` (91 days) + * `semiannual` (183 days) + * `y`, `yr`, `yrs`, `year`, `years`, `yearly`, or `annual` (365 days, regardless of leap days) + * `biannual` or `biyearly` (730 days) + +[ISO 8601 standard durations](https://en.wikipedia.org/wiki/ISO_8601#Durations) are also allowed. +While the standard does not specify the length of "P1Y" or "P1M", Taskchampion treats those as 365 and 30 days, respectively. + From b18701c3cbfc44c219ba13268d91c4ba772e30e0 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Tue, 1 Jun 2021 09:20:14 -0400 Subject: [PATCH 16/34] remove many duration strings to simplify --- cli/src/argparse/args/time.rs | 63 ----------------------------------- docs/src/time.md | 24 +++++-------- 2 files changed, 9 insertions(+), 78 deletions(-) diff --git a/cli/src/argparse/args/time.rs b/cli/src/argparse/args/time.rs index f17ebd880..4581d0a41 100644 --- a/cli/src/argparse/args/time.rs +++ b/cli/src/argparse/args/time.rs @@ -27,61 +27,27 @@ struct DurationCase(&'static str, Duration, bool); // TODO: use const when chrono supports it lazy_static! { static ref DURATION_CASES: Vec = vec![ - DurationCase("annual", Duration::days(365), true), - DurationCase("biannual", Duration::days(730), true), - DurationCase("bimonthly", Duration::days(61), true), - DurationCase("biweekly", Duration::days(14), true), - DurationCase("biyearly", Duration::days(730), true), - DurationCase("daily", Duration::days(1), true), DurationCase("days", Duration::days(1), false), DurationCase("day", Duration::days(1), true), DurationCase("d", Duration::days(1), false), - DurationCase("fortnight", Duration::days(14), true), DurationCase("hours", Duration::hours(1), false), DurationCase("hour", Duration::hours(1), true), - DurationCase("hrs", Duration::hours(1), false), - DurationCase("hr", Duration::hours(1), true), DurationCase("h", Duration::hours(1), false), DurationCase("minutes", Duration::minutes(1), false), DurationCase("minute", Duration::minutes(1), true), DurationCase("mins", Duration::minutes(1), false), DurationCase("min", Duration::minutes(1), true), - DurationCase("monthly", Duration::days(30), true), DurationCase("months", Duration::days(30), false), DurationCase("month", Duration::days(30), true), - DurationCase("mnths", Duration::days(30), false), - DurationCase("mths", Duration::days(30), false), - DurationCase("mth", Duration::days(30), true), - DurationCase("mos", Duration::days(30), false), DurationCase("mo", Duration::days(30), true), - DurationCase("m", Duration::days(30), false), - DurationCase("quarterly", Duration::days(91), true), - DurationCase("quarters", Duration::days(91), false), - DurationCase("quarter", Duration::days(91), true), - DurationCase("qrtrs", Duration::days(91), false), - DurationCase("qrtr", Duration::days(91), true), - DurationCase("qtrs", Duration::days(91), false), - DurationCase("qtr", Duration::days(91), true), - DurationCase("q", Duration::days(91), false), - DurationCase("semiannual", Duration::days(183), true), - DurationCase("sennight", Duration::days(14), false), DurationCase("seconds", Duration::seconds(1), false), DurationCase("second", Duration::seconds(1), true), - DurationCase("secs", Duration::seconds(1), false), - DurationCase("sec", Duration::seconds(1), true), DurationCase("s", Duration::seconds(1), false), - DurationCase("weekdays", Duration::days(1), true), - DurationCase("weekly", Duration::days(7), true), DurationCase("weeks", Duration::days(7), false), DurationCase("week", Duration::days(7), true), - DurationCase("wks", Duration::days(7), false), - DurationCase("wk", Duration::days(7), true), DurationCase("w", Duration::days(7), false), - DurationCase("yearly", Duration::days(365), true), DurationCase("years", Duration::days(365), false), DurationCase("year", Duration::days(365), true), - DurationCase("yrs", Duration::days(365), false), - DurationCase("yr", Duration::days(365), true), DurationCase("y", Duration::days(365), false), ]; } @@ -403,8 +369,6 @@ mod test { #[case("2 h", 2 * H)] #[case("10h", 10 * H)] #[case("1.5h", H + 30 * M)] - #[case("weekdays", DAY)] - #[case("daily", DAY)] #[case("0days", 0)] #[case("2 days", 2 * DAY)] #[case("10days", 10 * DAY)] @@ -417,7 +381,6 @@ mod test { #[case("2 d", 2 * DAY)] #[case("10d", 10 * DAY)] #[case("1.5d", DAY + 12 * H)] - #[case("weekly", 7 * DAY)] #[case("0weeks", 0)] #[case("2 weeks", 14 * DAY)] #[case("10weeks", 70 * DAY)] @@ -430,7 +393,6 @@ mod test { #[case("2 w", 14 * DAY)] #[case("10w", 70 * DAY)] #[case("1.5w", 10 * DAY + 12 * H)] - #[case("monthly", 30 * DAY)] #[case("0months", 0)] #[case("2 months", 60 * DAY)] #[case("10months", 300 * DAY)] @@ -443,20 +405,6 @@ mod test { #[case("2 mo", 60 * DAY)] #[case("10mo", 300 * DAY)] #[case("1.5mo", 45 * DAY)] - #[case("quarterly", 91 * DAY)] - #[case("0quarters", 0)] - #[case("2 quarters", 182 * DAY)] - #[case("10quarters", 910 * DAY)] - #[case("1.5quarters", 136 * DAY + 12 * H)] - #[case("0quarter", 0)] - #[case("2 quarter", 182 * DAY)] - #[case("10quarter", 910 * DAY)] - #[case("1.5quarter", 136 * DAY + 12 * H)] - #[case("0q", 0)] - #[case("2 q", 182 * DAY)] - #[case("10q", 910 * DAY)] - #[case("1.5q", 136 * DAY + 12 * H)] - #[case("yearly", YEAR)] #[case("0years", 0)] #[case("2 years", 2 * YEAR)] #[case("10years", 10 * YEAR)] @@ -469,17 +417,6 @@ mod test { #[case("2 y", 2 * YEAR)] #[case("10y", 10 * YEAR)] #[case("1.5y", 547 * DAY + 12 * H)] - #[case("annual", YEAR)] - #[case("biannual", 2 * YEAR)] - #[case("bimonthly", 61 * DAY)] - #[case("biweekly", 14 * DAY)] - #[case("biyearly", 2 * YEAR)] - #[case("fortnight", 14 * DAY)] - #[case("semiannual", 183 * DAY)] - #[case("0sennight", 0)] - #[case("2 sennight", 28 * DAY)] - #[case("10sennight", 140 * DAY)] - #[case("1.5sennight", 21 * DAY)] fn test_duration_units(#[case] input: &'static str, #[case] seconds: i64) { let (_, res) = complete_duration(input).expect(input); assert_eq!(res.num_seconds(), seconds, "parsing {}", input); diff --git a/docs/src/time.md b/docs/src/time.md index c6dc5e282..b82e1b2e1 100644 --- a/docs/src/time.md +++ b/docs/src/time.md @@ -3,10 +3,10 @@ Times may be specified in a wide variety of convenient formats. * [RFC3339](https://datatracker.ietf.org/doc/html/rfc3339) timestamps, such as `2019-10-12 07:20:50.12Z` - * A date of the format `YYYY-MM-DD` is interpreted as _local_ midnight on the given date. + * A date of the format `YYYY-MM-DD` is interpreted as the _local_ midnight at the beginning of the given date. Single-digit month and day are accepted, but the year must contain four digits. * `now` refers to the exact current time - * `yesterday`, `today`, and `tomorrow` refer to _local_ midnight on the given day + * `yesterday`, `today`, and `tomorrow` refer to the _local_ midnight at the beginning of the given day * Any duration (described below) may be used as a timestamp, and is considered relative to the current time. Times are stored internally as UTC. @@ -18,19 +18,13 @@ Each can be preceded by a whole number or a decimal multiplier, e.g., `3days`. The multiplier is optional with the singular forms of the units; for example `day` is allowed. Some of the units allow an adjectival form, such as `daily` or `annually`; this form is more readable in some cases, but otherwise has the same meaning. - * `s`, `sec`, `secs`, `second`, or `seconds` - * `min`, `mins`, `minute`, or `minutes` (note that `m` is a month!) - * `h`, `hr`, `hrs`, `hour`, or `hours` - * `d`, `day`, `days`, `daily`, or `weekdays` (note, weekdays includes weekends!) - * `w`, `wk`, `wks`, `week`, `weeks`, or `weekly` - * `biweekly`, `fornight` or `sennight` (14 days) - * `m`, `mo`, `mos`, `mth`, `mths`, `mnths`, `month`, `months`, or `monthly` (always 30 days, regardless of calendar month) - * `binmonthly` (61 days) - * `q`, `qtr`, `qtrs`, `qrtr`, `qrtrs`, `quarter`, `quarters`, or `quarterly` (91 days) - * `semiannual` (183 days) - * `y`, `yr`, `yrs`, `year`, `years`, `yearly`, or `annual` (365 days, regardless of leap days) - * `biannual` or `biyearly` (730 days) + * `s`, `second`, or `seconds` + * `min`, `mins`, `minute`, or `minutes` (note that `m` not allowed, as it might also mean `month`) + * `h`, `hour`, or `hours` + * `d`, `day`, or `days` + * `w`, `week`, or `weeks` + * `mo`, or `months` (always 30 days, regardless of calendar month) + * `y`, `year`, or `years` (365 days, regardless of leap days) [ISO 8601 standard durations](https://en.wikipedia.org/wiki/ISO_8601#Durations) are also allowed. While the standard does not specify the length of "P1Y" or "P1M", Taskchampion treats those as 365 and 30 days, respectively. - From ac6b020b6dd8adbad991679122168548901b2f75 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Tue, 1 Jun 2021 09:23:36 -0400 Subject: [PATCH 17/34] minor updates from review --- cli/src/argparse/args/colon.rs | 14 +++++++------- cli/src/argparse/modification.rs | 6 +++--- cli/src/invocation/cmd/info.rs | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cli/src/argparse/args/colon.rs b/cli/src/argparse/args/colon.rs index ecf1af6c9..3fa17ce97 100644 --- a/cli/src/argparse/args/colon.rs +++ b/cli/src/argparse/args/colon.rs @@ -5,8 +5,8 @@ use nom::bytes::complete::tag as nomtag; use nom::{branch::*, character::complete::*, combinator::*, sequence::*, IResult}; use taskchampion::Status; -/// Recognizes a colon-prefixed pair -fn colon_prefixed(prefix: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { +/// Recognizes up to the colon of the common `:...` syntax +fn colon_prefix(prefix: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { fn to_suffix<'a>(input: (&'a str, char, &'a str)) -> Result<&'a str, ()> { Ok(input.2) } @@ -28,7 +28,7 @@ pub(crate) fn status_colon(input: &str) -> IResult<&str, Status> { _ => Err(()), } } - map_res(colon_prefixed("status"), to_status)(input) + map_res(colon_prefix("status"), to_status)(input) } /// Recognizes `wait:` to None and `wait:` to `Some(ts)` @@ -53,10 +53,10 @@ mod test { use super::*; #[test] - fn test_colon_prefixed() { - assert_eq!(colon_prefixed("foo")("foo:abc").unwrap().1, "abc"); - assert_eq!(colon_prefixed("foo")("foo:").unwrap().1, ""); - assert!(colon_prefixed("foo")("foo").is_err()); + fn test_colon_prefix() { + assert_eq!(colon_prefix("foo")("foo:abc").unwrap().1, "abc"); + assert_eq!(colon_prefix("foo")("foo:").unwrap().1, ""); + assert!(colon_prefix("foo")("foo").is_err()); } #[test] diff --git a/cli/src/argparse/modification.rs b/cli/src/argparse/modification.rs index dcdc3d303..083f7fc8e 100644 --- a/cli/src/argparse/modification.rs +++ b/cli/src/argparse/modification.rs @@ -154,9 +154,9 @@ impl Modification { syntax: "wait:", summary: "Set or unset the task's wait time", description: " - Set the time before which the task is not actionable and - should not be shown in reports. With `wait:`, the time - is un-set. See the documentation for the timestamp syntax.", + Set the time before which the task is not actionable and should not be shown in + reports, e.g., `wait:3day` to wait for three days. With `wait:`, the time is + un-set. See the documentation for the timestamp syntax.", }); } } diff --git a/cli/src/invocation/cmd/info.rs b/cli/src/invocation/cmd/info.rs index f450b7a6f..c77476a69 100644 --- a/cli/src/invocation/cmd/info.rs +++ b/cli/src/invocation/cmd/info.rs @@ -36,8 +36,8 @@ pub(crate) fn execute( tags.sort(); t.add_row(row![b->"Tags", tags.join(" ")]); } - if task.is_waiting() { - t.add_row(row![b->"Wait", task.get_wait().unwrap()]); + if let Some(wait) = task.get_wait() { + t.add_row(row![b->"Wait", wait]); } } t.print(w)?; From 5f28eb3a743b2d3730820fc5b02d85c2ce986078 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Fri, 4 Jun 2021 09:26:12 -0400 Subject: [PATCH 18/34] produce Tag instances in the parser (#260) and.. * fix usage-docs plugin * upgrade mdbook --- Cargo.lock | 4 +-- cli/Cargo.toml | 2 +- cli/src/argparse/args/arg_matching.rs | 2 +- cli/src/argparse/args/misc.rs | 2 +- cli/src/argparse/args/tags.rs | 38 ++++++--------------------- cli/src/argparse/filter.rs | 30 ++++++++++----------- cli/src/argparse/modification.rs | 24 ++++++++--------- cli/src/bin/usage-docs.rs | 5 +++- cli/src/invocation/filter.rs | 24 ++++++++--------- cli/src/invocation/modify.rs | 3 --- cli/src/macros.rs | 6 +++++ cli/src/usage.rs | 2 +- 12 files changed, 62 insertions(+), 80 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d5b7e61c9..37d2536bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1630,9 +1630,9 @@ checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" [[package]] name = "mdbook" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed4060ccf332a0479df37e84c8435ad20be737d5337c3a90fa1b3b0d480a3a0" +checksum = "aeb86d199d0c1e8d41f3a9e9b0ba8639d6951a10043129159809ac9c18e3ce05" dependencies = [ "ammonia", "anyhow", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 05cfcb20c..fca908f70 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -27,7 +27,7 @@ lazy_static = "1" iso8601-duration = "0.1" # only needed for usage-docs -mdbook = { version = "0.4", optional = true } +mdbook = { version = "0.4.9", optional = true } serde_json = { version = "*", optional = true } [dependencies.taskchampion] diff --git a/cli/src/argparse/args/arg_matching.rs b/cli/src/argparse/args/arg_matching.rs index e1161738e..f52aa4254 100644 --- a/cli/src/argparse/args/arg_matching.rs +++ b/cli/src/argparse/args/arg_matching.rs @@ -44,7 +44,7 @@ mod test { fn test_arg_matching() { assert_eq!( arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(), - (argv!["bar"], "foo") + (argv!["bar"], tag!("foo")) ); assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err()); } diff --git a/cli/src/argparse/args/misc.rs b/cli/src/argparse/args/misc.rs index 006a0b939..27d2a1315 100644 --- a/cli/src/argparse/args/misc.rs +++ b/cli/src/argparse/args/misc.rs @@ -25,7 +25,7 @@ mod test { fn test_arg_matching() { assert_eq!( arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(), - (argv!["bar"], "foo") + (argv!["bar"], tag!("foo")) ); assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err()); } diff --git a/cli/src/argparse/args/tags.rs b/cli/src/argparse/args/tags.rs index 8c2cbd9c1..c15dae6bf 100644 --- a/cli/src/argparse/args/tags.rs +++ b/cli/src/argparse/args/tags.rs @@ -3,31 +3,13 @@ use std::convert::TryFrom; use taskchampion::Tag; /// Recognizes a tag prefixed with `+` and returns the tag value -pub(crate) fn plus_tag(input: &str) -> IResult<&str, &str> { - fn to_tag(input: (char, &str)) -> Result<&str, ()> { - Ok(input.1) - } - map_res( - all_consuming(tuple(( - char('+'), - recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())), - ))), - to_tag, - )(input) +pub(crate) fn plus_tag(input: &str) -> IResult<&str, Tag> { + preceded(char('+'), map_res(rest, Tag::try_from))(input) } /// Recognizes a tag prefixed with `-` and returns the tag value -pub(crate) fn minus_tag(input: &str) -> IResult<&str, &str> { - fn to_tag(input: (char, &str)) -> Result<&str, ()> { - Ok(input.1) - } - map_res( - all_consuming(tuple(( - char('-'), - recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())), - ))), - to_tag, - )(input) +pub(crate) fn minus_tag(input: &str) -> IResult<&str, Tag> { + preceded(char('-'), map_res(rest, Tag::try_from))(input) } #[cfg(test)] @@ -36,21 +18,17 @@ mod test { #[test] fn test_plus_tag() { - assert_eq!(plus_tag("+abc").unwrap().1, "abc"); - assert_eq!(plus_tag("+abc123").unwrap().1, "abc123"); + assert_eq!(plus_tag("+abc").unwrap().1, tag!("abc")); + assert_eq!(plus_tag("+abc123").unwrap().1, tag!("abc123")); assert!(plus_tag("-abc123").is_err()); - assert!(plus_tag("+abc123 ").is_err()); - assert!(plus_tag(" +abc123").is_err()); assert!(plus_tag("+1abc").is_err()); } #[test] fn test_minus_tag() { - assert_eq!(minus_tag("-abc").unwrap().1, "abc"); - assert_eq!(minus_tag("-abc123").unwrap().1, "abc123"); + assert_eq!(minus_tag("-abc").unwrap().1, tag!("abc")); + assert_eq!(minus_tag("-abc123").unwrap().1, tag!("abc123")); assert!(minus_tag("+abc123").is_err()); - assert!(minus_tag("-abc123 ").is_err()); - assert!(minus_tag(" -abc123").is_err()); assert!(minus_tag("-1abc").is_err()); } } diff --git a/cli/src/argparse/filter.rs b/cli/src/argparse/filter.rs index be63aa1af..fa2746bb1 100644 --- a/cli/src/argparse/filter.rs +++ b/cli/src/argparse/filter.rs @@ -8,7 +8,7 @@ use nom::{ multi::{fold_many0, fold_many1}, IResult, }; -use taskchampion::Status; +use taskchampion::{Status, Tag}; /// A filter represents a selection of a particular set of tasks. /// @@ -26,10 +26,10 @@ pub(crate) struct Filter { #[derive(Debug, PartialEq, Clone)] pub(crate) enum Condition { /// Task has the given tag - HasTag(String), + HasTag(Tag), /// Task does not have the given tag - NoTag(String), + NoTag(Tag), /// Task has the given status Status(Status), @@ -68,15 +68,15 @@ impl Condition { } fn parse_plus_tag(input: ArgList) -> IResult { - fn to_condition(input: &str) -> Result { - Ok(Condition::HasTag(input.to_owned())) + fn to_condition(input: Tag) -> Result { + Ok(Condition::HasTag(input)) } map_res(arg_matching(plus_tag), to_condition)(input) } fn parse_minus_tag(input: ArgList) -> IResult { - fn to_condition(input: &str) -> Result { - Ok(Condition::NoTag(input.to_owned())) + fn to_condition(input: Tag) -> Result { + Ok(Condition::NoTag(input)) } map_res(arg_matching(minus_tag), to_condition)(input) } @@ -320,8 +320,8 @@ mod test { Filter { conditions: vec![ Condition::IdList(vec![TaskId::WorkingSetId(1),]), - Condition::HasTag("yes".into()), - Condition::NoTag("no".into()), + Condition::HasTag(tag!("yes")), + Condition::NoTag(tag!("no")), ], } ); @@ -353,10 +353,10 @@ mod test { conditions: vec![ // from first filter Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]), - Condition::HasTag("yes".into()), + Condition::HasTag(tag!("yes")), // from second filter Condition::IdList(vec![TaskId::WorkingSetId(2), TaskId::WorkingSetId(3)]), - Condition::HasTag("no".into()), + Condition::HasTag(tag!("no")), ], } ); @@ -373,9 +373,9 @@ mod test { conditions: vec![ // from first filter Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]), - Condition::HasTag("yes".into()), + Condition::HasTag(tag!("yes")), // from second filter - Condition::HasTag("no".into()), + Condition::HasTag(tag!("no")), ], } ); @@ -390,8 +390,8 @@ mod test { both, Filter { conditions: vec![ - Condition::HasTag("yes".into()), - Condition::HasTag("no".into()), + Condition::HasTag(tag!("yes")), + Condition::HasTag(tag!("no")), ], } ); diff --git a/cli/src/argparse/modification.rs b/cli/src/argparse/modification.rs index 083f7fc8e..bd37db928 100644 --- a/cli/src/argparse/modification.rs +++ b/cli/src/argparse/modification.rs @@ -4,7 +4,7 @@ use crate::usage; use chrono::prelude::*; use nom::{branch::alt, combinator::*, multi::fold_many0, IResult}; use std::collections::HashSet; -use taskchampion::Status; +use taskchampion::{Status, Tag}; #[derive(Debug, PartialEq, Clone)] pub enum DescriptionMod { @@ -44,17 +44,17 @@ pub struct Modification { pub active: Option, /// Add tags - pub add_tags: HashSet, + pub add_tags: HashSet, /// Remove tags - pub remove_tags: HashSet, + pub remove_tags: HashSet, } /// A single argument that is part of a modification, used internally to this module enum ModArg<'a> { Description(&'a str), - PlusTag(&'a str), - MinusTag(&'a str), + PlusTag(Tag), + MinusTag(Tag), Wait(Option>), } @@ -71,10 +71,10 @@ impl Modification { } } ModArg::PlusTag(tag) => { - acc.add_tags.insert(tag.to_owned()); + acc.add_tags.insert(tag); } ModArg::MinusTag(tag) => { - acc.remove_tags.insert(tag.to_owned()); + acc.remove_tags.insert(tag); } ModArg::Wait(wait) => { acc.wait = Some(wait); @@ -105,14 +105,14 @@ impl Modification { } fn plus_tag(input: ArgList) -> IResult { - fn to_modarg(input: &str) -> Result { + fn to_modarg(input: Tag) -> Result, ()> { Ok(ModArg::PlusTag(input)) } map_res(arg_matching(plus_tag), to_modarg)(input) } fn minus_tag(input: ArgList) -> IResult { - fn to_modarg(input: &str) -> Result { + fn to_modarg(input: Tag) -> Result, ()> { Ok(ModArg::MinusTag(input)) } map_res(arg_matching(minus_tag), to_modarg)(input) @@ -198,7 +198,7 @@ mod test { assert_eq!( modification, Modification { - add_tags: set![s!("abc"), s!("def")], + add_tags: set![tag!("abc"), tag!("def")], ..Default::default() } ); @@ -252,8 +252,8 @@ mod test { modification, Modification { description: DescriptionMod::Set(s!("new desc fun")), - add_tags: set![s!("next")], - remove_tags: set![s!("daytime")], + add_tags: set![tag!("next")], + remove_tags: set![tag!("daytime")], ..Default::default() } ); diff --git a/cli/src/bin/usage-docs.rs b/cli/src/bin/usage-docs.rs index f78179c71..baa4079e1 100644 --- a/cli/src/bin/usage-docs.rs +++ b/cli/src/bin/usage-docs.rs @@ -40,7 +40,10 @@ fn process(_ctx: &PreprocessorContext, mut book: Book) -> Result { if new_content != chapter.content { eprintln!( "Substituting usage in {:?}", - chapter.source_path.as_ref().unwrap() + chapter + .source_path + .as_ref() + .unwrap_or(chapter.path.as_ref().unwrap()) ); } chapter.content = new_content; diff --git a/cli/src/invocation/filter.rs b/cli/src/invocation/filter.rs index 0cc6e31fe..0d4add799 100644 --- a/cli/src/invocation/filter.rs +++ b/cli/src/invocation/filter.rs @@ -1,22 +1,17 @@ use crate::argparse::{Condition, Filter, TaskId}; use std::collections::HashSet; -use std::convert::TryInto; -use taskchampion::{Replica, Status, Tag, Task, Uuid, WorkingSet}; +use taskchampion::{Replica, Status, Task, Uuid, WorkingSet}; fn match_task(filter: &Filter, task: &Task, uuid: Uuid, working_set: &WorkingSet) -> bool { for cond in &filter.conditions { match cond { Condition::HasTag(ref tag) => { - // see #111 for the unwrap - let tag: Tag = tag.try_into().unwrap(); - if !task.has_tag(&tag) { + if !task.has_tag(tag) { return false; } } Condition::NoTag(ref tag) => { - // see #111 for the unwrap - let tag: Tag = tag.try_into().unwrap(); - if task.has_tag(&tag) { + if task.has_tag(tag) { return false; } } @@ -254,8 +249,8 @@ mod test { #[test] fn tag_filtering() -> anyhow::Result<()> { let mut replica = test_replica(); - let yes: Tag = "yes".try_into()?; - let no: Tag = "no".try_into()?; + let yes = tag!("yes"); + let no = tag!("no"); let mut t1 = replica .new_task(Status::Pending, s!("A"))? @@ -274,7 +269,7 @@ mod test { // look for just "yes" (A and B) let filter = Filter { - conditions: vec![Condition::HasTag(s!("yes"))], + conditions: vec![Condition::HasTag(tag!("yes"))], }; let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? .map(|t| t.get_description().to_owned()) @@ -284,7 +279,7 @@ mod test { // look for tags without "no" (A, D) let filter = Filter { - conditions: vec![Condition::NoTag(s!("no"))], + conditions: vec![Condition::NoTag(tag!("no"))], }; let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? .map(|t| t.get_description().to_owned()) @@ -294,7 +289,10 @@ mod test { // look for tags with "yes" and "no" (B) let filter = Filter { - conditions: vec![Condition::HasTag(s!("yes")), Condition::HasTag(s!("no"))], + conditions: vec![ + Condition::HasTag(tag!("yes")), + Condition::HasTag(tag!("no")), + ], }; let filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? .map(|t| t.get_description().to_owned()) diff --git a/cli/src/invocation/modify.rs b/cli/src/invocation/modify.rs index 7ef6d758c..dd943fdd1 100644 --- a/cli/src/invocation/modify.rs +++ b/cli/src/invocation/modify.rs @@ -1,5 +1,4 @@ use crate::argparse::{DescriptionMod, Modification}; -use std::convert::TryInto; use taskchampion::TaskMut; /// Apply the given modification @@ -31,12 +30,10 @@ pub(super) fn apply_modification( } for tag in modification.add_tags.iter() { - let tag = tag.try_into()?; // see #111 task.add_tag(&tag)?; } for tag in modification.remove_tags.iter() { - let tag = tag.try_into()?; // see #111 task.remove_tag(&tag)?; } diff --git a/cli/src/macros.rs b/cli/src/macros.rs index 02e11e5cf..1a3024c13 100644 --- a/cli/src/macros.rs +++ b/cli/src/macros.rs @@ -30,3 +30,9 @@ macro_rules! set( macro_rules! s( { $s:expr } => { $s.to_owned() }; ); + +/// Create a Tag from an &str; just a testing shorthand +#[cfg(test)] +macro_rules! tag( + { $s:expr } => { { use std::convert::TryFrom; taskchampion::Tag::try_from($s).unwrap() } }; +); diff --git a/cli/src/usage.rs b/cli/src/usage.rs index 59a2ba982..b3f688909 100644 --- a/cli/src/usage.rs +++ b/cli/src/usage.rs @@ -93,7 +93,7 @@ impl Usage { /// With the appropriate documentation. pub fn substitute_docs(&self, content: &str) -> Result { // this is not efficient, but it doesn't need to be - let mut lines = content.lines(); + let lines = content.lines(); let mut w = String::new(); const DOC_HEADER_PREFIX: &str = "