Merge pull request #133 from djmitche/issue121

Use named reports
This commit is contained in:
Dustin J. Mitchell
2020-12-29 20:06:19 -05:00
committed by GitHub
11 changed files with 495 additions and 261 deletions

View File

@@ -10,7 +10,7 @@ use nom::{
sequence::*, sequence::*,
Err, IResult, Err, IResult,
}; };
use taskchampion::Uuid; use taskchampion::{Status, Uuid};
/// A task identifier, as given in a filter command-line expression /// A task identifier, as given in a filter command-line expression
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
@@ -30,11 +30,42 @@ pub(super) fn any(input: &str) -> IResult<&str, &str> {
rest(input) 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 /// Recognizes a literal string
pub(super) fn literal(literal: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { pub(super) fn literal(literal: &'static str) -> impl Fn(&str) -> IResult<&str, &str> {
move |input: &str| all_consuming(nomtag(literal))(input) 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<Status, ()> {
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 /// Recognizes a comma-separated list of TaskIds
pub(super) fn id_list(input: &str) -> IResult<&str, Vec<TaskId>> { pub(super) fn id_list(input: &str) -> IResult<&str, Vec<TaskId>> {
fn hex_n(n: usize) -> impl Fn(&str) -> IResult<&str, &str> { 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()); 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] #[test]
fn test_plus_tag() { fn test_plus_tag() {
assert_eq!(plus_tag("+abc").unwrap().1, "abc"); assert_eq!(plus_tag("+abc").unwrap().1, "abc");

View File

@@ -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 super::ArgList;
use crate::usage; use crate::usage;
use nom::{branch::alt, combinator::*, multi::fold_many0, IResult}; use nom::{branch::alt, combinator::*, multi::fold_many0, IResult};
use taskchampion::Status;
/// A filter represents a selection of a particular set of tasks. /// 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. /// pending tasks, or all tasks.
#[derive(Debug, PartialEq, Default, Clone)] #[derive(Debug, PartialEq, Default, Clone)]
pub(crate) struct Filter { 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 /// A set of filter conditions, all of which must match a task in order for that task to be
/// selected. /// selected.
pub(crate) conditions: Vec<Condition>, pub(crate) conditions: Vec<Condition>,
} }
/// 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<TaskId>),
/// 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<usize>) -> 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. /// A condition which tasks must match to be accepted by the filter.
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
pub(crate) enum Condition { pub(crate) enum Condition {
@@ -52,66 +24,94 @@ pub(crate) enum Condition {
/// Task does not have the given tag /// Task does not have the given tag
NoTag(String), NoTag(String),
}
/// Internal struct representing a parsed filter argument /// Task has the given status
enum FilterArg { Status(Status),
/// Task has one of the given IDs
IdList(Vec<TaskId>), IdList(Vec<TaskId>),
Condition(Condition),
} }
impl Filter { impl Filter {
pub(super) fn parse(input: ArgList) -> IResult<ArgList, Filter> { pub(super) fn parse(input: ArgList) -> IResult<ArgList, Filter> {
fold_many0( 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 { Filter {
..Default::default() ..Default::default()
}, },
Self::fold_args, |acc, arg| acc.with_arg(arg),
)(input) )(input)
} }
/// fold multiple filter args into a single Filter instance /// fold multiple filter args into a single Filter instance
fn fold_args(mut acc: Filter, mod_arg: FilterArg) -> Filter { fn with_arg(mut self, cond: Condition) -> Filter {
match mod_arg { if let Condition::IdList(mut id_list) = cond {
FilterArg::IdList(mut id_list) => { // If there is already an IdList condition, concatenate this one
// If any IDs are specified, then the filter's universe // to it. Thus multiple IdList command-line args represent an OR
// is those IDs. If there are already IDs, append to the // operation. This assumes that the filter is still being built
// list. // from command-line arguments and thus has at most one IdList
if let Universe::IdList(ref mut existing) = acc.universe { // condition.
existing.append(&mut id_list); if let Some(Condition::IdList(existing)) = self
} else { .conditions
acc.universe = Universe::IdList(id_list); .iter_mut()
} .find(|c| matches!(c, Condition::IdList(_)))
} {
FilterArg::Condition(cond) => { existing.append(&mut id_list);
acc.conditions.push(cond); } 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<ArgList, FilterArg> { /// combine this filter with another filter in an AND operation
fn to_filterarg(input: Vec<TaskId>) -> Result<FilterArg, ()> { pub(crate) fn intersect(mut self, mut other: Filter) -> Filter {
Ok(FilterArg::IdList(input)) // simply concatenate the conditions
} self.conditions.append(&mut other.conditions);
map_res(arg_matching(id_list), to_filterarg)(input)
self
} }
fn plus_tag(input: ArgList) -> IResult<ArgList, FilterArg> { // parsers
fn to_filterarg(input: &str) -> Result<FilterArg, ()> {
Ok(FilterArg::Condition(Condition::HasTag(input.to_owned()))) fn parse_id_list(input: ArgList) -> IResult<ArgList, Condition> {
fn to_condition(input: Vec<TaskId>) -> Result<Condition, ()> {
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<ArgList, FilterArg> { fn parse_plus_tag(input: ArgList) -> IResult<ArgList, Condition> {
fn to_filterarg(input: &str) -> Result<FilterArg, ()> { fn to_condition(input: &str) -> Result<Condition, ()> {
Ok(FilterArg::Condition(Condition::NoTag(input.to_owned()))) 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<ArgList, Condition> {
fn to_condition(input: &str) -> Result<Condition, ()> {
Ok(Condition::NoTag(input.to_owned()))
}
map_res(arg_matching(minus_tag), to_condition)(input)
}
fn parse_status(input: ArgList) -> IResult<ArgList, Condition> {
fn to_condition(input: Status) -> Result<Condition, ()> {
Ok(Condition::Status(input))
}
map_res(arg_matching(status_colon), to_condition)(input)
}
// usage
pub(super) fn get_usage(u: &mut usage::Usage) { pub(super) fn get_usage(u: &mut usage::Usage) {
u.filters.push(usage::Filter { u.filters.push(usage::Filter {
syntax: "TASKID[,TASKID,..]", syntax: "TASKID[,TASKID,..]",
@@ -134,6 +134,12 @@ impl Filter {
description: " description: "
Select tasks that do not have the given tag.", 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!( assert_eq!(
filter, filter,
Filter { Filter {
universe: Universe::IdList(vec![TaskId::WorkingSetId(1)]), conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(1)])],
..Default::default()
} }
); );
} }
@@ -173,12 +178,11 @@ mod test {
assert_eq!( assert_eq!(
filter, filter,
Filter { Filter {
universe: Universe::IdList(vec![ conditions: vec![Condition::IdList(vec![
TaskId::WorkingSetId(1), TaskId::WorkingSetId(1),
TaskId::WorkingSetId(2), TaskId::WorkingSetId(2),
TaskId::WorkingSetId(3), TaskId::WorkingSetId(3),
]), ])],
..Default::default()
} }
); );
} }
@@ -190,13 +194,12 @@ mod test {
assert_eq!( assert_eq!(
filter, filter,
Filter { Filter {
universe: Universe::IdList(vec![ conditions: vec![Condition::IdList(vec![
TaskId::WorkingSetId(1), TaskId::WorkingSetId(1),
TaskId::WorkingSetId(2), TaskId::WorkingSetId(2),
TaskId::WorkingSetId(3), TaskId::WorkingSetId(3),
TaskId::WorkingSetId(4), TaskId::WorkingSetId(4),
]), ])],
..Default::default()
} }
); );
} }
@@ -208,11 +211,10 @@ mod test {
assert_eq!( assert_eq!(
filter, filter,
Filter { Filter {
universe: Universe::IdList(vec![ conditions: vec![Condition::IdList(vec![
TaskId::WorkingSetId(1), TaskId::WorkingSetId(1),
TaskId::PartialUuid(s!("abcd1234")), TaskId::PartialUuid(s!("abcd1234")),
]), ])],
..Default::default()
} }
); );
} }
@@ -224,12 +226,81 @@ mod test {
assert_eq!( assert_eq!(
filter, filter,
Filter { Filter {
universe: Universe::IdList(vec![TaskId::WorkingSetId(1),]),
conditions: vec![ conditions: vec![
Condition::IdList(vec![TaskId::WorkingSetId(1),]),
Condition::HasTag("yes".into()), Condition::HasTag("yes".into()),
Condition::NoTag("no".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()),
],
} }
); );
} }

View File

@@ -15,14 +15,12 @@ mod args;
mod command; mod command;
mod filter; mod filter;
mod modification; mod modification;
mod report;
mod subcommand; mod subcommand;
pub(crate) use args::TaskId; pub(crate) use args::TaskId;
pub(crate) use command::Command; 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 modification::{DescriptionMod, Modification};
pub(crate) use report::{Column, Property, Report, Sort, SortBy};
pub(crate) use subcommand::Subcommand; pub(crate) use subcommand::Subcommand;
use crate::usage::Usage; use crate::usage::Usage;

View File

@@ -1,7 +1,5 @@
use super::args::*; use super::args::*;
use super::{ use super::{ArgList, DescriptionMod, Filter, Modification};
ArgList, Column, DescriptionMod, Filter, Modification, Property, Report, Sort, SortBy,
};
use crate::usage; use crate::usage;
use nom::{branch::alt, combinator::*, sequence::*, IResult}; use nom::{branch::alt, combinator::*, sequence::*, IResult};
use taskchampion::Status; use taskchampion::Status;
@@ -39,8 +37,12 @@ pub(crate) enum Subcommand {
}, },
/// Lists (reports) /// 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) /// Per-task information (typically one task)
@@ -56,16 +58,17 @@ pub(crate) enum Subcommand {
impl Subcommand { impl Subcommand {
pub(super) fn parse(input: ArgList) -> IResult<ArgList, Subcommand> { pub(super) fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
alt(( all_consuming(alt((
Version::parse, Version::parse,
Help::parse, Help::parse,
Add::parse, Add::parse,
Modify::parse, Modify::parse,
List::parse,
Info::parse, Info::parse,
Gc::parse, Gc::parse,
Sync::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) { pub(super) fn get_usage(u: &mut usage::Usage) {
@@ -73,10 +76,10 @@ impl Subcommand {
Help::get_usage(u); Help::get_usage(u);
Add::get_usage(u); Add::get_usage(u);
Modify::get_usage(u); Modify::get_usage(u);
List::get_usage(u);
Info::get_usage(u); Info::get_usage(u);
Gc::get_usage(u); Gc::get_usage(u);
Sync::get_usage(u); Sync::get_usage(u);
Report::get_usage(u);
} }
} }
@@ -251,59 +254,43 @@ impl Modify {
} }
} }
struct List; struct Report;
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()
}
}
impl Report {
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> { fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
fn to_subcommand(input: (Filter, &str)) -> Result<Subcommand, ()> { fn to_subcommand(filter: Filter, report_name: &str) -> Result<Subcommand, ()> {
let report = Report { Ok(Subcommand::Report {
filter: input.0, filter,
..List::default_report() report_name: report_name.to_owned(),
}; })
Ok(Subcommand::List { report })
} }
map_res( // allow the filter expression before or after the report name
pair(Filter::parse, arg_matching(literal("list"))), alt((
to_subcommand, map_res(pair(arg_matching(report_name), Filter::parse), |input| {
)(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) { fn get_usage(u: &mut usage::Usage) {
u.subcommands.push(usage::Subcommand { u.subcommands.push(usage::Subcommand {
name: "list", name: "report",
syntax: "[filter] list", syntax: "[filter] [report-name] *or* [report-name] [filter]",
summary: "List tasks", summary: "Show a report",
description: " 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)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use crate::argparse::Universe; use crate::argparse::Condition;
const EMPTY: Vec<&str> = vec![]; const EMPTY: Vec<&str> = vec![];
@@ -472,8 +459,7 @@ mod test {
fn test_modify_description_multi() { fn test_modify_description_multi() {
let subcommand = Subcommand::Modify { let subcommand = Subcommand::Modify {
filter: Filter { filter: Filter {
universe: Universe::for_ids(vec![123]), conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
..Default::default()
}, },
modification: Modification { modification: Modification {
description: DescriptionMod::Set(s!("foo bar")), description: DescriptionMod::Set(s!("foo bar")),
@@ -490,8 +476,7 @@ mod test {
fn test_append() { fn test_append() {
let subcommand = Subcommand::Modify { let subcommand = Subcommand::Modify {
filter: Filter { filter: Filter {
universe: Universe::for_ids(vec![123]), conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
..Default::default()
}, },
modification: Modification { modification: Modification {
description: DescriptionMod::Append(s!("foo bar")), description: DescriptionMod::Append(s!("foo bar")),
@@ -508,8 +493,7 @@ mod test {
fn test_prepend() { fn test_prepend() {
let subcommand = Subcommand::Modify { let subcommand = Subcommand::Modify {
filter: Filter { filter: Filter {
universe: Universe::for_ids(vec![123]), conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
..Default::default()
}, },
modification: Modification { modification: Modification {
description: DescriptionMod::Prepend(s!("foo bar")), description: DescriptionMod::Prepend(s!("foo bar")),
@@ -526,8 +510,7 @@ mod test {
fn test_done() { fn test_done() {
let subcommand = Subcommand::Modify { let subcommand = Subcommand::Modify {
filter: Filter { filter: Filter {
universe: Universe::for_ids(vec![123]), conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
..Default::default()
}, },
modification: Modification { modification: Modification {
status: Some(Status::Completed), status: Some(Status::Completed),
@@ -544,8 +527,7 @@ mod test {
fn test_done_modify() { fn test_done_modify() {
let subcommand = Subcommand::Modify { let subcommand = Subcommand::Modify {
filter: Filter { filter: Filter {
universe: Universe::for_ids(vec![123]), conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
..Default::default()
}, },
modification: Modification { modification: Modification {
description: DescriptionMod::Set(s!("now-finished")), description: DescriptionMod::Set(s!("now-finished")),
@@ -563,8 +545,7 @@ mod test {
fn test_start() { fn test_start() {
let subcommand = Subcommand::Modify { let subcommand = Subcommand::Modify {
filter: Filter { filter: Filter {
universe: Universe::for_ids(vec![123]), conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
..Default::default()
}, },
modification: Modification { modification: Modification {
active: Some(true), active: Some(true),
@@ -581,8 +562,7 @@ mod test {
fn test_start_modify() { fn test_start_modify() {
let subcommand = Subcommand::Modify { let subcommand = Subcommand::Modify {
filter: Filter { filter: Filter {
universe: Universe::for_ids(vec![123]), conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
..Default::default()
}, },
modification: Modification { modification: Modification {
active: Some(true), active: Some(true),
@@ -600,8 +580,7 @@ mod test {
fn test_stop() { fn test_stop() {
let subcommand = Subcommand::Modify { let subcommand = Subcommand::Modify {
filter: Filter { filter: Filter {
universe: Universe::for_ids(vec![123]), conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
..Default::default()
}, },
modification: Modification { modification: Modification {
active: Some(false), active: Some(false),
@@ -618,8 +597,7 @@ mod test {
fn test_stop_modify() { fn test_stop_modify() {
let subcommand = Subcommand::Modify { let subcommand = Subcommand::Modify {
filter: Filter { filter: Filter {
universe: Universe::for_ids(vec![123]), conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
..Default::default()
}, },
modification: Modification { modification: Modification {
description: DescriptionMod::Set(s!("mod")), description: DescriptionMod::Set(s!("mod")),
@@ -634,31 +612,78 @@ mod test {
} }
#[test] #[test]
fn test_list() { fn test_report() {
let subcommand = Subcommand::List { let subcommand = Subcommand::Report {
report: Report { filter: Default::default(),
..List::default_report() report_name: "myreport".to_owned(),
},
}; };
assert_eq!( assert_eq!(
Subcommand::parse(argv!["list"]).unwrap(), Subcommand::parse(argv!["myreport"]).unwrap(),
(&EMPTY[..], subcommand) (&EMPTY[..], subcommand)
); );
} }
#[test] #[test]
fn test_list_filter() { fn test_report_filter_before() {
let subcommand = Subcommand::List { let subcommand = Subcommand::Report {
report: Report { filter: Filter {
filter: Filter { conditions: vec![Condition::IdList(vec![
universe: Universe::for_ids(vec![12, 13]), TaskId::WorkingSetId(12),
..Default::default() TaskId::WorkingSetId(13),
}, ])],
..List::default_report()
}, },
report_name: "foo".to_owned(),
}; };
assert_eq!( 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) (&EMPTY[..], subcommand)
); );
} }
@@ -668,8 +693,10 @@ mod test {
let subcommand = Subcommand::Info { let subcommand = Subcommand::Info {
debug: false, debug: false,
filter: Filter { filter: Filter {
universe: Universe::for_ids(vec![12, 13]), conditions: vec![Condition::IdList(vec![
..Default::default() TaskId::WorkingSetId(12),
TaskId::WorkingSetId(13),
])],
}, },
}; };
assert_eq!( assert_eq!(
@@ -683,8 +710,7 @@ mod test {
let subcommand = Subcommand::Info { let subcommand = Subcommand::Info {
debug: true, debug: true,
filter: Filter { filter: Filter {
universe: Universe::for_ids(vec![12]), conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(12)])],
..Default::default()
}, },
}; };
assert_eq!( assert_eq!(
@@ -704,11 +730,7 @@ mod test {
#[test] #[test]
fn test_gc_extra_args() { fn test_gc_extra_args() {
let subcommand = Subcommand::Gc; assert!(Subcommand::parse(argv!["gc", "foo"]).is_err());
assert_eq!(
Subcommand::parse(argv!["gc", "foo"]).unwrap(),
(&vec!["foo"][..], subcommand)
);
} }
#[test] #[test]

View File

@@ -4,7 +4,7 @@ pub(crate) mod add;
pub(crate) mod gc; pub(crate) mod gc;
pub(crate) mod help; pub(crate) mod help;
pub(crate) mod info; pub(crate) mod info;
pub(crate) mod list;
pub(crate) mod modify; pub(crate) mod modify;
pub(crate) mod report;
pub(crate) mod sync; pub(crate) mod sync;
pub(crate) mod version; pub(crate) mod version;

View File

@@ -1,4 +1,4 @@
use crate::argparse::Report; use crate::argparse::Filter;
use crate::invocation::display_report; use crate::invocation::display_report;
use failure::Fallible; use failure::Fallible;
use taskchampion::Replica; use taskchampion::Replica;
@@ -7,35 +7,33 @@ use termcolor::WriteColor;
pub(crate) fn execute<W: WriteColor>( pub(crate) fn execute<W: WriteColor>(
w: &mut W, w: &mut W,
replica: &mut Replica, replica: &mut Replica,
report: Report, report_name: String,
filter: Filter,
) -> Fallible<()> { ) -> Fallible<()> {
display_report(w, replica, &report) display_report(w, replica, report_name, filter)
} }
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use crate::argparse::{Column, Filter, Property}; use crate::argparse::Filter;
use crate::invocation::test::*; use crate::invocation::test::*;
use taskchampion::Status; use taskchampion::Status;
#[test] #[test]
fn test_list() { fn test_report() {
let mut w = test_writer(); let mut w = test_writer();
let mut replica = test_replica(); let mut replica = test_replica();
replica.new_task(Status::Pending, s!("my task")).unwrap(); replica.new_task(Status::Pending, s!("my task")).unwrap();
let report = Report { // The function being tested is only one line long, so this is sort of an integration test
filter: Filter { // for display_report.
..Default::default()
}, let report_name = "next".to_owned();
columns: vec![Column { let filter = Filter {
label: "Description".to_owned(),
property: Property::Description,
}],
..Default::default() ..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")); assert!(w.into_string().contains("my task"));
} }
} }

View File

@@ -1,10 +1,10 @@
use crate::argparse::{Condition, Filter, TaskId, Universe}; use crate::argparse::{Condition, Filter, TaskId};
use failure::Fallible; use failure::Fallible;
use std::collections::HashSet; use std::collections::HashSet;
use std::convert::TryInto; 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<usize>) -> bool {
for cond in &filter.conditions { for cond in &filter.conditions {
match cond { match cond {
Condition::HasTag(ref tag) => { Condition::HasTag(ref tag) => {
@@ -21,11 +21,85 @@ fn match_task(filter: &Filter, task: &Task) -> bool {
return false; 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 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<TaskId>),
}
/// 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<Vec<TaskId>> {
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 /// 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 /// task once, even if the user specified the same task multiple times on the
/// command line. /// command line.
@@ -35,38 +109,14 @@ pub(super) fn filtered_tasks(
) -> Fallible<impl Iterator<Item = Task>> { ) -> Fallible<impl Iterator<Item = Task>> {
let mut res = vec![]; let mut res = vec![];
fn is_partial_uuid(taskid: &TaskId) -> bool { log::debug!("Applying filter {:?}", filter);
matches!(taskid, TaskId::PartialUuid(_))
}
// We will enumerate the universe of tasks for this filter, checking // We will enumerate the universe of tasks for this filter, checking
// each resulting task with match_task // 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 // A list of IDs, but some are partial so we need to iterate over
// all tasks and pattern-match their Uuids // all tasks and pattern-match their Uuids
Universe::IdList(ref ids) if ids.iter().any(is_partial_uuid) => { Universe::AbsoluteIdList(ref ids) => {
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) => {
log::debug!("Scanning only the tasks specified in the filter"); log::debug!("Scanning only the tasks specified in the filter");
// this is the only case where we might accidentally return the same task // this is the only case where we might accidentally return the same task
// several times, so we must track the seen tasks. // several times, so we must track the seen tasks.
@@ -74,7 +124,7 @@ pub(super) fn filtered_tasks(
for id in ids { for id in ids {
let task = match id { let task = match id {
TaskId::WorkingSetId(id) => replica.get_working_set_task(*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)?, TaskId::Uuid(id) => replica.get_task(id)?,
}; };
@@ -86,7 +136,9 @@ pub(super) fn filtered_tasks(
} }
seen.insert(uuid); 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); res.push(task);
} }
} }
@@ -96,19 +148,20 @@ pub(super) fn filtered_tasks(
// All tasks -- iterate over the full set // All tasks -- iterate over the full set
Universe::AllTasks => { Universe::AllTasks => {
log::debug!("Scanning all tasks in the task database"); log::debug!("Scanning all tasks in the task database");
for (_, task) in replica.all_tasks()?.drain() { for (uuid, task) in replica.all_tasks()?.drain() {
if match_task(filter, &task) { // 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); res.push(task);
} }
} }
} }
Universe::WorkingSet => {
// Pending tasks -- just scan the working set
Universe::PendingTasks => {
log::debug!("Scanning only the working set (pending tasks)"); 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 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); res.push(task);
} }
} }
@@ -136,12 +189,11 @@ mod test {
let t1uuid = *t1.get_uuid(); let t1uuid = *t1.get_uuid();
let filter = Filter { let filter = Filter {
universe: Universe::IdList(vec![ conditions: vec![Condition::IdList(vec![
TaskId::Uuid(t1uuid), // A TaskId::Uuid(t1uuid), // A
TaskId::WorkingSetId(1), // A (again, dups filtered) TaskId::WorkingSetId(1), // A (again, dups filtered)
TaskId::Uuid(*t2.get_uuid()), // B TaskId::Uuid(*t2.get_uuid()), // B
]), ])],
..Default::default()
}; };
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter) let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)
.unwrap() .unwrap()
@@ -165,12 +217,11 @@ mod test {
let t2partial = t2uuid[..13].to_owned(); let t2partial = t2uuid[..13].to_owned();
let filter = Filter { let filter = Filter {
universe: Universe::IdList(vec![ conditions: vec![Condition::IdList(vec![
TaskId::Uuid(t1uuid), // A TaskId::Uuid(t1uuid), // A
TaskId::WorkingSetId(1), // A (again, dups filtered) TaskId::WorkingSetId(1), // A (again, dups filtered)
TaskId::PartialUuid(t2partial), // B TaskId::PartialUuid(t2partial), // B
]), ])],
..Default::default()
}; };
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter) let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)
.unwrap() .unwrap()
@@ -189,10 +240,7 @@ mod test {
replica.new_task(Status::Deleted, s!("C")).unwrap(); replica.new_task(Status::Deleted, s!("C")).unwrap();
replica.gc().unwrap(); replica.gc().unwrap();
let filter = Filter { let filter = Filter { conditions: vec![] };
universe: Universe::AllTasks,
..Default::default()
};
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter) let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)
.unwrap() .unwrap()
.map(|t| t.get_description().to_owned()) .map(|t| t.get_description().to_owned())
@@ -224,9 +272,7 @@ mod test {
// look for just "yes" (A and B) // look for just "yes" (A and B)
let filter = Filter { let filter = Filter {
universe: Universe::AllTasks,
conditions: vec![Condition::HasTag(s!("yes"))], conditions: vec![Condition::HasTag(s!("yes"))],
..Default::default()
}; };
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
.map(|t| t.get_description().to_owned()) .map(|t| t.get_description().to_owned())
@@ -236,9 +282,7 @@ mod test {
// look for tags without "no" (A, D) // look for tags without "no" (A, D)
let filter = Filter { let filter = Filter {
universe: Universe::AllTasks,
conditions: vec![Condition::NoTag(s!("no"))], conditions: vec![Condition::NoTag(s!("no"))],
..Default::default()
}; };
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
.map(|t| t.get_description().to_owned()) .map(|t| t.get_description().to_owned())
@@ -248,9 +292,7 @@ mod test {
// look for tags with "yes" and "no" (B) // look for tags with "yes" and "no" (B)
let filter = Filter { let filter = Filter {
universe: Universe::AllTasks,
conditions: vec![Condition::HasTag(s!("yes")), Condition::HasTag(s!("no"))], conditions: vec![Condition::HasTag(s!("yes")), Condition::HasTag(s!("no"))],
..Default::default()
}; };
let filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? let filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
.map(|t| t.get_description().to_owned()) .map(|t| t.get_description().to_owned())
@@ -270,8 +312,7 @@ mod test {
replica.gc().unwrap(); replica.gc().unwrap();
let filter = Filter { let filter = Filter {
universe: Universe::PendingTasks, conditions: vec![Condition::Status(Status::Pending)],
..Default::default()
}; };
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter) let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)
.unwrap() .unwrap()

View File

@@ -60,9 +60,13 @@ pub(crate) fn invoke(command: Command, settings: Config) -> Fallible<()> {
} => return cmd::modify::execute(&mut w, &mut replica, filter, modification), } => return cmd::modify::execute(&mut w, &mut replica, filter, modification),
Command { 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 { Command {
subcommand: Subcommand::Info { filter, debug }, subcommand: Subcommand::Info { filter, debug },

View File

@@ -1,10 +1,11 @@
use crate::argparse::{Column, Property, Report, SortBy}; use crate::argparse::{Condition, Filter};
use crate::invocation::filtered_tasks; use crate::invocation::filtered_tasks;
use crate::report::{Column, Property, Report, Sort, SortBy};
use crate::table; use crate::table;
use failure::Fallible; use failure::{bail, Fallible};
use prettytable::{Row, Table}; use prettytable::{Row, Table};
use std::cmp::Ordering; use std::cmp::Ordering;
use taskchampion::{Replica, Task, Uuid}; use taskchampion::{Replica, Status, Task, Uuid};
use termcolor::WriteColor; use termcolor::WriteColor;
// pending #123, this is a non-fallible way of looking up a task's working set index // 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<Report> {
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: WriteColor>( pub(super) fn display_report<W: WriteColor>(
w: &mut W, w: &mut W,
replica: &mut Replica, replica: &mut Replica,
report: &Report, report_name: String,
filter: Filter,
) -> Fallible<()> { ) -> Fallible<()> {
let mut t = Table::new(); let mut t = Table::new();
let report = get_report(report_name, filter)?;
let working_set = WorkingSet::new(replica)?; let working_set = WorkingSet::new(replica)?;
// Get the tasks from the filter // Get the tasks from the filter
let mut tasks: Vec<_> = filtered_tasks(replica, &report.filter)?.collect(); let mut tasks: Vec<_> = filtered_tasks(replica, &report.filter)?.collect();
// ..sort them as desired // ..sort them as desired
sort_tasks(&mut tasks, report, &working_set); sort_tasks(&mut tasks, &report, &working_set);
// ..set up the column titles // ..set up the column titles
t.set_format(table::format()); t.set_format(table::format());
@@ -138,8 +185,8 @@ pub(super) fn display_report<W: WriteColor>(
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use crate::argparse::{Column, Property, Report, Sort, SortBy};
use crate::invocation::test::*; use crate::invocation::test::*;
use crate::report::Sort;
use std::convert::TryInto; use std::convert::TryInto;
use taskchampion::Status; use taskchampion::Status;
@@ -371,4 +418,3 @@ mod test {
assert_eq!(task_column(&task, &column, &working_set), s!("")); assert_eq!(task_column(&task, &column, &working_set), s!(""));
} }
} }
// TODO: test task_column

View File

@@ -38,6 +38,7 @@ mod macros;
mod argparse; mod argparse;
mod invocation; mod invocation;
mod report;
mod settings; mod settings;
mod table; mod table;
mod usage; mod usage;

View File

@@ -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 /// A report specifies a filter as well as a sort order and information about which
/// task attributes to display /// task attributes to display