From 00f548c71383d26a0a0b4684378504b796d0c06e Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sat, 26 Dec 2020 04:07:36 +0000 Subject: [PATCH] implement generic report generation --- cli/src/argparse/mod.rs | 2 +- cli/src/argparse/report.rs | 79 +++++-- cli/src/argparse/subcommand.rs | 46 +++- cli/src/invocation/cmd/list.rs | 29 +-- cli/src/invocation/mod.rs | 2 + cli/src/invocation/report.rs | 374 +++++++++++++++++++++++++++++++++ taskchampion/src/task.rs | 2 +- 7 files changed, 484 insertions(+), 50 deletions(-) create mode 100644 cli/src/invocation/report.rs diff --git a/cli/src/argparse/mod.rs b/cli/src/argparse/mod.rs index a6d8c72bd..01df66bed 100644 --- a/cli/src/argparse/mod.rs +++ b/cli/src/argparse/mod.rs @@ -22,7 +22,7 @@ pub(crate) use args::TaskId; pub(crate) use command::Command; pub(crate) use filter::{Condition, Filter, Universe}; pub(crate) use modification::{DescriptionMod, Modification}; -pub(crate) use report::Report; +pub(crate) use report::{Column, Property, Report, Sort, SortBy}; pub(crate) use subcommand::Subcommand; use crate::usage::Usage; diff --git a/cli/src/argparse/report.rs b/cli/src/argparse/report.rs index b800cafb6..3b569652c 100644 --- a/cli/src/argparse/report.rs +++ b/cli/src/argparse/report.rs @@ -1,33 +1,68 @@ -use super::{ArgList, Filter}; -use nom::IResult; +use super::Filter; /// A report specifies a filter as well as a sort order and information about which /// task attributes to display -#[derive(Debug, PartialEq, Default)] +#[derive(Clone, Debug, PartialEq, Default)] pub(crate) struct Report { + /// Columns to display in this report + pub columns: Vec, + /// Sort order for this report + pub sort: Vec, + /// Filter selecting tasks for this report pub filter: Filter, } -impl Report { - pub(super) fn parse(input: ArgList) -> IResult { - let (input, filter) = Filter::parse(input)?; - Ok((input, Report { filter })) - } +/// A column to display in a report +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Column { + /// The label for this column + pub label: String, + + /// The property to display + pub property: Property, } -#[cfg(test)] -mod test { - use super::*; +/// Task property to display in a report +#[derive(Clone, Debug, PartialEq)] +#[allow(dead_code)] +pub(crate) enum Property { + /// The task's ID, either working-set index or Uuid if no in the working set + Id, - #[test] - fn test_empty() { - let (input, report) = Report::parse(argv![]).unwrap(); - assert_eq!(input.len(), 0); - assert_eq!( - report, - Report { - ..Default::default() - } - ); - } + /// The task's full UUID + Uuid, + + /// Whether the task is active or not + Active, + + /// The task's description + Description, + + /// The task's tags + Tags, +} + +/// A sorting criterion for a sort operation. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Sort { + /// True if the sort should be "ascending" (a -> z, 0 -> 9, etc.) + pub ascending: bool, + + /// The property to sort on + pub sort_by: SortBy, +} + +/// Task property to sort by +#[derive(Clone, Debug, PartialEq)] +#[allow(dead_code)] +pub(crate) enum SortBy { + /// The task's ID, either working-set index or a UUID prefix; working + /// set tasks sort before others. + Id, + + /// The task's full UUID + Uuid, + + /// The task's description + Description, } diff --git a/cli/src/argparse/subcommand.rs b/cli/src/argparse/subcommand.rs index 5efa4c6d1..5884cc4a9 100644 --- a/cli/src/argparse/subcommand.rs +++ b/cli/src/argparse/subcommand.rs @@ -1,5 +1,7 @@ use super::args::*; -use super::{ArgList, DescriptionMod, Filter, Modification, Report}; +use super::{ + ArgList, Column, DescriptionMod, Filter, Modification, Property, Report, Sort, SortBy, +}; use crate::usage; use nom::{branch::alt, combinator::*, sequence::*, IResult}; use taskchampion::Status; @@ -252,12 +254,45 @@ 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() + } + } + fn parse(input: ArgList) -> IResult { - fn to_subcommand(input: (Report, &str)) -> Result { - Ok(Subcommand::List { report: input.0 }) + fn to_subcommand(input: (Filter, &str)) -> Result { + let report = Report { + filter: input.0, + ..List::default_report() + }; + Ok(Subcommand::List { report }) } map_res( - pair(Report::parse, arg_matching(literal("list"))), + pair(Filter::parse, arg_matching(literal("list"))), to_subcommand, )(input) } @@ -602,7 +637,7 @@ mod test { fn test_list() { let subcommand = Subcommand::List { report: Report { - ..Default::default() + ..List::default_report() }, }; assert_eq!( @@ -619,6 +654,7 @@ mod test { universe: Universe::for_ids(vec![12, 13]), ..Default::default() }, + ..List::default_report() }, }; assert_eq!( diff --git a/cli/src/invocation/cmd/list.rs b/cli/src/invocation/cmd/list.rs index b3d922527..48f8543b6 100644 --- a/cli/src/invocation/cmd/list.rs +++ b/cli/src/invocation/cmd/list.rs @@ -1,8 +1,6 @@ use crate::argparse::Report; -use crate::invocation::filtered_tasks; -use crate::table; +use crate::invocation::display_report; use failure::Fallible; -use prettytable::{cell, row, Table}; use taskchampion::Replica; use termcolor::WriteColor; @@ -11,29 +9,13 @@ pub(crate) fn execute( replica: &mut Replica, report: Report, ) -> Fallible<()> { - let mut t = Table::new(); - t.set_format(table::format()); - t.set_titles(row![b->"id", b->"act", b->"description"]); - for task in filtered_tasks(replica, &report.filter)? { - let uuid = task.get_uuid(); - let mut id = uuid.to_string(); - if let Some(i) = replica.get_working_set_index(&uuid)? { - id = i.to_string(); - } - let active = match task.is_active() { - true => "*", - false => "", - }; - t.add_row(row![id, active, task.get_description()]); - } - t.print(w)?; - Ok(()) + display_report(w, replica, &report) } #[cfg(test)] mod test { use super::*; - use crate::argparse::Filter; + use crate::argparse::{Column, Filter, Property}; use crate::invocation::test::*; use taskchampion::Status; @@ -47,6 +29,11 @@ mod test { filter: Filter { ..Default::default() }, + columns: vec![Column { + label: "Description".to_owned(), + property: Property::Description, + }], + ..Default::default() }; execute(&mut w, &mut replica, report).unwrap(); assert!(w.into_string().contains("my task")); diff --git a/cli/src/invocation/mod.rs b/cli/src/invocation/mod.rs index 1920bb2dc..d7bcaef0a 100644 --- a/cli/src/invocation/mod.rs +++ b/cli/src/invocation/mod.rs @@ -9,12 +9,14 @@ use termcolor::{ColorChoice, StandardStream}; mod cmd; mod filter; mod modify; +mod report; #[cfg(test)] mod test; use filter::filtered_tasks; use modify::apply_modification; +use report::display_report; /// Invoke the given Command in the context of the given settings #[allow(clippy::needless_return)] diff --git a/cli/src/invocation/report.rs b/cli/src/invocation/report.rs new file mode 100644 index 000000000..db5d577df --- /dev/null +++ b/cli/src/invocation/report.rs @@ -0,0 +1,374 @@ +use crate::argparse::{Column, Property, Report, SortBy}; +use crate::invocation::filtered_tasks; +use crate::table; +use failure::Fallible; +use prettytable::{Row, Table}; +use std::cmp::Ordering; +use taskchampion::{Replica, Task, Uuid}; +use termcolor::WriteColor; + +// pending #123, this is a non-fallible way of looking up a task's working set index +struct WorkingSet(Vec>); + +impl WorkingSet { + fn new(replica: &mut Replica) -> Fallible { + let working_set = replica.working_set()?; + Ok(Self( + working_set + .iter() + .map(|opt| opt.as_ref().map(|t| *t.get_uuid())) + .collect(), + )) + } + + fn index(&self, target: &Uuid) -> Option { + for (i, uuid) in self.0.iter().enumerate() { + if let Some(uuid) = uuid { + if uuid == target { + return Some(i); + } + } + } + None + } +} + +/// Sort tasks for the given report. +fn sort_tasks(tasks: &mut Vec, report: &Report, working_set: &WorkingSet) { + tasks.sort_by(|a, b| { + for s in &report.sort { + let ord = match s.sort_by { + SortBy::Id => { + let a_uuid = a.get_uuid(); + let b_uuid = b.get_uuid(); + let a_id = working_set.index(a_uuid); + let b_id = working_set.index(b_uuid); + println!("a_uuid {} -> a_id {:?}", a_uuid, a_id); + println!("b_uuid {} -> b_id {:?}", b_uuid, b_id); + match (a_id, b_id) { + (Some(a_id), Some(b_id)) => a_id.cmp(&b_id), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => a_uuid.cmp(b_uuid), + } + } + SortBy::Uuid => a.get_uuid().cmp(b.get_uuid()), + SortBy::Description => a.get_description().cmp(b.get_description()), + }; + // If this sort property is equal, go on to the next.. + if ord == Ordering::Equal { + continue; + } + // Reverse order if not ascending + if s.ascending { + return ord; + } else { + return ord.reverse(); + } + } + Ordering::Equal + }); +} + +/// Generate the string representation for the given task and column. +fn task_column(task: &Task, column: &Column, working_set: &WorkingSet) -> String { + match column.property { + Property::Id => { + let uuid = task.get_uuid(); + let mut id = uuid.to_string(); + if let Some(i) = working_set.index(uuid) { + id = i.to_string(); + } + id + } + Property::Uuid => { + let uuid = task.get_uuid(); + uuid.to_string() + } + Property::Active => match task.is_active() { + true => "*".to_owned(), + false => "".to_owned(), + }, + Property::Description => task.get_description().to_owned(), + Property::Tags => { + let mut tags = task + .get_tags() + .map(|t| format!("+{}", t)) + .collect::>(); + tags.sort(); + tags.join(" ") + } + } +} + +pub(super) fn display_report( + w: &mut W, + replica: &mut Replica, + report: &Report, +) -> Fallible<()> { + let mut t = Table::new(); + + 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); + + // ..set up the column titles + t.set_format(table::format()); + t.set_titles(report.columns.iter().map(|col| col.label.clone()).into()); + + // ..insert the data + for task in &tasks { + let row: Row = report + .columns + .iter() + .map(|col| task_column(task, col, &working_set)) + .collect::(); + t.add_row(row); + } + + // ..and display it + t.print(w)?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::argparse::{Column, Property, Report, Sort, SortBy}; + use crate::invocation::test::*; + use std::convert::TryInto; + use taskchampion::Status; + + fn create_tasks(replica: &mut Replica) -> [Uuid; 3] { + let t1 = replica.new_task(Status::Pending, s!("A")).unwrap(); + let t2 = replica.new_task(Status::Pending, s!("B")).unwrap(); + let t3 = replica.new_task(Status::Pending, s!("C")).unwrap(); + + // t2 is comleted and not in the working set + let mut t2 = t2.into_mut(replica); + t2.set_status(Status::Completed).unwrap(); + let t2 = t2.into_immut(); + + replica.gc().unwrap(); + + [*t1.get_uuid(), *t2.get_uuid(), *t3.get_uuid()] + } + + #[test] + fn sorting_by_descr() { + let mut replica = test_replica(); + create_tasks(&mut replica); + let working_set = WorkingSet::new(&mut replica).unwrap(); + let mut report = Report { + sort: vec![Sort { + ascending: true, + sort_by: SortBy::Description, + }], + ..Default::default() + }; + + // ascending + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect(); + assert_eq!(descriptions, vec!["A", "B", "C"]); + + // ascending + report.sort[0].ascending = false; + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect(); + assert_eq!(descriptions, vec!["C", "B", "A"]); + } + + #[test] + fn sorting_by_id() { + let mut replica = test_replica(); + create_tasks(&mut replica); + let working_set = WorkingSet::new(&mut replica).unwrap(); + let mut report = Report { + sort: vec![Sort { + ascending: true, + sort_by: SortBy::Id, + }], + ..Default::default() + }; + + // ascending + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect(); + assert_eq!(descriptions, vec!["A", "C", "B"]); + + // ascending + report.sort[0].ascending = false; + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect(); + assert_eq!(descriptions, vec!["B", "C", "A"]); + } + + #[test] + fn sorting_by_uuid() { + let mut replica = test_replica(); + let uuids = create_tasks(&mut replica); + let working_set = WorkingSet::new(&mut replica).unwrap(); + let report = Report { + sort: vec![Sort { + ascending: true, + sort_by: SortBy::Uuid, + }], + ..Default::default() + }; + + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let got_uuids: Vec<_> = tasks.iter().map(|t| *t.get_uuid()).collect(); + let mut exp_uuids = uuids.to_vec(); + exp_uuids.sort(); + assert_eq!(got_uuids, exp_uuids); + } + + #[test] + fn sorting_by_multiple() { + let mut replica = test_replica(); + create_tasks(&mut replica); + + // make a second task named A with a larger ID than the first + let t = replica.new_task(Status::Pending, s!("A")).unwrap(); + t.into_mut(&mut replica) + .add_tag(&("second".try_into().unwrap())) + .unwrap(); + + let working_set = WorkingSet::new(&mut replica).unwrap(); + let report = Report { + sort: vec![ + Sort { + ascending: false, + sort_by: SortBy::Description, + }, + Sort { + ascending: true, + sort_by: SortBy::Id, + }, + ], + ..Default::default() + }; + + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect(); + assert_eq!(descriptions, vec!["C", "B", "A", "A"]); + assert!(tasks[3].has_tag(&("second".try_into().unwrap()))); + } + + #[test] + fn task_column_id() { + let mut replica = test_replica(); + let uuids = create_tasks(&mut replica); + let working_set = WorkingSet::new(&mut replica).unwrap(); + + let task = replica.get_working_set_task(1).unwrap().unwrap(); + let column = Column { + label: s!(""), + property: Property::Id, + }; + assert_eq!(task_column(&task, &column, &working_set), s!("1")); + + // get the task that's not in the working set, which should show + // a uuid for its id column + let task = replica.get_task(&uuids[1]).unwrap().unwrap(); + assert_eq!( + task_column(&task, &column, &working_set), + uuids[1].to_string() + ); + } + + #[test] + fn task_column_uuid() { + let mut replica = test_replica(); + create_tasks(&mut replica); + let working_set = WorkingSet::new(&mut replica).unwrap(); + + let task = replica.get_working_set_task(1).unwrap().unwrap(); + let column = Column { + label: s!(""), + property: Property::Uuid, + }; + assert_eq!( + task_column(&task, &column, &working_set), + task.get_uuid().to_string() + ); + } + + #[test] + fn task_column_active() { + let mut replica = test_replica(); + let uuids = create_tasks(&mut replica); + let working_set = WorkingSet::new(&mut replica).unwrap(); + + // make task A active + replica + .get_task(&uuids[0]) + .unwrap() + .unwrap() + .into_mut(&mut replica) + .start() + .unwrap(); + + let column = Column { + label: s!(""), + property: Property::Active, + }; + + let task = replica.get_working_set_task(1).unwrap().unwrap(); + assert_eq!(task_column(&task, &column, &working_set), s!("*")); + let task = replica.get_working_set_task(2).unwrap().unwrap(); + assert_eq!(task_column(&task, &column, &working_set), s!("")); + } + + #[test] + fn task_column_description() { + let mut replica = test_replica(); + create_tasks(&mut replica); + let working_set = WorkingSet::new(&mut replica).unwrap(); + + let task = replica.get_working_set_task(2).unwrap().unwrap(); + let column = Column { + label: s!(""), + property: Property::Description, + }; + assert_eq!(task_column(&task, &column, &working_set), s!("C")); + } + + #[test] + fn task_column_tags() { + let mut replica = test_replica(); + let uuids = create_tasks(&mut replica); + let working_set = WorkingSet::new(&mut replica).unwrap(); + + // add some tags to task A + let mut t1 = replica + .get_task(&uuids[0]) + .unwrap() + .unwrap() + .into_mut(&mut replica); + t1.add_tag(&("foo".try_into().unwrap())).unwrap(); + t1.add_tag(&("bar".try_into().unwrap())).unwrap(); + + let column = Column { + label: s!(""), + property: Property::Tags, + }; + + let task = replica.get_working_set_task(1).unwrap().unwrap(); + assert_eq!(task_column(&task, &column, &working_set), s!("+bar +foo")); + let task = replica.get_working_set_task(2).unwrap().unwrap(); + assert_eq!(task_column(&task, &column, &working_set), s!("")); + } +} +// TODO: test task_column diff --git a/taskchampion/src/task.rs b/taskchampion/src/task.rs index 8997919de..8736a97da 100644 --- a/taskchampion/src/task.rs +++ b/taskchampion/src/task.rs @@ -148,7 +148,7 @@ pub struct Annotation { /// /// This struct contains only getters for various values on the task. The `into_mut` method returns /// a TaskMut which can be used to modify the task. -#[derive(Debug, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub struct Task { uuid: Uuid, taskmap: TaskMap,