diff --git a/cli/src/argparse/args.rs b/cli/src/argparse/args.rs index c58b5ac51..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)] @@ -30,11 +30,42 @@ 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 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> { @@ -159,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 f381e5862..6a8033eb3 100644 --- a/cli/src/argparse/filter.rs +++ b/cli/src/argparse/filter.rs @@ -1,7 +1,8 @@ -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}; +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,66 +24,94 @@ pub(crate) enum Condition { /// Task does not have the given tag NoTag(String), -} -/// Internal struct representing a parsed filter argument -enum FilterArg { + /// Task has the given status + Status(Status), + + /// Task has one of the given IDs IdList(Vec), - Condition(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() }, - 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 { - 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 { - existing.append(&mut id_list); - } else { - acc.universe = Universe::IdList(id_list); - } - } - FilterArg::Condition(cond) => { - acc.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); } - acc + self } - fn id_list(input: ArgList) -> IResult { - fn to_filterarg(input: Vec) -> Result { - Ok(FilterArg::IdList(input)) - } - map_res(arg_matching(id_list), to_filterarg)(input) + /// 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 } - fn plus_tag(input: ArgList) -> IResult { - fn to_filterarg(input: &str) -> Result { - Ok(FilterArg::Condition(Condition::HasTag(input.to_owned()))) + // parsers + + fn parse_id_list(input: ArgList) -> IResult { + fn to_condition(input: Vec) -> Result { + Ok(Condition::IdList(input)) } - map_res(arg_matching(plus_tag), to_filterarg)(input) + map_res(arg_matching(id_list), to_condition)(input) } - fn minus_tag(input: ArgList) -> IResult { - fn to_filterarg(input: &str) -> Result { - Ok(FilterArg::Condition(Condition::NoTag(input.to_owned()))) + fn parse_plus_tag(input: ArgList) -> IResult { + fn to_condition(input: &str) -> Result { + Ok(Condition::HasTag(input.to_owned())) } - map_res(arg_matching(minus_tag), to_filterarg)(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())) + } + 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 + pub(super) fn get_usage(u: &mut usage::Usage) { u.filters.push(usage::Filter { syntax: "TASKID[,TASKID,..]", @@ -134,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.", + }); } } @@ -160,8 +166,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 +178,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 +194,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 +211,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 +226,81 @@ 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 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; + 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 01df66bed..ab54326f1 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 filter::{Condition, Filter}; 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..1d7ddc2ba 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", }); } } @@ -396,7 +383,7 @@ impl Sync { #[cfg(test)] mod test { use super::*; - use crate::argparse::Universe; + use crate::argparse::Condition; const EMPTY: Vec<&str> = vec![]; @@ -472,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")), @@ -490,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")), @@ -508,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")), @@ -526,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), @@ -544,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")), @@ -563,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), @@ -581,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), @@ -600,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), @@ -618,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")), @@ -634,31 +612,78 @@ 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 { + conditions: vec![Condition::IdList(vec![ + TaskId::WorkingSetId(12), + TaskId::WorkingSetId(13), + ])], }, + 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 { + conditions: vec![Condition::IdList(vec![ + TaskId::WorkingSetId(12), + TaskId::WorkingSetId(13), + ])], + }, + 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 { + conditions: vec![Condition::IdList(vec![ + TaskId::WorkingSetId(12), + TaskId::WorkingSetId(13), + ])], + }, + 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) ); } @@ -668,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!( @@ -683,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!( @@ -704,11 +730,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/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/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..82cc67bba 100644 --- a/cli/src/invocation/report.rs +++ b/cli/src/invocation/report.rs @@ -1,10 +1,11 @@ -use crate::argparse::{Column, Property, Report, SortBy}; +use crate::argparse::{Condition, 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}; +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 @@ -101,20 +102,66 @@ 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, + }]; + let mut report = match report_name.as_ref() { + "list" => Report { + columns, + sort, + filter: Default::default(), + }, + "next" => Report { + columns, + sort, + filter: Filter { + 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( 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 +185,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 +418,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