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::*,
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<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
pub(super) fn id_list(input: &str) -> IResult<&str, Vec<TaskId>> {
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");

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 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<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.
#[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<TaskId>),
Condition(Condition),
}
impl Filter {
pub(super) fn parse(input: ArgList) -> IResult<ArgList, Filter> {
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<ArgList, FilterArg> {
fn to_filterarg(input: Vec<TaskId>) -> Result<FilterArg, ()> {
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<ArgList, FilterArg> {
fn to_filterarg(input: &str) -> Result<FilterArg, ()> {
Ok(FilterArg::Condition(Condition::HasTag(input.to_owned())))
// parsers
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 to_filterarg(input: &str) -> Result<FilterArg, ()> {
Ok(FilterArg::Condition(Condition::NoTag(input.to_owned())))
fn parse_plus_tag(input: ArgList) -> IResult<ArgList, Condition> {
fn to_condition(input: &str) -> Result<Condition, ()> {
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) {
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()),
],
}
);
}

View File

@@ -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;

View File

@@ -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<ArgList, Subcommand> {
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<ArgList, Subcommand> {
fn to_subcommand(input: (Filter, &str)) -> Result<Subcommand, ()> {
let report = Report {
filter: input.0,
..List::default_report()
};
Ok(Subcommand::List { report })
fn to_subcommand(filter: Filter, report_name: &str) -> Result<Subcommand, ()> {
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]

View File

@@ -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;

View File

@@ -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: WriteColor>(
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"));
}
}

View File

@@ -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<usize>) -> 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<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
/// 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<impl Iterator<Item = Task>> {
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()

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),
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 },

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::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<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>(
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<W: WriteColor>(
#[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

View File

@@ -38,6 +38,7 @@ mod macros;
mod argparse;
mod invocation;
mod report;
mod settings;
mod table;
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
/// task attributes to display