diff --git a/cli/src/argparse/filter.rs b/cli/src/argparse/filter.rs index 07b2b3fd3..66845e54b 100644 --- a/cli/src/argparse/filter.rs +++ b/cli/src/argparse/filter.rs @@ -1,6 +1,8 @@ -use super::args::{arg_matching, id_list, TaskId}; +use super::args::{arg_matching, id_list, minus_tag, plus_tag, TaskId}; use super::ArgList; -use nom::{combinator::*, multi::fold_many0, IResult}; +use crate::usage; +use nom::{branch::alt, combinator::*, multi::fold_many0, IResult}; +use textwrap::dedent; /// A filter represents a selection of a particular set of tasks. /// @@ -9,8 +11,12 @@ use nom::{combinator::*, multi::fold_many0, IResult}; /// pending tasks, or all tasks. #[derive(Debug, PartialEq, Default, Clone)] pub(crate) struct Filter { - /// A list of numeric IDs or prefixes of UUIDs + /// The universe of tasks from which this filter can select pub(crate) universe: Universe, + + /// A set of filter conditions, all of which must match a task in order for that task to be + /// selected. + pub(crate) conditions: Vec, } /// The universe of tasks over which a filter should be applied. @@ -39,15 +45,26 @@ impl Default for Universe { } } +/// A condition which tasks must match to be accepted by the filter. +#[derive(Debug, PartialEq, Clone)] +pub(crate) enum Condition { + /// Task has the given tag + HasTag(String), + + /// Task does not have the given tag + NoTag(String), +} + /// Internal struct representing a parsed filter argument enum FilterArg { IdList(Vec), + Condition(Condition), } impl Filter { pub(super) fn parse(input: ArgList) -> IResult { fold_many0( - Self::id_list, + alt((Self::id_list, Self::plus_tag, Self::minus_tag)), Filter { ..Default::default() }, @@ -68,6 +85,9 @@ impl Filter { acc.universe = Universe::IdList(id_list); } } + FilterArg::Condition(cond) => { + acc.conditions.push(cond); + } } acc } @@ -78,6 +98,50 @@ impl Filter { } map_res(arg_matching(id_list), to_filterarg)(input) } + + fn plus_tag(input: ArgList) -> IResult { + fn to_filterarg(input: &str) -> Result { + Ok(FilterArg::Condition(Condition::HasTag(input.to_owned()))) + } + map_res(arg_matching(plus_tag), to_filterarg)(input) + } + + fn minus_tag(input: ArgList) -> IResult { + fn to_filterarg(input: &str) -> Result { + Ok(FilterArg::Condition(Condition::NoTag(input.to_owned()))) + } + map_res(arg_matching(minus_tag), to_filterarg)(input) + } + + pub(super) fn get_usage(u: &mut usage::Usage) { + u.filters.push(usage::Filter { + syntax: "TASKID[,TASKID,..]".to_owned(), + summary: "Specific tasks".to_owned(), + description: dedent( + " + Select only specific tasks. Multiple tasks can be specified either separated by + commas or as separate arguments. Each task may be specfied by its working-set + index (a small number) or by its UUID. Prefixes of UUIDs broken at hyphens are + also supported, such as `b5664ef8-423d` or `b5664ef8`.", + ), + }); + u.filters.push(usage::Filter { + syntax: "+TAG".to_owned(), + summary: "Tagged tasks".to_owned(), + description: dedent( + " + Select tasks with the given tag.", + ), + }); + u.filters.push(usage::Filter { + syntax: "-TAG".to_owned(), + summary: "Un-tagged tasks".to_owned(), + description: dedent( + " + Select tasks that do not have the given tag.", + ), + }); + } } #[cfg(test)] @@ -159,4 +223,21 @@ mod test { } ); } + + #[test] + fn test_tags() { + let (input, filter) = Filter::parse(argv!["1", "+yes", "-no"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + universe: Universe::IdList(vec![TaskId::WorkingSetId(1),]), + conditions: vec![ + Condition::HasTag("yes".into()), + Condition::NoTag("no".into()), + ], + ..Default::default() + } + ); + } } diff --git a/cli/src/argparse/mod.rs b/cli/src/argparse/mod.rs index ad757b892..a6d8c72bd 100644 --- a/cli/src/argparse/mod.rs +++ b/cli/src/argparse/mod.rs @@ -20,7 +20,7 @@ mod subcommand; pub(crate) use args::TaskId; pub(crate) use command::Command; -pub(crate) use filter::{Filter, Universe}; +pub(crate) use filter::{Condition, Filter, Universe}; pub(crate) use modification::{DescriptionMod, Modification}; pub(crate) use report::Report; pub(crate) use subcommand::Subcommand; @@ -31,4 +31,6 @@ type ArgList<'a> = &'a [&'a str]; pub(crate) fn get_usage(usage: &mut Usage) { Subcommand::get_usage(usage); + Filter::get_usage(usage); + Modification::get_usage(usage); } diff --git a/cli/src/argparse/modification.rs b/cli/src/argparse/modification.rs index 892e2ff5b..1c46856a2 100644 --- a/cli/src/argparse/modification.rs +++ b/cli/src/argparse/modification.rs @@ -1,8 +1,10 @@ use super::args::{any, arg_matching, minus_tag, plus_tag}; use super::ArgList; +use crate::usage; use nom::{branch::alt, combinator::*, multi::fold_many0, IResult}; use std::collections::HashSet; use taskchampion::Status; +use textwrap::dedent; #[derive(Debug, PartialEq, Clone)] pub enum DescriptionMod { @@ -107,6 +109,34 @@ impl Modification { } map_res(arg_matching(minus_tag), to_modarg)(input) } + + pub(super) fn get_usage(u: &mut usage::Usage) { + u.modifications.push(usage::Modification { + syntax: "DESCRIPTION".to_owned(), + summary: "Set description".to_owned(), + description: dedent( + " + Set the task description. Multiple arguments are combined into a single + space-separated description.", + ), + }); + u.modifications.push(usage::Modification { + syntax: "+TAG".to_owned(), + summary: "Tag task".to_owned(), + description: dedent( + " + Add the given tag to the task.", + ), + }); + u.modifications.push(usage::Modification { + syntax: "-TAG".to_owned(), + summary: "Un-tag task".to_owned(), + description: dedent( + " + Remove the given tag from the task.", + ), + }); + } } #[cfg(test)] diff --git a/cli/src/argparse/subcommand.rs b/cli/src/argparse/subcommand.rs index af4f6fe36..6db98105e 100644 --- a/cli/src/argparse/subcommand.rs +++ b/cli/src/argparse/subcommand.rs @@ -243,7 +243,7 @@ impl Modify { }); u.subcommands.push(usage::Subcommand { name: "stop".to_owned(), - syntax: "[filter] start [modification]".to_owned(), + syntax: "[filter] stop [modification]".to_owned(), summary: "Stop tasks".to_owned(), description: dedent( " @@ -252,7 +252,7 @@ impl Modify { }); u.subcommands.push(usage::Subcommand { name: "done".to_owned(), - syntax: "[filter] start [modification]".to_owned(), + syntax: "[filter] done [modification]".to_owned(), summary: "Mark tasks as completed".to_owned(), description: dedent( " diff --git a/cli/src/invocation/filter.rs b/cli/src/invocation/filter.rs index ae9422627..e6eacc9fc 100644 --- a/cli/src/invocation/filter.rs +++ b/cli/src/invocation/filter.rs @@ -1,10 +1,28 @@ -use crate::argparse::{Filter, TaskId, Universe}; +use crate::argparse::{Condition, Filter, TaskId, Universe}; use failure::Fallible; use std::collections::HashSet; -use taskchampion::{Replica, Task}; +use std::convert::TryInto; +use taskchampion::{Replica, Tag, Task}; -fn match_task(_filter: &Filter, _task: &Task) -> bool { - // TODO: at the moment, only filtering by Universe is supported +fn match_task(filter: &Filter, task: &Task) -> 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) { + return false; + } + } + Condition::NoTag(ref tag) => { + // see #111 for the unwrap + let tag: Tag = tag.try_into().unwrap(); + if task.has_tag(&tag) { + return false; + } + } + } + } true } @@ -186,6 +204,68 @@ mod test { ); } + #[test] + fn tag_filtering() -> Fallible<()> { + let mut replica = test_replica(); + let yes: Tag = "yes".try_into()?; + let no: Tag = "no".try_into()?; + + let mut t1 = replica + .new_task(Status::Pending, "A".to_owned())? + .into_mut(&mut replica); + t1.add_tag(&yes)?; + let mut t2 = replica + .new_task(Status::Pending, "B".to_owned())? + .into_mut(&mut replica); + t2.add_tag(&yes)?; + t2.add_tag(&no)?; + let mut t3 = replica + .new_task(Status::Pending, "C".to_owned())? + .into_mut(&mut replica); + t3.add_tag(&no)?; + let _t4 = replica.new_task(Status::Pending, "D".to_owned())?; + + // look for just "yes" (A and B) + let filter = Filter { + universe: Universe::AllTasks, + conditions: vec![Condition::HasTag("yes".to_owned())], + ..Default::default() + }; + let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? + .map(|t| t.get_description().to_owned()) + .collect(); + filtered.sort(); + assert_eq!(vec!["A".to_owned(), "B".to_owned()], filtered); + + // look for tags without "no" (A, D) + let filter = Filter { + universe: Universe::AllTasks, + conditions: vec![Condition::NoTag("no".to_owned())], + ..Default::default() + }; + let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? + .map(|t| t.get_description().to_owned()) + .collect(); + filtered.sort(); + assert_eq!(vec!["A".to_owned(), "D".to_owned()], filtered); + + // look for tags with "yes" and "no" (B) + let filter = Filter { + universe: Universe::AllTasks, + conditions: vec![ + Condition::HasTag("yes".to_owned()), + Condition::HasTag("no".to_owned()), + ], + ..Default::default() + }; + let filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? + .map(|t| t.get_description().to_owned()) + .collect(); + assert_eq!(vec!["B".to_owned()], filtered); + + Ok(()) + } + #[test] fn pending_tasks() { let mut replica = test_replica(); diff --git a/cli/src/invocation/modify.rs b/cli/src/invocation/modify.rs index 26e190e2c..c3923f0ee 100644 --- a/cli/src/invocation/modify.rs +++ b/cli/src/invocation/modify.rs @@ -34,14 +34,12 @@ pub(super) fn apply_modification( } for tag in modification.add_tags.iter() { - // note that the parser should have already ensured that this tag was valid - let tag = tag.try_into()?; + let tag = tag.try_into()?; // see #111 task.add_tag(&tag)?; } for tag in modification.remove_tags.iter() { - // note that the parser should have already ensured that this tag was valid - let tag = tag.try_into()?; + let tag = tag.try_into()?; // see #111 task.remove_tag(&tag)?; } diff --git a/cli/src/usage.rs b/cli/src/usage.rs index d1b36d59d..d91b449cc 100644 --- a/cli/src/usage.rs +++ b/cli/src/usage.rs @@ -9,6 +9,8 @@ use textwrap::indent; #[derive(Debug, Default)] pub(crate) struct Usage { pub(crate) subcommands: Vec, + pub(crate) filters: Vec, + pub(crate) modifications: Vec, } impl Usage { @@ -43,6 +45,18 @@ impl Usage { for subcommand in self.subcommands.iter() { subcommand.write_help(&mut w, summary)?; } + write!(w, "Filter Expressions:\n\n")?; + write!(w, "Where [filter] appears above, zero or more of the following arguments can be used to limit\n")?; + write!(w, "the tasks concerned.\n\n")?; + for filter in self.filters.iter() { + filter.write_help(&mut w, summary)?; + } + write!(w, "Modifications:\n\n")?; + write!(w, "Where [modification] appears above, zero or more of the following arguments can be used\n")?; + write!(w, "to modify the selected tasks.\n\n")?; + for modification in self.modifications.iter() { + modification.write_help(&mut w, summary)?; + } if !summary { write!(w, "\nSee `task help` for more detail\n")?; } @@ -50,6 +64,7 @@ impl Usage { } } +/// Usage documentation for a subcommand #[derive(Debug, Default)] pub(crate) struct Subcommand { /// Name of the subcommand @@ -81,3 +96,63 @@ impl Subcommand { Ok(()) } } + +/// Usage documentation for a filter argument +#[derive(Debug, Default)] +pub(crate) struct Filter { + /// Syntax summary + pub(crate) syntax: String, + + /// One-line description of the filter. Use all-caps words for placeholders. + pub(crate) summary: String, + + /// Multi-line description of the filter. It's OK for this to duplicate summary, as the + /// two are not displayed together. + pub(crate) description: String, +} + +impl Filter { + fn write_help(&self, mut w: W, summary: bool) -> Result<()> { + if summary { + write!(w, " {} - {}\n", self.syntax, self.summary)?; + } else { + write!( + w, + " {}\n{}\n", + self.syntax, + indent(self.description.trim(), " ") + )?; + } + Ok(()) + } +} + +/// Usage documentation for a modification argument +#[derive(Debug, Default)] +pub(crate) struct Modification { + /// Syntax summary + pub(crate) syntax: String, + + /// One-line description of the modification. Use all-caps words for placeholders. + pub(crate) summary: String, + + /// Multi-line description of the modification. It's OK for this to duplicate summary, as the + /// two are not displayed together. + pub(crate) description: String, +} + +impl Modification { + fn write_help(&self, mut w: W, summary: bool) -> Result<()> { + if summary { + write!(w, " {} - {}\n", self.syntax, self.summary)?; + } else { + write!( + w, + " {}\n{}\n", + self.syntax, + indent(self.description.trim(), " ") + )?; + } + Ok(()) + } +}