From 0a458b5f5b5534daac851a1ec3a026c18c5ddcfb Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Tue, 29 Dec 2020 22:54:07 +0000 Subject: [PATCH 1/4] Treat any bare word in the command line as a report name --- cli/src/argparse/args.rs | 5 + cli/src/argparse/mod.rs | 2 - cli/src/argparse/subcommand.rs | 172 ++++++++++-------- cli/src/invocation/cmd/mod.rs | 2 +- cli/src/invocation/cmd/{list.rs => report.rs} | 26 ++- cli/src/invocation/mod.rs | 8 +- cli/src/invocation/report.rs | 58 +++++- cli/src/lib.rs | 1 + cli/src/{argparse => }/report.rs | 4 +- 9 files changed, 177 insertions(+), 101 deletions(-) rename cli/src/invocation/cmd/{list.rs => report.rs} (55%) rename cli/src/{argparse => }/report.rs (94%) diff --git a/cli/src/argparse/args.rs b/cli/src/argparse/args.rs index c58b5ac51..dc568e7bf 100644 --- a/cli/src/argparse/args.rs +++ b/cli/src/argparse/args.rs @@ -30,6 +30,11 @@ 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) diff --git a/cli/src/argparse/mod.rs b/cli/src/argparse/mod.rs index 01df66bed..0bf656a8f 100644 --- a/cli/src/argparse/mod.rs +++ b/cli/src/argparse/mod.rs @@ -15,14 +15,12 @@ mod args; mod command; mod filter; mod modification; -mod report; mod subcommand; pub(crate) use args::TaskId; pub(crate) use command::Command; pub(crate) use filter::{Condition, Filter, Universe}; pub(crate) use modification::{DescriptionMod, Modification}; -pub(crate) use report::{Column, Property, Report, Sort, SortBy}; pub(crate) use subcommand::Subcommand; use crate::usage::Usage; diff --git a/cli/src/argparse/subcommand.rs b/cli/src/argparse/subcommand.rs index 5884cc4a9..0caf17ebd 100644 --- a/cli/src/argparse/subcommand.rs +++ b/cli/src/argparse/subcommand.rs @@ -1,7 +1,5 @@ use super::args::*; -use super::{ - ArgList, Column, DescriptionMod, Filter, Modification, Property, Report, Sort, SortBy, -}; +use super::{ArgList, DescriptionMod, Filter, Modification}; use crate::usage; use nom::{branch::alt, combinator::*, sequence::*, IResult}; use taskchampion::Status; @@ -39,8 +37,12 @@ pub(crate) enum Subcommand { }, /// Lists (reports) - List { - report: Report, + Report { + /// The name of the report to show + report_name: String, + + /// Additional filter terms beyond those in the report + filter: Filter, }, /// Per-task information (typically one task) @@ -56,16 +58,17 @@ pub(crate) enum Subcommand { impl Subcommand { pub(super) fn parse(input: ArgList) -> IResult { - alt(( + all_consuming(alt(( Version::parse, Help::parse, Add::parse, Modify::parse, - List::parse, Info::parse, Gc::parse, Sync::parse, - ))(input) + // This must come last since it accepts arbitrary report names + Report::parse, + )))(input) } pub(super) fn get_usage(u: &mut usage::Usage) { @@ -73,10 +76,10 @@ impl Subcommand { Help::get_usage(u); Add::get_usage(u); Modify::get_usage(u); - List::get_usage(u); Info::get_usage(u); Gc::get_usage(u); Sync::get_usage(u); + Report::get_usage(u); } } @@ -251,59 +254,43 @@ impl Modify { } } -struct List; - -impl List { - // temporary - fn default_report() -> Report { - Report { - columns: vec![ - Column { - label: "Id".to_owned(), - property: Property::Id, - }, - Column { - label: "Description".to_owned(), - property: Property::Description, - }, - Column { - label: "Active".to_owned(), - property: Property::Active, - }, - Column { - label: "Tags".to_owned(), - property: Property::Tags, - }, - ], - sort: vec![Sort { - ascending: false, - sort_by: SortBy::Uuid, - }], - ..Default::default() - } - } +struct Report; +impl Report { fn parse(input: ArgList) -> IResult { - fn to_subcommand(input: (Filter, &str)) -> Result { - let report = Report { - filter: input.0, - ..List::default_report() - }; - Ok(Subcommand::List { report }) + fn to_subcommand(filter: Filter, report_name: &str) -> Result { + Ok(Subcommand::Report { + filter, + report_name: report_name.to_owned(), + }) } - map_res( - pair(Filter::parse, arg_matching(literal("list"))), - to_subcommand, - )(input) + // allow the filter expression before or after the report name + alt(( + map_res(pair(arg_matching(report_name), Filter::parse), |input| { + to_subcommand(input.1, input.0) + }), + map_res(pair(Filter::parse, 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")), + ))(input) } fn get_usage(u: &mut usage::Usage) { u.subcommands.push(usage::Subcommand { - name: "list", - syntax: "[filter] list", - summary: "List tasks", + name: "report", + syntax: "[filter] [report-name] *or* [report-name] [filter]", + summary: "Show a report", description: " - Show a list of the tasks matching the filter", + Show the named report, including only tasks matching the filter", + }); + u.subcommands.push(usage::Subcommand { + name: "next", + syntax: "[filter]", + summary: "Show the 'next' report", + description: " + Show the report named 'next', including only tasks matching the filter", }); } } @@ -634,31 +621,72 @@ mod test { } #[test] - fn test_list() { - let subcommand = Subcommand::List { - report: Report { - ..List::default_report() - }, + fn test_report() { + let subcommand = Subcommand::Report { + filter: Default::default(), + report_name: "myreport".to_owned(), }; assert_eq!( - Subcommand::parse(argv!["list"]).unwrap(), + Subcommand::parse(argv!["myreport"]).unwrap(), (&EMPTY[..], subcommand) ); } #[test] - fn test_list_filter() { - let subcommand = Subcommand::List { - report: Report { - filter: Filter { - universe: Universe::for_ids(vec![12, 13]), - ..Default::default() - }, - ..List::default_report() + fn test_report_filter_before() { + let subcommand = Subcommand::Report { + filter: Filter { + universe: Universe::for_ids(vec![12, 13]), + ..Default::default() }, + report_name: "foo".to_owned(), }; assert_eq!( - Subcommand::parse(argv!["12,13", "list"]).unwrap(), + Subcommand::parse(argv!["12,13", "foo"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_report_filter_after() { + let subcommand = Subcommand::Report { + filter: Filter { + universe: Universe::for_ids(vec![12, 13]), + ..Default::default() + }, + report_name: "foo".to_owned(), + }; + assert_eq!( + Subcommand::parse(argv!["foo", "12,13"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_report_filter_next() { + let subcommand = Subcommand::Report { + filter: Filter { + universe: Universe::for_ids(vec![12, 13]), + ..Default::default() + }, + report_name: "next".to_owned(), + }; + assert_eq!( + Subcommand::parse(argv!["12,13"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_report_next() { + let subcommand = Subcommand::Report { + filter: Filter { + ..Default::default() + }, + report_name: "next".to_owned(), + }; + assert_eq!( + Subcommand::parse(argv![]).unwrap(), (&EMPTY[..], subcommand) ); } @@ -704,11 +732,7 @@ mod test { #[test] fn test_gc_extra_args() { - let subcommand = Subcommand::Gc; - assert_eq!( - Subcommand::parse(argv!["gc", "foo"]).unwrap(), - (&vec!["foo"][..], subcommand) - ); + assert!(Subcommand::parse(argv!["gc", "foo"]).is_err()); } #[test] diff --git a/cli/src/invocation/cmd/mod.rs b/cli/src/invocation/cmd/mod.rs index 43050215b..18a973ebb 100644 --- a/cli/src/invocation/cmd/mod.rs +++ b/cli/src/invocation/cmd/mod.rs @@ -4,7 +4,7 @@ pub(crate) mod add; pub(crate) mod gc; pub(crate) mod help; pub(crate) mod info; -pub(crate) mod list; pub(crate) mod modify; +pub(crate) mod report; pub(crate) mod sync; pub(crate) mod version; diff --git a/cli/src/invocation/cmd/list.rs b/cli/src/invocation/cmd/report.rs similarity index 55% rename from cli/src/invocation/cmd/list.rs rename to cli/src/invocation/cmd/report.rs index 48f8543b6..38a72b3e7 100644 --- a/cli/src/invocation/cmd/list.rs +++ b/cli/src/invocation/cmd/report.rs @@ -1,4 +1,4 @@ -use crate::argparse::Report; +use crate::argparse::Filter; use crate::invocation::display_report; use failure::Fallible; use taskchampion::Replica; @@ -7,35 +7,33 @@ use termcolor::WriteColor; pub(crate) fn execute( w: &mut W, replica: &mut Replica, - report: Report, + report_name: String, + filter: Filter, ) -> Fallible<()> { - display_report(w, replica, &report) + display_report(w, replica, report_name, filter) } #[cfg(test)] mod test { use super::*; - use crate::argparse::{Column, Filter, Property}; + use crate::argparse::Filter; use crate::invocation::test::*; use taskchampion::Status; #[test] - fn test_list() { + fn test_report() { let mut w = test_writer(); let mut replica = test_replica(); replica.new_task(Status::Pending, s!("my task")).unwrap(); - let report = Report { - filter: Filter { - ..Default::default() - }, - columns: vec![Column { - label: "Description".to_owned(), - property: Property::Description, - }], + // The function being tested is only one line long, so this is sort of an integration test + // for display_report. + + let report_name = "next".to_owned(); + let filter = Filter { ..Default::default() }; - execute(&mut w, &mut replica, report).unwrap(); + execute(&mut w, &mut replica, report_name, filter).unwrap(); assert!(w.into_string().contains("my task")); } } diff --git a/cli/src/invocation/mod.rs b/cli/src/invocation/mod.rs index 685c96cf2..6cbc0df03 100644 --- a/cli/src/invocation/mod.rs +++ b/cli/src/invocation/mod.rs @@ -60,9 +60,13 @@ pub(crate) fn invoke(command: Command, settings: Config) -> Fallible<()> { } => return cmd::modify::execute(&mut w, &mut replica, filter, modification), Command { - subcommand: Subcommand::List { report }, + subcommand: + Subcommand::Report { + report_name, + filter, + }, .. - } => return cmd::list::execute(&mut w, &mut replica, report), + } => return cmd::report::execute(&mut w, &mut replica, report_name, filter), Command { subcommand: Subcommand::Info { filter, debug }, diff --git a/cli/src/invocation/report.rs b/cli/src/invocation/report.rs index db5d577df..0ed793f7d 100644 --- a/cli/src/invocation/report.rs +++ b/cli/src/invocation/report.rs @@ -1,7 +1,8 @@ -use crate::argparse::{Column, Property, Report, SortBy}; +use crate::argparse::Filter; use crate::invocation::filtered_tasks; +use crate::report::{Column, Property, Report, Sort, SortBy}; use crate::table; -use failure::Fallible; +use failure::{bail, Fallible}; use prettytable::{Row, Table}; use std::cmp::Ordering; use taskchampion::{Replica, Task, Uuid}; @@ -101,20 +102,64 @@ fn task_column(task: &Task, column: &Column, working_set: &WorkingSet) -> String } } +fn get_report(report_name: String, filter: Filter) -> Fallible { + let columns = vec![ + Column { + label: "Id".to_owned(), + property: Property::Id, + }, + Column { + label: "Description".to_owned(), + property: Property::Description, + }, + Column { + label: "Active".to_owned(), + property: Property::Active, + }, + Column { + label: "Tags".to_owned(), + property: Property::Tags, + }, + ]; + let sort = vec![Sort { + ascending: false, + sort_by: SortBy::Uuid, + }]; + use crate::argparse::Universe; + Ok(match report_name.as_ref() { + "list" => Report { + columns, + sort, + filter, + }, + "next" => Report { + columns, + sort, + // TODO: merge invocation filter and report filter + filter: Filter { + universe: Universe::PendingTasks, + ..Default::default() + }, + }, + _ => bail!("Unknown report {:?}", report_name), + }) +} + pub(super) fn display_report( w: &mut W, replica: &mut Replica, - report: &Report, + report_name: String, + filter: Filter, ) -> Fallible<()> { let mut t = Table::new(); - + let report = get_report(report_name, filter)?; let working_set = WorkingSet::new(replica)?; // Get the tasks from the filter let mut tasks: Vec<_> = filtered_tasks(replica, &report.filter)?.collect(); // ..sort them as desired - sort_tasks(&mut tasks, report, &working_set); + sort_tasks(&mut tasks, &report, &working_set); // ..set up the column titles t.set_format(table::format()); @@ -138,8 +183,8 @@ pub(super) fn display_report( #[cfg(test)] mod test { use super::*; - use crate::argparse::{Column, Property, Report, Sort, SortBy}; use crate::invocation::test::*; + use crate::report::Sort; use std::convert::TryInto; use taskchampion::Status; @@ -371,4 +416,3 @@ mod test { assert_eq!(task_column(&task, &column, &working_set), s!("")); } } -// TODO: test task_column diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 379b3ea81..38546f6cb 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -38,6 +38,7 @@ mod macros; mod argparse; mod invocation; +mod report; mod settings; mod table; mod usage; diff --git a/cli/src/argparse/report.rs b/cli/src/report.rs similarity index 94% rename from cli/src/argparse/report.rs rename to cli/src/report.rs index 3b569652c..370e48706 100644 --- a/cli/src/argparse/report.rs +++ b/cli/src/report.rs @@ -1,4 +1,6 @@ -use super::Filter; +//! This module contains the data structures used to define reports. + +use crate::argparse::Filter; /// A report specifies a filter as well as a sort order and information about which /// task attributes to display From fc977a0fe65884edf40de829e1e218eaa39763d0 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Wed, 30 Dec 2020 00:23:18 +0000 Subject: [PATCH 2/4] Limit Filter "universes" to invocation::filter Universes are really an optimization of filtering tasks, so let's define them there, and derive them from the set of conditions. This means that complex filters might get missed and end up doing a full task scan, but that's probably OK. Note that this does not fix the working-set API issues (#108 and #123). --- cli/src/argparse/filter.rs | 150 ++++++++++++++++++++----------- cli/src/argparse/mod.rs | 2 +- cli/src/argparse/subcommand.rs | 56 ++++++------ cli/src/invocation/filter.rs | 155 +++++++++++++++++++++------------ cli/src/invocation/report.rs | 20 +++-- 5 files changed, 236 insertions(+), 147 deletions(-) diff --git a/cli/src/argparse/filter.rs b/cli/src/argparse/filter.rs index f381e5862..4645de5da 100644 --- a/cli/src/argparse/filter.rs +++ b/cli/src/argparse/filter.rs @@ -2,6 +2,7 @@ use super::args::{arg_matching, id_list, minus_tag, plus_tag, TaskId}; use super::ArgList; use crate::usage; use nom::{branch::alt, combinator::*, multi::fold_many0, IResult}; +use taskchampion::Status; /// A filter represents a selection of a particular set of tasks. /// @@ -10,40 +11,11 @@ use nom::{branch::alt, combinator::*, multi::fold_many0, IResult}; /// pending tasks, or all tasks. #[derive(Debug, PartialEq, Default, Clone)] pub(crate) struct Filter { - /// 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. -#[derive(Debug, PartialEq, Clone)] -pub(crate) enum Universe { - /// Only the identified tasks. Note that this may contain duplicates. - IdList(Vec), - /// All tasks in the task database - AllTasks, - /// Only pending tasks (or as an approximation, the working set) - #[allow(dead_code)] // currently only used in tests - PendingTasks, -} - -impl Universe { - /// Testing shorthand to construct a simple universe - #[cfg(test)] - pub(super) fn for_ids(mut ids: Vec) -> Self { - Universe::IdList(ids.drain(..).map(|id| TaskId::WorkingSetId(id)).collect()) - } -} - -impl Default for Universe { - fn default() -> Self { - Self::AllTasks - } -} - /// A condition which tasks must match to be accepted by the filter. #[derive(Debug, PartialEq, Clone)] pub(crate) enum Condition { @@ -52,10 +24,18 @@ pub(crate) enum Condition { /// Task does not have the given tag NoTag(String), + + // TODO: add command-line syntax for this + /// Task has the given status + Status(Status), + + /// Task has one of the given IDs + IdList(Vec), } /// Internal struct representing a parsed filter argument enum FilterArg { + // TODO: get rid of this whole enum IdList(Vec), Condition(Condition), } @@ -67,30 +47,46 @@ impl Filter { Filter { ..Default::default() }, - Self::fold_args, + |acc, arg| acc.with_arg(arg), )(input) } /// fold multiple filter args into a single Filter instance - fn fold_args(mut acc: Filter, mod_arg: FilterArg) -> Filter { + fn with_arg(mut self, mod_arg: FilterArg) -> Filter { match mod_arg { FilterArg::IdList(mut id_list) => { - // If any IDs are specified, then the filter's universe - // is those IDs. If there are already IDs, append to the - // list. - if let Universe::IdList(ref mut existing) = acc.universe { + // If there is already an IdList condition, concatenate this one + // to it. Thus multiple IdList command-line args represent an OR + // operation. This assumes that the filter is still being built + // from command-line arguments and thus has at most one IdList + // condition. + if let Some(Condition::IdList(existing)) = self + .conditions + .iter_mut() + .find(|c| matches!(c, Condition::IdList(_))) + { existing.append(&mut id_list); } else { - acc.universe = Universe::IdList(id_list); + self.conditions.push(Condition::IdList(id_list)); } } FilterArg::Condition(cond) => { - acc.conditions.push(cond); + self.conditions.push(cond); } } - acc + self } + /// combine this filter with another filter in an AND operation + pub(crate) fn intersect(mut self, mut other: Filter) -> Filter { + // simply concatenate the conditions + self.conditions.append(&mut other.conditions); + + self + } + + // parsers + fn id_list(input: ArgList) -> IResult { fn to_filterarg(input: Vec) -> Result { Ok(FilterArg::IdList(input)) @@ -112,6 +108,8 @@ impl Filter { map_res(arg_matching(minus_tag), to_filterarg)(input) } + // usage + pub(super) fn get_usage(u: &mut usage::Usage) { u.filters.push(usage::Filter { syntax: "TASKID[,TASKID,..]", @@ -160,8 +158,7 @@ mod test { assert_eq!( filter, Filter { - universe: Universe::IdList(vec![TaskId::WorkingSetId(1)]), - ..Default::default() + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(1)])], } ); } @@ -173,12 +170,11 @@ mod test { assert_eq!( filter, Filter { - universe: Universe::IdList(vec![ + conditions: vec![Condition::IdList(vec![ TaskId::WorkingSetId(1), TaskId::WorkingSetId(2), TaskId::WorkingSetId(3), - ]), - ..Default::default() + ])], } ); } @@ -190,13 +186,12 @@ mod test { assert_eq!( filter, Filter { - universe: Universe::IdList(vec![ + conditions: vec![Condition::IdList(vec![ TaskId::WorkingSetId(1), TaskId::WorkingSetId(2), TaskId::WorkingSetId(3), TaskId::WorkingSetId(4), - ]), - ..Default::default() + ])], } ); } @@ -208,11 +203,10 @@ mod test { assert_eq!( filter, Filter { - universe: Universe::IdList(vec![ + conditions: vec![Condition::IdList(vec![ TaskId::WorkingSetId(1), TaskId::PartialUuid(s!("abcd1234")), - ]), - ..Default::default() + ])], } ); } @@ -224,12 +218,66 @@ mod test { assert_eq!( filter, Filter { - universe: Universe::IdList(vec![TaskId::WorkingSetId(1),]), conditions: vec![ + Condition::IdList(vec![TaskId::WorkingSetId(1),]), Condition::HasTag("yes".into()), Condition::NoTag("no".into()), ], - ..Default::default() + } + ); + } + + #[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 both = left.intersect(right); + assert_eq!( + both, + Filter { + conditions: vec![ + // from first filter + Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]), + Condition::HasTag("yes".into()), + // from second filter + Condition::IdList(vec![TaskId::WorkingSetId(2), TaskId::WorkingSetId(3)]), + Condition::HasTag("no".into()), + ], + } + ); + } + + #[test] + fn intersect_idlist_alltasks() { + let left = Filter::parse(argv!["1,2", "+yes"]).unwrap().1; + let right = Filter::parse(argv!["+no"]).unwrap().1; + let both = left.intersect(right); + assert_eq!( + both, + Filter { + conditions: vec![ + // from first filter + Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]), + Condition::HasTag("yes".into()), + // from second filter + Condition::HasTag("no".into()), + ], + } + ); + } + + #[test] + fn intersect_alltasks_alltasks() { + let left = Filter::parse(argv!["+yes"]).unwrap().1; + let right = Filter::parse(argv!["+no"]).unwrap().1; + let both = left.intersect(right); + assert_eq!( + both, + Filter { + conditions: vec![ + Condition::HasTag("yes".into()), + Condition::HasTag("no".into()), + ], } ); } diff --git a/cli/src/argparse/mod.rs b/cli/src/argparse/mod.rs index 0bf656a8f..ab54326f1 100644 --- a/cli/src/argparse/mod.rs +++ b/cli/src/argparse/mod.rs @@ -19,7 +19,7 @@ mod subcommand; pub(crate) use args::TaskId; pub(crate) use command::Command; -pub(crate) use filter::{Condition, Filter, Universe}; +pub(crate) use filter::{Condition, Filter}; pub(crate) use modification::{DescriptionMod, Modification}; pub(crate) use subcommand::Subcommand; diff --git a/cli/src/argparse/subcommand.rs b/cli/src/argparse/subcommand.rs index 0caf17ebd..1d7ddc2ba 100644 --- a/cli/src/argparse/subcommand.rs +++ b/cli/src/argparse/subcommand.rs @@ -383,7 +383,7 @@ impl Sync { #[cfg(test)] mod test { use super::*; - use crate::argparse::Universe; + use crate::argparse::Condition; const EMPTY: Vec<&str> = vec![]; @@ -459,8 +459,7 @@ mod test { fn test_modify_description_multi() { let subcommand = Subcommand::Modify { filter: Filter { - universe: Universe::for_ids(vec![123]), - ..Default::default() + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], }, modification: Modification { description: DescriptionMod::Set(s!("foo bar")), @@ -477,8 +476,7 @@ mod test { fn test_append() { let subcommand = Subcommand::Modify { filter: Filter { - universe: Universe::for_ids(vec![123]), - ..Default::default() + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], }, modification: Modification { description: DescriptionMod::Append(s!("foo bar")), @@ -495,8 +493,7 @@ mod test { fn test_prepend() { let subcommand = Subcommand::Modify { filter: Filter { - universe: Universe::for_ids(vec![123]), - ..Default::default() + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], }, modification: Modification { description: DescriptionMod::Prepend(s!("foo bar")), @@ -513,8 +510,7 @@ mod test { fn test_done() { let subcommand = Subcommand::Modify { filter: Filter { - universe: Universe::for_ids(vec![123]), - ..Default::default() + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], }, modification: Modification { status: Some(Status::Completed), @@ -531,8 +527,7 @@ mod test { fn test_done_modify() { let subcommand = Subcommand::Modify { filter: Filter { - universe: Universe::for_ids(vec![123]), - ..Default::default() + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], }, modification: Modification { description: DescriptionMod::Set(s!("now-finished")), @@ -550,8 +545,7 @@ mod test { fn test_start() { let subcommand = Subcommand::Modify { filter: Filter { - universe: Universe::for_ids(vec![123]), - ..Default::default() + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], }, modification: Modification { active: Some(true), @@ -568,8 +562,7 @@ mod test { fn test_start_modify() { let subcommand = Subcommand::Modify { filter: Filter { - universe: Universe::for_ids(vec![123]), - ..Default::default() + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], }, modification: Modification { active: Some(true), @@ -587,8 +580,7 @@ mod test { fn test_stop() { let subcommand = Subcommand::Modify { filter: Filter { - universe: Universe::for_ids(vec![123]), - ..Default::default() + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], }, modification: Modification { active: Some(false), @@ -605,8 +597,7 @@ mod test { fn test_stop_modify() { let subcommand = Subcommand::Modify { filter: Filter { - universe: Universe::for_ids(vec![123]), - ..Default::default() + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], }, modification: Modification { description: DescriptionMod::Set(s!("mod")), @@ -636,8 +627,10 @@ mod test { fn test_report_filter_before() { let subcommand = Subcommand::Report { filter: Filter { - universe: Universe::for_ids(vec![12, 13]), - ..Default::default() + conditions: vec![Condition::IdList(vec![ + TaskId::WorkingSetId(12), + TaskId::WorkingSetId(13), + ])], }, report_name: "foo".to_owned(), }; @@ -651,8 +644,10 @@ mod test { fn test_report_filter_after() { let subcommand = Subcommand::Report { filter: Filter { - universe: Universe::for_ids(vec![12, 13]), - ..Default::default() + conditions: vec![Condition::IdList(vec![ + TaskId::WorkingSetId(12), + TaskId::WorkingSetId(13), + ])], }, report_name: "foo".to_owned(), }; @@ -666,8 +661,10 @@ mod test { fn test_report_filter_next() { let subcommand = Subcommand::Report { filter: Filter { - universe: Universe::for_ids(vec![12, 13]), - ..Default::default() + conditions: vec![Condition::IdList(vec![ + TaskId::WorkingSetId(12), + TaskId::WorkingSetId(13), + ])], }, report_name: "next".to_owned(), }; @@ -696,8 +693,10 @@ mod test { let subcommand = Subcommand::Info { debug: false, filter: Filter { - universe: Universe::for_ids(vec![12, 13]), - ..Default::default() + conditions: vec![Condition::IdList(vec![ + TaskId::WorkingSetId(12), + TaskId::WorkingSetId(13), + ])], }, }; assert_eq!( @@ -711,8 +710,7 @@ mod test { let subcommand = Subcommand::Info { debug: true, filter: Filter { - universe: Universe::for_ids(vec![12]), - ..Default::default() + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(12)])], }, }; assert_eq!( diff --git a/cli/src/invocation/filter.rs b/cli/src/invocation/filter.rs index f87263270..34fc1b1e1 100644 --- a/cli/src/invocation/filter.rs +++ b/cli/src/invocation/filter.rs @@ -1,10 +1,10 @@ -use crate::argparse::{Condition, Filter, TaskId, Universe}; +use crate::argparse::{Condition, Filter, TaskId}; use failure::Fallible; use std::collections::HashSet; use std::convert::TryInto; -use taskchampion::{Replica, Tag, Task}; +use taskchampion::{Replica, Status, Tag, Task, Uuid}; -fn match_task(filter: &Filter, task: &Task) -> bool { +fn match_task(filter: &Filter, task: &Task, uuid: Uuid, working_set_id: Option) -> bool { for cond in &filter.conditions { match cond { Condition::HasTag(ref tag) => { @@ -21,11 +21,85 @@ fn match_task(filter: &Filter, task: &Task) -> bool { return false; } } + Condition::Status(status) => { + if task.get_status() != *status { + return false; + } + } + Condition::IdList(ids) => { + let uuid_str = uuid.to_string(); + let mut found = false; + for id in ids { + if match id { + TaskId::WorkingSetId(i) => Some(*i) == working_set_id, + TaskId::PartialUuid(partial) => uuid_str.starts_with(partial), + TaskId::Uuid(i) => *i == uuid, + } { + found = true; + break; + } + } + if !found { + return false; + } + } } } true } +// the universe of tasks we must consider +enum Universe { + /// Scan all the tasks + AllTasks, + /// Scan the working set (for pending tasks) + WorkingSet, + /// Scan an explicit set of tasks, "Absolute" meaning either full UUID or a working set + /// index + AbsoluteIdList(Vec), +} + +/// Determine the universe for the given filter; avoiding the need to scan all tasks in most cases. +fn universe_for_filter(filter: &Filter) -> Universe { + /// If there is a condition with Status::Pending, return true + fn has_pending_condition(filter: &Filter) -> bool { + filter + .conditions + .iter() + .any(|cond| matches!(cond, Condition::Status(Status::Pending))) + } + + /// If there is a condition with an IdList containing no partial UUIDs, + /// return that. + fn absolute_id_list_condition(filter: &Filter) -> Option> { + filter + .conditions + .iter() + .find(|cond| { + if let Condition::IdList(ids) = cond { + !ids.iter().any(|id| matches!(id, TaskId::PartialUuid(_))) + } else { + false + } + }) + .map(|cond| { + if let Condition::IdList(ids) = cond { + ids.to_vec() + } else { + unreachable!() // any condition found above must be an IdList(_) + } + }) + } + + if let Some(ids) = absolute_id_list_condition(filter) { + Universe::AbsoluteIdList(ids) + } else if has_pending_condition(filter) { + Universe::WorkingSet + } else { + Universe::AllTasks + } +} + /// Return the tasks matching the given filter. This will return each matching /// task once, even if the user specified the same task multiple times on the /// command line. @@ -35,38 +109,14 @@ pub(super) fn filtered_tasks( ) -> Fallible> { let mut res = vec![]; - fn is_partial_uuid(taskid: &TaskId) -> bool { - matches!(taskid, TaskId::PartialUuid(_)) - } + log::debug!("Applying filter {:?}", filter); // We will enumerate the universe of tasks for this filter, checking // each resulting task with match_task - match filter.universe { + match universe_for_filter(filter) { // A list of IDs, but some are partial so we need to iterate over // all tasks and pattern-match their Uuids - Universe::IdList(ref ids) if ids.iter().any(is_partial_uuid) => { - log::debug!("Scanning entire task database due to partial UUIDs in the filter"); - 'task: for (uuid, task) in replica.all_tasks()?.drain() { - for id in ids { - let in_universe = match id { - TaskId::WorkingSetId(id) => { - // NOTE: (#108) this results in many reads of the working set; it - // may be better to cache this information here or in the Replica. - replica.get_working_set_index(&uuid)? == Some(*id) - } - TaskId::PartialUuid(prefix) => uuid.to_string().starts_with(prefix), - TaskId::Uuid(id) => id == &uuid, - }; - if in_universe && match_task(filter, &task) { - res.push(task); - continue 'task; - } - } - } - } - - // A list of full IDs, which we can fetch directly - Universe::IdList(ref ids) => { + Universe::AbsoluteIdList(ref ids) => { log::debug!("Scanning only the tasks specified in the filter"); // this is the only case where we might accidentally return the same task // several times, so we must track the seen tasks. @@ -74,7 +124,7 @@ pub(super) fn filtered_tasks( for id in ids { let task = match id { TaskId::WorkingSetId(id) => replica.get_working_set_task(*id)?, - TaskId::PartialUuid(_) => unreachable!(), // handled above + TaskId::PartialUuid(_) => unreachable!(), // not present in absolute id list TaskId::Uuid(id) => replica.get_task(id)?, }; @@ -86,7 +136,9 @@ pub(super) fn filtered_tasks( } seen.insert(uuid); - if match_task(filter, &task) { + let working_set_id = replica.get_working_set_index(&uuid)?; + + if match_task(filter, &task, uuid, working_set_id) { res.push(task); } } @@ -96,19 +148,20 @@ pub(super) fn filtered_tasks( // All tasks -- iterate over the full set Universe::AllTasks => { log::debug!("Scanning all tasks in the task database"); - for (_, task) in replica.all_tasks()?.drain() { - if match_task(filter, &task) { + for (uuid, task) in replica.all_tasks()?.drain() { + // Yikes, slow! https://github.com/djmitche/taskchampion/issues/108 + let working_set_id = replica.get_working_set_index(&uuid)?; + if match_task(filter, &task, uuid, working_set_id) { res.push(task); } } } - - // Pending tasks -- just scan the working set - Universe::PendingTasks => { + Universe::WorkingSet => { log::debug!("Scanning only the working set (pending tasks)"); - for task in replica.working_set()?.drain(..) { + for (i, task) in replica.working_set()?.drain(..).enumerate() { if let Some(task) = task { - if match_task(filter, &task) { + let uuid = *task.get_uuid(); + if match_task(filter, &task, uuid, Some(i)) { res.push(task); } } @@ -136,12 +189,11 @@ mod test { let t1uuid = *t1.get_uuid(); let filter = Filter { - universe: Universe::IdList(vec![ + conditions: vec![Condition::IdList(vec![ TaskId::Uuid(t1uuid), // A TaskId::WorkingSetId(1), // A (again, dups filtered) TaskId::Uuid(*t2.get_uuid()), // B - ]), - ..Default::default() + ])], }; let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter) .unwrap() @@ -165,12 +217,11 @@ mod test { let t2partial = t2uuid[..13].to_owned(); let filter = Filter { - universe: Universe::IdList(vec![ + conditions: vec![Condition::IdList(vec![ TaskId::Uuid(t1uuid), // A TaskId::WorkingSetId(1), // A (again, dups filtered) TaskId::PartialUuid(t2partial), // B - ]), - ..Default::default() + ])], }; let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter) .unwrap() @@ -189,10 +240,7 @@ mod test { replica.new_task(Status::Deleted, s!("C")).unwrap(); replica.gc().unwrap(); - let filter = Filter { - universe: Universe::AllTasks, - ..Default::default() - }; + let filter = Filter { conditions: vec![] }; let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter) .unwrap() .map(|t| t.get_description().to_owned()) @@ -224,9 +272,7 @@ mod test { // look for just "yes" (A and B) let filter = Filter { - universe: Universe::AllTasks, conditions: vec![Condition::HasTag(s!("yes"))], - ..Default::default() }; let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? .map(|t| t.get_description().to_owned()) @@ -236,9 +282,7 @@ mod test { // look for tags without "no" (A, D) let filter = Filter { - universe: Universe::AllTasks, conditions: vec![Condition::NoTag(s!("no"))], - ..Default::default() }; let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? .map(|t| t.get_description().to_owned()) @@ -248,9 +292,7 @@ mod test { // look for tags with "yes" and "no" (B) let filter = Filter { - universe: Universe::AllTasks, conditions: vec![Condition::HasTag(s!("yes")), Condition::HasTag(s!("no"))], - ..Default::default() }; let filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? .map(|t| t.get_description().to_owned()) @@ -270,8 +312,7 @@ mod test { replica.gc().unwrap(); let filter = Filter { - universe: Universe::PendingTasks, - ..Default::default() + conditions: vec![Condition::Status(Status::Pending)], }; let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter) .unwrap() diff --git a/cli/src/invocation/report.rs b/cli/src/invocation/report.rs index 0ed793f7d..82cc67bba 100644 --- a/cli/src/invocation/report.rs +++ b/cli/src/invocation/report.rs @@ -1,11 +1,11 @@ -use crate::argparse::Filter; +use crate::argparse::{Condition, Filter}; use crate::invocation::filtered_tasks; use crate::report::{Column, Property, Report, Sort, SortBy}; use crate::table; use failure::{bail, Fallible}; use prettytable::{Row, Table}; use std::cmp::Ordering; -use taskchampion::{Replica, Task, Uuid}; +use taskchampion::{Replica, Status, Task, Uuid}; use termcolor::WriteColor; // pending #123, this is a non-fallible way of looking up a task's working set index @@ -125,24 +125,26 @@ fn get_report(report_name: String, filter: Filter) -> Fallible { ascending: false, sort_by: SortBy::Uuid, }]; - use crate::argparse::Universe; - Ok(match report_name.as_ref() { + let mut report = match report_name.as_ref() { "list" => Report { columns, sort, - filter, + filter: Default::default(), }, "next" => Report { columns, sort, - // TODO: merge invocation filter and report filter filter: Filter { - universe: Universe::PendingTasks, - ..Default::default() + conditions: vec![Condition::Status(Status::Pending)], }, }, _ => bail!("Unknown report {:?}", report_name), - }) + }; + + // intersect the report's filter with the user-supplied filter + report.filter = report.filter.intersect(filter); + + Ok(report) } pub(super) fn display_report( From 83d8fc3b4edd4c436c4f95be68f7c86de9a7ea93 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Wed, 30 Dec 2020 00:28:48 +0000 Subject: [PATCH 3/4] Factor out the unnecessary ModArg struct --- cli/src/argparse/filter.rs | 63 ++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/cli/src/argparse/filter.rs b/cli/src/argparse/filter.rs index 4645de5da..ba01a3fd0 100644 --- a/cli/src/argparse/filter.rs +++ b/cli/src/argparse/filter.rs @@ -33,13 +33,6 @@ pub(crate) enum Condition { IdList(Vec), } -/// Internal struct representing a parsed filter argument -enum FilterArg { - // TODO: get rid of this whole enum - IdList(Vec), - Condition(Condition), -} - impl Filter { pub(super) fn parse(input: ArgList) -> IResult { fold_many0( @@ -52,27 +45,25 @@ impl Filter { } /// fold multiple filter args into a single Filter instance - fn with_arg(mut self, mod_arg: FilterArg) -> Filter { - match mod_arg { - FilterArg::IdList(mut id_list) => { - // If there is already an IdList condition, concatenate this one - // to it. Thus multiple IdList command-line args represent an OR - // operation. This assumes that the filter is still being built - // from command-line arguments and thus has at most one IdList - // condition. - if let Some(Condition::IdList(existing)) = self - .conditions - .iter_mut() - .find(|c| matches!(c, Condition::IdList(_))) - { - existing.append(&mut id_list); - } else { - self.conditions.push(Condition::IdList(id_list)); - } - } - FilterArg::Condition(cond) => { - self.conditions.push(cond); + fn with_arg(mut self, cond: Condition) -> Filter { + if let Condition::IdList(mut id_list) = cond { + // If there is already an IdList condition, concatenate this one + // to it. Thus multiple IdList command-line args represent an OR + // operation. This assumes that the filter is still being built + // from command-line arguments and thus has at most one IdList + // condition. + if let Some(Condition::IdList(existing)) = self + .conditions + .iter_mut() + .find(|c| matches!(c, Condition::IdList(_))) + { + existing.append(&mut id_list); + } else { + self.conditions.push(Condition::IdList(id_list)); } + } else { + // all other command-line conditions are AND'd together + self.conditions.push(cond); } self } @@ -87,23 +78,23 @@ impl Filter { // parsers - fn id_list(input: ArgList) -> IResult { - fn to_filterarg(input: Vec) -> Result { - Ok(FilterArg::IdList(input)) + fn id_list(input: ArgList) -> IResult { + fn to_filterarg(input: Vec) -> Result { + Ok(Condition::IdList(input)) } 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()))) + fn plus_tag(input: ArgList) -> IResult { + fn to_filterarg(input: &str) -> Result { + Ok(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()))) + fn minus_tag(input: ArgList) -> IResult { + fn to_filterarg(input: &str) -> Result { + Ok(Condition::NoTag(input.to_owned())) } map_res(arg_matching(minus_tag), to_filterarg)(input) } From b7c12eec1e731a769862152ae909c47a9e8d872b Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Wed, 30 Dec 2020 00:51:29 +0000 Subject: [PATCH 4/4] Allow filtering by status --- cli/src/argparse/args.rs | 48 +++++++++++++++++++++++++++++++- cli/src/argparse/filter.rs | 56 ++++++++++++++++++++++++++++++-------- 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/cli/src/argparse/args.rs b/cli/src/argparse/args.rs index dc568e7bf..337bca763 100644 --- a/cli/src/argparse/args.rs +++ b/cli/src/argparse/args.rs @@ -10,7 +10,7 @@ use nom::{ sequence::*, Err, IResult, }; -use taskchampion::Uuid; +use taskchampion::{Status, Uuid}; /// A task identifier, as given in a filter command-line expression #[derive(Debug, PartialEq, Clone)] @@ -40,6 +40,32 @@ pub(super) fn literal(literal: &'static str) -> impl Fn(&str) -> IResult<&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 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> { @@ -164,6 +190,26 @@ mod test { 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"); diff --git a/cli/src/argparse/filter.rs b/cli/src/argparse/filter.rs index ba01a3fd0..6a8033eb3 100644 --- a/cli/src/argparse/filter.rs +++ b/cli/src/argparse/filter.rs @@ -1,4 +1,4 @@ -use super::args::{arg_matching, id_list, minus_tag, plus_tag, TaskId}; +use super::args::{arg_matching, id_list, minus_tag, plus_tag, status_colon, TaskId}; use super::ArgList; use crate::usage; use nom::{branch::alt, combinator::*, multi::fold_many0, IResult}; @@ -25,7 +25,6 @@ pub(crate) enum Condition { /// Task does not have the given tag NoTag(String), - // TODO: add command-line syntax for this /// Task has the given status Status(Status), @@ -36,7 +35,12 @@ pub(crate) enum Condition { impl Filter { pub(super) fn parse(input: ArgList) -> IResult { fold_many0( - alt((Self::id_list, Self::plus_tag, Self::minus_tag)), + alt(( + Self::parse_id_list, + Self::parse_plus_tag, + Self::parse_minus_tag, + Self::parse_status, + )), Filter { ..Default::default() }, @@ -78,25 +82,32 @@ impl Filter { // parsers - fn id_list(input: ArgList) -> IResult { - fn to_filterarg(input: Vec) -> Result { + fn parse_id_list(input: ArgList) -> IResult { + fn to_condition(input: Vec) -> Result { Ok(Condition::IdList(input)) } - map_res(arg_matching(id_list), to_filterarg)(input) + map_res(arg_matching(id_list), to_condition)(input) } - fn plus_tag(input: ArgList) -> IResult { - fn to_filterarg(input: &str) -> Result { + fn parse_plus_tag(input: ArgList) -> IResult { + fn to_condition(input: &str) -> Result { Ok(Condition::HasTag(input.to_owned())) } - map_res(arg_matching(plus_tag), to_filterarg)(input) + map_res(arg_matching(plus_tag), to_condition)(input) } - fn minus_tag(input: ArgList) -> IResult { - fn to_filterarg(input: &str) -> Result { + fn parse_minus_tag(input: ArgList) -> IResult { + fn to_condition(input: &str) -> Result { Ok(Condition::NoTag(input.to_owned())) } - map_res(arg_matching(minus_tag), to_filterarg)(input) + map_res(arg_matching(minus_tag), to_condition)(input) + } + + fn parse_status(input: ArgList) -> IResult { + fn to_condition(input: Status) -> Result { + Ok(Condition::Status(input)) + } + map_res(arg_matching(status_colon), to_condition)(input) } // usage @@ -123,6 +134,12 @@ impl Filter { description: " Select tasks that do not have the given tag.", }); + u.filters.push(usage::Filter { + syntax: "status:pending, status:completed, status:deleted", + summary: "Task status", + description: " + Select tasks with the given status.", + }); } } @@ -218,6 +235,21 @@ mod test { ); } + #[test] + fn test_status() { + let (input, filter) = Filter::parse(argv!["status:completed", "status:pending"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + conditions: vec![ + Condition::Status(Status::Completed), + Condition::Status(Status::Pending), + ], + } + ); + } + #[test] fn intersect_idlist_idlist() { let left = Filter::parse(argv!["1,2", "+yes"]).unwrap().1;