From e496faee489dbddff37dd4c4b26e15e4b988ba70 Mon Sep 17 00:00:00 2001 From: aaron-jack-manning Date: Sat, 10 Sep 2022 15:14:47 +1000 Subject: [PATCH] verify duration invariants on serialize --- README.md | 1 + src/args.rs | 5 +- src/edit.rs | 7 - src/list.rs | 301 ++++++++++++++++++++++++++ src/main.rs | 7 +- src/tasks.rs | 580 +++++++++++++++++---------------------------------- 6 files changed, 496 insertions(+), 405 deletions(-) create mode 100644 src/list.rs diff --git a/README.md b/README.md index 7fc78ae..145c2af 100755 --- a/README.md +++ b/README.md @@ -86,3 +86,4 @@ Toru stores tasks and other metadata locally in the folder of the vault in the i - Validate invariants at the point of saving, to create consistency across creating and editing notes. - Convenient options with the edit command so that editing the raw file isn't the only option +- Check for if dependencies are complete when listing notes with no dependencies. diff --git a/src/args.rs b/src/args.rs index bcfe37f..379de43 100644 --- a/src/args.rs +++ b/src/args.rs @@ -81,10 +81,7 @@ pub enum Command { /// For tracking time against a task. Track { id_or_name : String, - #[clap(short='H', default_value_t=0)] - hours : u16, - #[clap(short='M', default_value_t=0)] - minutes : u16, + duration : tasks::Duration, /// Date for the time entry [default: Today] #[clap(short, long)] date : Option, diff --git a/src/edit.rs b/src/edit.rs index 0677614..7a89b41 100755 --- a/src/edit.rs +++ b/src/edit.rs @@ -75,13 +75,6 @@ pub fn edit_raw(id : Id, vault_folder : path::PathBuf, editor : &str, state : &m else { let mut edited_task = tasks::Task::load_direct(temp_path.clone(), true)?; - // Enforce time entry duration invariant. - for entry in &edited_task.data.time_entries { - if !entry.duration.satisfies_invariant() { - return Err(error::Error::Generic(String::from("Task duration must not have a number of minutes greater than 60"))) - } - } - // Make sure ID is not changed. if edited_task.data.id != task.data.id { Err(error::Error::Generic(String::from("You cannot change the ID of a task in a direct edit"))) diff --git a/src/list.rs b/src/list.rs new file mode 100644 index 0000000..e710f5e --- /dev/null +++ b/src/list.rs @@ -0,0 +1,301 @@ +use crate::args; +use crate::error; +use crate::state; +use crate::tasks; +use crate::format; + +use std::cmp; +use std::path; +use std::collections::HashSet; +use chrono::SubsecRound; + +impl args::ListOptions { + /// Combines list options coming from a profile and from the additional arguments given. Order + /// of the arguments provided matters, hence the argument names (because optional arguments + /// from the profile are overwritten by the additional arguments). + pub fn combine(profile : &Self, additional : &Self) -> Self { + /// Joins two vectors together one after the other, creating a new allocation. + fn concat(a : &Vec, b : &Vec) -> Vec { + let mut a = a.clone(); + a.extend(b.iter().cloned()); + a + } + + /// Takes two options, and prioritises the second if it is provided in the output, using + /// the first as a fallback, and returning None if both are None. + fn join_options(a : &Option, b : &Option) -> Option { + match (a, b) { + (Some(_), Some(b)) => Some(b.clone()), + (Some(a), None) => Some(a.clone()), + (None, Some(b)) => Some(b.clone()), + (None, None) => None, + } + } + + Self { + column : concat(&profile.column, &additional.column), + order_by : join_options(&profile.order_by, &additional.order_by), + order : join_options(&profile.order, &profile.order), + tag : concat(&profile.tag, &additional.tag), + exclude_tag : concat(&profile.exclude_tag, &additional.exclude_tag), + priority : concat(&profile.priority, &additional.priority), + due_before : join_options(&profile.due_before, &additional.due_before), + due_after : join_options(&profile.due_after, &additional.due_after), + created_before : join_options(&profile.created_before, &additional.created_before), + created_after : join_options(&profile.created_after, &additional.created_after), + include_completed : profile.include_completed || additional.include_completed, + no_dependencies : profile.no_dependencies || additional.no_dependencies, + no_dependents : profile.no_dependents || additional.no_dependents, + } + } +} + +/// Lists all tasks in the specified vault. +pub fn list(mut options : args::ListOptions, vault_folder : &path::Path, state : &state::State) -> Result<(), error::Error> { + + let mut table = comfy_table::Table::new(); + table + .load_preset(comfy_table::presets::UTF8_FULL) + .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS) + .set_content_arrangement(comfy_table::ContentArrangement::Dynamic); + + + let mut tasks : Box> = Box::new(tasks::Task::load_all(vault_folder, true)?.into_iter()); + + // Filter the tasks. + if let Some(date) = options.created_before { + tasks = Box::new(tasks.filter(move |t| t.data.created.date() <= date)); + } + if let Some(date) = options.created_after { + tasks = Box::new(tasks.filter(move |t| t.data.created.date() >= date)); + } + + if let Some(date) = options.due_before { + tasks = Box::new(tasks.filter(move |t| { + match tasks::compare_due_dates(&t.data.due.map(|d| d.date()), &Some(date)) { + cmp::Ordering::Less | cmp::Ordering::Equal => true, + cmp::Ordering::Greater => false, + } + })); + } + if let Some(date) = options.due_after { + tasks = Box::new(tasks.filter(move |t| { + match tasks::compare_due_dates(&t.data.due.map(|d| d.date()), &Some(date)) { + cmp::Ordering::Greater | cmp::Ordering::Equal => true, + cmp::Ordering::Less => false, + } + })); + } + + if !options.include_completed { + tasks = Box::new(tasks.filter(|t| t.data.completed.is_none())); + } + + if !options.tag.is_empty() { + let specified_tags : HashSet<_> = options.tag.iter().collect(); + + tasks = Box::new(tasks.filter(move |t| { + let task_tags : HashSet<_> = t.data.tags.iter().collect(); + + // Non empty intersection of tags means the task should be displayed + specified_tags.intersection(&task_tags).next().is_some() + })); + } + + if !options.exclude_tag.is_empty() { + let specified_tags : HashSet<_> = options.exclude_tag.iter().collect(); + + tasks = Box::new(tasks.filter(move |t| { + let task_tags : HashSet<_> = t.data.tags.iter().collect(); + + // If the task contains a tag which was supposed to be excluded, it should be filtered + // out + !specified_tags.intersection(&task_tags).next().is_some() + })); + } + + if !options.priority.is_empty() { + let specified_priority_levels : HashSet<_> = options.priority.iter().collect(); + + tasks = Box::new(tasks.filter(move |t| { + specified_priority_levels.contains(&t.data.priority) + })); + } + + if options.no_dependencies { + tasks = Box::new(tasks.filter(move |t| { + t.data.dependencies.is_empty() + })); + } + + if options.no_dependents { + let tasks_with_dependents = state.data.deps.get_tasks_with_dependents(); + + tasks = Box::new(tasks.filter(move |t| { + !tasks_with_dependents.contains(&t.data.id) + })); + } + + let mut tasks : Vec = tasks.collect(); + + + // Sort the tasks. + use super::{OrderBy, Order}; + match options.order_by.unwrap_or_default() { + OrderBy::Id => { + match options.order.unwrap_or_default() { + Order::Asc => { + tasks.sort_by(|t1, t2| t1.data.id.cmp(&t2.data.id)); + }, + Order::Desc => { + tasks.sort_by(|t1, t2| t2.data.id.cmp(&t1.data.id)); + }, + } + }, + OrderBy::Name => { + match options.order.unwrap_or_default() { + Order::Asc => { + tasks.sort_by(|t1, t2| t1.data.name.cmp(&t2.data.name)); + }, + Order::Desc => { + tasks.sort_by(|t1, t2| t2.data.name.cmp(&t1.data.name)); + }, + } + }, + OrderBy::Due => { + match options.order.unwrap_or_default() { + Order::Asc => { + tasks.sort_by(|t1, t2| tasks::compare_due_dates(&t1.data.due, &t2.data.due)); + }, + Order::Desc => { + tasks.sort_by(|t1, t2| tasks::compare_due_dates(&t2.data.due, &t1.data.due)); + }, + } + }, + OrderBy::Priority => { + match options.order.unwrap_or_default() { + Order::Asc => { + tasks.sort_by(|t1, t2| t1.data.priority.cmp(&t2.data.priority)); + }, + Order::Desc => { + tasks.sort_by(|t1, t2| t2.data.priority.cmp(&t1.data.priority)); + }, + } + }, + OrderBy::Created => { + match options.order.unwrap_or_default() { + Order::Asc => { + tasks.sort_by(|t1, t2| t1.data.created.cmp(&t2.data.created)); + }, + Order::Desc => { + tasks.sort_by(|t1, t2| t2.data.created.cmp(&t1.data.created)); + }, + } + }, + OrderBy::Tracked => { + match options.order.unwrap_or_default() { + Order::Asc => { + tasks.sort_by(|t1, t2| tasks::TimeEntry::total(&t1.data.time_entries).cmp(&tasks::TimeEntry::total(&t2.data.time_entries))); + }, + Order::Desc => { + tasks.sort_by(|t1, t2| tasks::TimeEntry::total(&t2.data.time_entries).cmp(&tasks::TimeEntry::total(&t1.data.time_entries))); + }, + } + } + } + + // Include the required columns + let mut headers = vec!["Id", "Name"]; + + // Remove duplicate columns. + options.column = { + let mut columns = HashSet::new(); + + options.column.clone() + .into_iter() + .filter(|c| { + if columns.contains(c) { + false + } + else { + columns.insert(c.clone()); + true + } + }) + .collect() + }; + + use super::Column; + for column in &options.column { + match column { + Column::Tracked => { + headers.push("Tracked"); + }, + Column::Due => { + headers.push("Due"); + }, + Column::Tags => { + headers.push("Tags"); + }, + Column::Priority => { + headers.push("Priority"); + }, + Column::Status => { + headers.push("Status"); + }, + Column::Created => { + headers.push("Created"); + }, + } + } + + table.set_header(headers); + + for task in tasks { + + use comfy_table::Cell; + let mut row = vec![Cell::from(task.data.id), Cell::from(task.data.name)]; + + for column in &options.column { + match column { + Column::Tracked => { + let duration = tasks::TimeEntry::total(&task.data.time_entries); + row.push( + Cell::from(if duration == tasks::Duration::zero() { String::new() } else { duration.to_string() }) + ); + }, + Column::Due => { + row.push(match task.data.due { + Some(due) => format::cell::due_date(&due, task.data.completed.is_none()), + None => Cell::from(String::new()) + }); + }, + Column::Tags => { + row.push(Cell::new(format::hash_set(&task.data.tags)?)); + }, + Column::Priority => { + row.push(format::cell::priority(&task.data.priority)); + }, + Column::Status => { + row.push( + Cell::new(if task.data.completed.is_some() { + String::from("complete") + } + else { + String::from("incomplete") + }) + ); + }, + Column::Created => { + row.push(Cell::new(task.data.created.round_subsecs(0).to_string())); + }, + } + } + + table.add_row(row); + } + + println!("{}", table); + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index fbcc66b..d665313 100755 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod vcs; mod edit; mod args; +mod list; mod vault; mod index; mod error; @@ -146,10 +147,10 @@ fn program() -> Result<(), error::Error> { } println!("Updated task {}", format::id(id)); }, - Command::Track { id_or_name, hours, minutes, date, message } => { + Command::Track { id_or_name, duration, date, message } => { let id = state.data.index.lookup(&id_or_name)?; let mut task = tasks::Task::load(id, vault_folder, false)?; - let entry = tasks::TimeEntry::new(hours, minutes, date, message); + let entry = tasks::TimeEntry::new(duration, date, message); task.data.time_entries.push(entry); task.save()?; }, @@ -181,7 +182,7 @@ fn program() -> Result<(), error::Error> { additional } }; - tasks::list(options, vault_folder, &state)?; + list::list(options, vault_folder, &state)?; }, // All commands which are dealt with in if let chain at start. Command::Vault(_) | Command::Config(_) | Command::Git { args : _ } | Command::Svn { args : _ } | Command::Switch { name : _ } | Command::GitIgnore | Command::SvnIgnore => unreachable!(), diff --git a/src/tasks.rs b/src/tasks.rs index 3ff256b..03d909b 100755 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -1,12 +1,10 @@ -use crate::args; use crate::error; use crate::state; use crate::format; use std::io; use std::fs; -use std::fmt; -use std::ops; +use std::str; use std::mem; use std::cmp; use std::path; @@ -22,28 +20,6 @@ pub struct Task { pub data : InternalTask, } -#[derive(Default, Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum, serde::Serialize, serde::Deserialize)] -pub enum Priority { - Backlog, - #[default] - Low, - Medium, - High, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct TimeEntry { - pub logged_date : chrono::NaiveDate, - pub message : Option, - pub duration : Duration, -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] -pub struct Duration { - hours : u16, - minutes : u16, -} - #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct InternalTask { pub id : Id, @@ -58,6 +34,193 @@ pub struct InternalTask { pub time_entries : Vec, } +#[derive(Default, Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum, serde::Serialize, serde::Deserialize)] +pub enum Priority { + Backlog, + #[default] + Low, + Medium, + High, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Duration { + hours : u16, + minutes : u16, +} + +impl Duration { + pub fn zero() -> Self { + Self { + hours : 0, + minutes : 0, + } + } +} + +pub mod duration { + use super::Duration; + + use std::ops; + use std::str; + use std::fmt; + + /// Serialize to custom format HH:MM where MM is padded to be two characters wide and HH can be + /// arbitrarily large. + impl serde::Serialize for Duration { + fn serialize(&self, serializer : S) -> Result { + serializer.serialize_str(&format!("{}:{:0>2}", self.hours, self.minutes)) + } + } + + /// Deserialize from custom format HH:MM where MM is an integer between 0 and 59 inclusive, and + /// HH is some integer representable as a u16. + /// The width of MM is not enforced for deserialization. + impl<'de> serde::Deserialize<'de> for Duration { + fn deserialize>(deserializer : D) -> Result { + let raw = String::deserialize(deserializer)?; + + use std::str::FromStr; + Self::from_str(&raw) + .map_err(|x| serde::de::Error::invalid_value(serde::de::Unexpected::Str(&raw), &x.serde_expected())) + } + } + + /// Custom type for errors when converting duration from str, with error messages for clap and + /// serde respectively. + #[derive(Debug)] + pub enum DurationRead { + /// For when the number of minutes is not less than 60. + Minutes, + /// For when either value cannot be parsed into a u16. + Range, + /// For general formatting error (i.e. split at colon doesn't produce two values). + General, + } + + impl fmt::Display for DurationRead { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DurationRead::Minutes => { + write!(f, "the number of minutes must be between 0 and 59 inclusive") + }, + DurationRead::Range => { + write!(f, "the number of hours and minutes must be representable as a u16") + }, + DurationRead::General => { + write!(f, "duration must be in the format HH:MM where HH is any integer (representable as a u16) and MM is an integer between 0 and 59 inclusive") + }, + } + } + } + + impl std::error::Error for DurationRead { } + + impl DurationRead { + /// Gives a str of what was expected (and not provided) when serializing. + pub fn serde_expected(&self) -> &'static str { + match self { + DurationRead::Minutes => { + "the number of minutes to be an integer between 0 and 59 inclusive" + }, + DurationRead::Range => { + "the number of hours and minutes to be representable as a u16" + }, + DurationRead::General => { + "a duration in the format HH:MM where HH is any integer (representable as a u16) and MM is an integer between 0 and 59 inclusive" + }, + } + } + } + + impl str::FromStr for Duration { + type Err = DurationRead; + + fn from_str(s : &str) -> Result { + if let &[h, m] = &s.split(':').collect::>()[..] { + if let (Ok(hours), Ok(minutes)) = (h.parse::(), m.parse::()) { + if minutes < 60 { + Ok(Self { + hours, + minutes, + }) + } + else { + Err(DurationRead::Minutes) + } + } + else { + Err(DurationRead::Range) + } + } + else { + Err(DurationRead::General) + } + } + } + + impl ops::Add for Duration { + type Output = Self; + + fn add(self, other : Self) -> Self::Output { + + Self { + hours : self.hours + other.hours + (self.minutes + other.minutes) / 60, + minutes : (self.minutes + other.minutes) % 60, + } + } + } + + impl ops::Div for Duration { + type Output = Self; + + fn div(self, divisor : usize) -> Self::Output { + let total_mins = f64::from(self.hours * 60 + self.minutes); + let divided_mins = total_mins / (divisor as f64); + let divided_mins = divided_mins.round() as u16; + + Self { + hours : divided_mins / 60, + minutes : divided_mins % 60, + } + } + } + + /// Same display format as serialization. + impl fmt::Display for Duration { + fn fmt(&self, f : &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{:0>2}", self.hours, self.minutes) + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TimeEntry { + pub logged_date : chrono::NaiveDate, + pub message : Option, + pub duration : Duration, +} + +impl TimeEntry { + /// Adds up the times from a collection of time entries. + pub fn total(entries : &[TimeEntry]) -> Duration { + entries + .iter() + .map(|e| e.duration) + .fold(Duration::zero(), |a, d| a + d) + } + + /// Creates a new TimeEntry, correctly validating and setting defaults. + pub fn new(duration : Duration, date : Option, message : Option) -> Self { + + Self { + logged_date : date.unwrap_or(chrono::Utc::now().naive_local().date()), + message, + duration, + } + } +} + impl Task { /// Creates a new task from the input data. pub fn new(name : String, info : Option, tags : Vec, dependencies : Vec, priority : Option, due : Option, vault_folder : &path::Path, state : &mut state::State) -> Result { @@ -306,48 +469,9 @@ impl Task { } } -impl Duration { - pub fn zero() -> Self { - Self { - minutes : 0, - hours : 0, - } - } - - pub fn satisfies_invariant(&self) -> bool { - self.minutes < 60 - } -} - -impl TimeEntry { - /// Adds up the times from a collection of time entries. - fn total(entries : &[TimeEntry]) -> Duration { - entries - .iter() - .map(|e| e.duration) - .fold(Duration::zero(), |a, d| a + d) - } - - /// Creates a new TimeEntry, correctly validating and setting defaults. - pub fn new(hours : u16, minutes : u16, date : Option, message : Option) -> Self { - - let (hours, minutes) = { - (hours + minutes / 60, minutes % 60) - }; - - Self { - logged_date : date.unwrap_or(chrono::Utc::now().naive_local().date()), - message, - duration : Duration { - hours, - minutes, - } - } - } -} /// Compares due dates correctly, treating None as at infinity. -fn compare_due_dates(first : &Option, second : &Option) -> cmp::Ordering { +pub fn compare_due_dates(first : &Option, second : &Option) -> cmp::Ordering { match (first, second) { (None, None) => cmp::Ordering::Equal, (Some(_), None) => cmp::Ordering::Less, @@ -356,329 +480,3 @@ fn compare_due_dates(first : &Option, second : &Option) -> cmp::O } } - -impl args::ListOptions { - /// Combines list options coming from a profile and from the additional arguments given. Order - /// of the arguments provided matters, hence the argument names (because optional arguments - /// from the profile are overwritten by the additional arguments). - pub fn combine(profile : &Self, additional : &Self) -> Self { - /// Joins two vectors together one after the other, creating a new allocation. - fn concat(a : &Vec, b : &Vec) -> Vec { - let mut a = a.clone(); - a.extend(b.iter().cloned()); - a - } - - /// Takes two options, and prioritises the second if it is provided in the output, using - /// the first as a fallback, and returning None if both are None. - fn join_options(a : &Option, b : &Option) -> Option { - match (a, b) { - (Some(_), Some(b)) => Some(b.clone()), - (Some(a), None) => Some(a.clone()), - (None, Some(b)) => Some(b.clone()), - (None, None) => None, - } - } - - Self { - column : concat(&profile.column, &additional.column), - order_by : join_options(&profile.order_by, &additional.order_by), - order : join_options(&profile.order, &profile.order), - tag : concat(&profile.tag, &additional.tag), - exclude_tag : concat(&profile.exclude_tag, &additional.exclude_tag), - priority : concat(&profile.priority, &additional.priority), - due_before : join_options(&profile.due_before, &additional.due_before), - due_after : join_options(&profile.due_after, &additional.due_after), - created_before : join_options(&profile.created_before, &additional.created_before), - created_after : join_options(&profile.created_after, &additional.created_after), - include_completed : profile.include_completed || additional.include_completed, - no_dependencies : profile.no_dependencies || additional.no_dependencies, - no_dependents : profile.no_dependents || additional.no_dependents, - } - } -} - -/// Lists all tasks in the specified vault. -pub fn list(mut options : args::ListOptions, vault_folder : &path::Path, state : &state::State) -> Result<(), error::Error> { - - let mut table = comfy_table::Table::new(); - table - .load_preset(comfy_table::presets::UTF8_FULL) - .apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS) - .set_content_arrangement(comfy_table::ContentArrangement::Dynamic); - - - let mut tasks : Box> = Box::new(Task::load_all(vault_folder, true)?.into_iter()); - - // Filter the tasks. - if let Some(date) = options.created_before { - tasks = Box::new(tasks.filter(move |t| t.data.created.date() <= date)); - } - if let Some(date) = options.created_after { - tasks = Box::new(tasks.filter(move |t| t.data.created.date() >= date)); - } - - if let Some(date) = options.due_before { - tasks = Box::new(tasks.filter(move |t| { - match compare_due_dates(&t.data.due.map(|d| d.date()), &Some(date)) { - cmp::Ordering::Less | cmp::Ordering::Equal => true, - cmp::Ordering::Greater => false, - } - })); - } - if let Some(date) = options.due_after { - tasks = Box::new(tasks.filter(move |t| { - match compare_due_dates(&t.data.due.map(|d| d.date()), &Some(date)) { - cmp::Ordering::Greater | cmp::Ordering::Equal => true, - cmp::Ordering::Less => false, - } - })); - } - - if !options.include_completed { - tasks = Box::new(tasks.filter(|t| t.data.completed.is_none())); - } - - if !options.tag.is_empty() { - let specified_tags : HashSet<_> = options.tag.iter().collect(); - - tasks = Box::new(tasks.filter(move |t| { - let task_tags : HashSet<_> = t.data.tags.iter().collect(); - - // Non empty intersection of tags means the task should be displayed - specified_tags.intersection(&task_tags).next().is_some() - })); - } - - if !options.exclude_tag.is_empty() { - let specified_tags : HashSet<_> = options.exclude_tag.iter().collect(); - - tasks = Box::new(tasks.filter(move |t| { - let task_tags : HashSet<_> = t.data.tags.iter().collect(); - - // If the task contains a tag which was supposed to be excluded, it should be filtered - // out - !specified_tags.intersection(&task_tags).next().is_some() - })); - } - - if !options.priority.is_empty() { - let specified_priority_levels : HashSet<_> = options.priority.iter().collect(); - - tasks = Box::new(tasks.filter(move |t| { - specified_priority_levels.contains(&t.data.priority) - })); - } - - if options.no_dependencies { - tasks = Box::new(tasks.filter(move |t| { - t.data.dependencies.is_empty() - })); - } - - if options.no_dependents { - let tasks_with_dependents = state.data.deps.get_tasks_with_dependents(); - - tasks = Box::new(tasks.filter(move |t| { - !tasks_with_dependents.contains(&t.data.id) - })); - } - - let mut tasks : Vec = tasks.collect(); - - - // Sort the tasks. - use super::{OrderBy, Order}; - match options.order_by.unwrap_or_default() { - OrderBy::Id => { - match options.order.unwrap_or_default() { - Order::Asc => { - tasks.sort_by(|t1, t2| t1.data.id.cmp(&t2.data.id)); - }, - Order::Desc => { - tasks.sort_by(|t1, t2| t2.data.id.cmp(&t1.data.id)); - }, - } - }, - OrderBy::Name => { - match options.order.unwrap_or_default() { - Order::Asc => { - tasks.sort_by(|t1, t2| t1.data.name.cmp(&t2.data.name)); - }, - Order::Desc => { - tasks.sort_by(|t1, t2| t2.data.name.cmp(&t1.data.name)); - }, - } - }, - OrderBy::Due => { - match options.order.unwrap_or_default() { - Order::Asc => { - tasks.sort_by(|t1, t2| compare_due_dates(&t1.data.due, &t2.data.due)); - }, - Order::Desc => { - tasks.sort_by(|t1, t2| compare_due_dates(&t2.data.due, &t1.data.due)); - }, - } - }, - OrderBy::Priority => { - match options.order.unwrap_or_default() { - Order::Asc => { - tasks.sort_by(|t1, t2| t1.data.priority.cmp(&t2.data.priority)); - }, - Order::Desc => { - tasks.sort_by(|t1, t2| t2.data.priority.cmp(&t1.data.priority)); - }, - } - }, - OrderBy::Created => { - match options.order.unwrap_or_default() { - Order::Asc => { - tasks.sort_by(|t1, t2| t1.data.created.cmp(&t2.data.created)); - }, - Order::Desc => { - tasks.sort_by(|t1, t2| t2.data.created.cmp(&t1.data.created)); - }, - } - }, - OrderBy::Tracked => { - match options.order.unwrap_or_default() { - Order::Asc => { - tasks.sort_by(|t1, t2| TimeEntry::total(&t1.data.time_entries).cmp(&TimeEntry::total(&t2.data.time_entries))); - }, - Order::Desc => { - tasks.sort_by(|t1, t2| TimeEntry::total(&t2.data.time_entries).cmp(&TimeEntry::total(&t1.data.time_entries))); - }, - } - } - } - - // Include the required columns - let mut headers = vec!["Id", "Name"]; - - // Remove duplicate columns. - options.column = { - let mut columns = HashSet::new(); - - options.column.clone() - .into_iter() - .filter(|c| { - if columns.contains(c) { - false - } - else { - columns.insert(c.clone()); - true - } - }) - .collect() - }; - - use super::Column; - for column in &options.column { - match column { - Column::Tracked => { - headers.push("Tracked"); - }, - Column::Due => { - headers.push("Due"); - }, - Column::Tags => { - headers.push("Tags"); - }, - Column::Priority => { - headers.push("Priority"); - }, - Column::Status => { - headers.push("Status"); - }, - Column::Created => { - headers.push("Created"); - }, - } - } - - table.set_header(headers); - - for task in tasks { - - use comfy_table::Cell; - let mut row = vec![Cell::from(task.data.id), Cell::from(task.data.name)]; - - for column in &options.column { - match column { - Column::Tracked => { - let duration = TimeEntry::total(&task.data.time_entries); - row.push( - Cell::from(if duration == Duration::zero() { String::new() } else { duration.to_string() }) - ); - }, - Column::Due => { - row.push(match task.data.due { - Some(due) => format::cell::due_date(&due, task.data.completed.is_none()), - None => Cell::from(String::new()) - }); - }, - Column::Tags => { - row.push(Cell::new(format::hash_set(&task.data.tags)?)); - }, - Column::Priority => { - row.push(format::cell::priority(&task.data.priority)); - }, - Column::Status => { - row.push( - Cell::new(if task.data.completed.is_some() { - String::from("complete") - } - else { - String::from("incomplete") - }) - ); - }, - Column::Created => { - row.push(Cell::new(task.data.created.round_subsecs(0).to_string())); - }, - } - } - - table.add_row(row); - } - - println!("{}", table); - - Ok(()) -} - -impl ops::Add for Duration { - type Output = Self; - - fn add(self, other : Self) -> Self::Output { - - Self { - hours : self.hours + other.hours + (self.minutes + other.minutes) / 60, - minutes : (self.minutes + other.minutes) % 60, - } - } -} - -impl ops::Div for Duration { - type Output = Self; - - fn div(self, divisor : usize) -> Self::Output { - let total_mins = f64::from(self.hours * 60 + self.minutes); - let divided_mins = total_mins / (divisor as f64); - let divided_mins = divided_mins.round() as u16; - - Self { - hours : divided_mins / 60, - minutes : divided_mins % 60, - } - } -} - -impl fmt::Display for Duration { - fn fmt(&self, f : &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}:{:0>2}", self.hours, self.minutes) - } -} - -