commit 157d7b0f7b1b8381c16b9761abcec10f6293ee6b from: witcher date: Sun Jan 8 12:43:45 2023 UTC Add methods relating to recurring tasks Basic recurring task functionality has been implemented in this patch, but the strict recurring rule is still missing and needs to be implemented. This is quite hard to do while keeping the methods testable as the strict rule relies on the current date, which currently is not being taken via a parameter. References: https://todo.sr.ht/~witcher/todotxt/4 commit - 2b4c6992a668ec0a40bb37a7ef041bd46f8f2b11 commit + 157d7b0f7b1b8381c16b9761abcec10f6293ee6b blob - d2e82dfcca37040e4bc3c22a04ff1468c659b780 blob + e002717c0474fd7c83aa1dfd3f960e2057da5db4 --- src/types.rs +++ src/types.rs @@ -6,7 +6,10 @@ use crate::err::TodoError; use crate::parser; -use chrono::naive::NaiveDate; +use chrono::{ + naive::{Days, NaiveDate}, + Months, +}; use nom::Finish; use std::{borrow::Cow, num::NonZeroU8}; @@ -421,13 +424,65 @@ impl<'a> Task<'a> { } } - /// Return the recurring rule of the task, if there is one. + /// Return the recurring rule of the [`Task`], if there is one. #[must_use] pub fn rec(&self) -> Option { self.tags() .into_iter() .find_map(|t| if let Tag::Rec(r) = t { Some(r) } else { None }) } + + /// Set the [`Recurring`] rule of the [`Task`], creating a new one. + /// + /// If the [`Task`] does not yet have a [`Recurring`] rule, it is being set by appending the + /// `rec` tag at the end of the description. + /// If the [`Recurring`] rule is already set, it modifies it in place, setting the new rule. + #[must_use] + pub fn set_rec(&'a self, rec: Recurring) -> Task<'a> { + let re = crate::regex!(r"(rec:)+?\d+[dwmy]"); + + if re.is_match(self.description.as_ref()) { + let description = re.replace(self.description.as_ref(), format!("${{1}}{rec}")); + Task { + completion: self.completion, + priority: self.priority, + creation_date: self.creation_date, + // `replace` must have found a match because the call to `is_match` returned true, + // so calling `clone` is fine + description: description.clone(), + } + } else { + self.add_tag(&Tag::Rec(rec)) + } + } + + /// Advance the due date of the [`Task`] by the value of the [`Recurring`] [`Tag`]. + /// + /// The due date is only advanced if there is one. + #[must_use] + pub fn next_due_with_rec(&self, rec: &Recurring) -> Option { + // TODO: set new creation date, too + // TODO: handle strict recurring + // non-strict: next due date is x amount after due date + // strict: next due date is x amount after *current* date + let prev_due = self.due()?; + let due = match rec.amount.unit { + TimeUnit::Day => prev_due + Days::new(rec.amount.amount.try_into().ok()?), + TimeUnit::Week => prev_due + Days::new((rec.amount.amount * 7).try_into().ok()?), + TimeUnit::Month => prev_due + Months::new(rec.amount.amount.try_into().ok()?), + TimeUnit::Year => prev_due + Months::new((rec.amount.amount * 12).try_into().ok()?), + }; + + Some(self.set_due(due)) + } + + /// Advance the due date of the [`Task`] by the value of the [`Recurring`] [`Tag`]. + /// + /// The due date is only advanced if both a due date *and* a [`Recurring`] [`Tag`] are present. + #[must_use] + pub fn next_due(&self) -> Option { + self.next_due_with_rec(&self.rec()?) + } } impl<'a> @@ -595,4 +650,43 @@ mod tests { vec![Tag::Due(NaiveDate::from_ymd_opt(2023, 1, 9).unwrap())] ); } + + #[test] + fn test_next_due_with_rec() { + let task_without_due = Task::try_from("2023-01-08 boring task").unwrap(); + let rec = Recurring { + strict: false, + amount: TimeAmount { + amount: 4, + unit: TimeUnit::Day, + }, + }; + assert_eq!(task_without_due.next_due_with_rec(&rec), None); + + let task_with_due = Task::try_from("2023-01-08 due task due:2023-01-09").unwrap(); + let new_task = task_with_due.next_due_with_rec(&rec).unwrap(); + + assert_eq!( + new_task.due().unwrap(), + NaiveDate::from_ymd_opt(2023, 01, 13).unwrap() + ); + } + + #[test] + fn test_next_due() { + let task_without_due_without_rec = Task::try_from("2023-01-08 boring task").unwrap(); + let task_without_due_with_rec = Task::try_from("2023-01-08 boring task rec:+4d").unwrap(); + let task_with_due_without_rec = + Task::try_from("2023-01-08 boring task due:2023-01-09").unwrap(); + let task_with_due_with_rec = + Task::try_from("2023-01-08 boring task due:2023-01-09 rec:+4d").unwrap(); + + assert_eq!(task_without_due_without_rec.next_due(), None); + assert_eq!(task_without_due_with_rec.next_due(), None); + assert_eq!(task_with_due_without_rec.next_due(), None); + assert_eq!( + task_with_due_with_rec.next_due().unwrap(), + Task::try_from("2023-01-08 boring task due:2023-01-13 rec:+4d").unwrap() + ); + } }