Commit Diff


commit - 82d62f1006b9df033509bb03d9cf3b550fe2d7bc
commit + 4846caa86f50134b6790df48298060c52944a501
blob - 7ef625469dadf66107a9d7721f134eac58a93593
blob + a6008ec414cdc9cce23379b272b25193e1ae49ed
--- Cargo.toml
+++ Cargo.toml
@@ -19,4 +19,6 @@ publish = false # set to true when publishing
 [dependencies]
 chrono = "0.4.23"
 nom = "7.1.2"
+once_cell = "1.17.0"
+regex = "1.7.0"
 thiserror = "1.0.38"
blob - cc4598b14dc65e776e011b82629b41812d796dd5
blob + 69b270b3a5c49ee1c5de1a076b3dc9dfc66b62b8
--- src/types.rs
+++ src/types.rs
@@ -206,14 +206,14 @@ impl TryFrom<(bool, Option<NaiveDate>)> for Completion
 #[derive(Debug, PartialEq, Eq)]
 pub struct Task<'a> {
     /// Whether the [`Task`] has been completed or not.
-    pub completion: Completion,
+    completion: Completion,
     /// What priority the [`Task`] is associated with.
     ///
     /// A priority of 0 indicates no priority. Possible priority values are in range `1..=26`,
     /// corresponding to letters in range `A..=Z`.
-    pub priority: u8,
+    priority: u8,
     /// Date of creation.
-    pub creation_date: Option<NaiveDate>,
+    creation_date: Option<NaiveDate>,
     /// Description of the [`Task`].
     description: Cow<'a, str>,
 }
@@ -221,15 +221,19 @@ pub struct Task<'a> {
 impl<'a> Task<'a> {
     /// Create a new task.
     ///
-    /// The tags of the task are parsed from the description. As the tags and the description are
-    /// closely tied together, one changing if the other does, mutable access is only granted to
-    /// the description, which, when changed, changes the tags, too.
+    /// The tags of the task are parsed on demand from the description.
     ///
     /// ```rust
     /// # use todotxt_parser::{Completion, Tag, Task, TodoError};
-    /// # use chrono::NaiveDate;
-    /// let raw_task = "x (A) 2016-05-20 2016-04-30 measure space for +chapelShelving @chapel due:2016-05-30";
-    /// let task = Task::try_from(raw_task)?;
+    /// use chrono::NaiveDate;
+    ///
+    /// let task = Task::new(
+    ///     Completion::Complete(Some(NaiveDate::from_ymd_opt(2016, 5, 20).unwrap())),
+    ///     1,
+    ///     Some(NaiveDate::from_ymd_opt(2016, 4, 30).unwrap()),
+    ///     "measure space for +chapelShelving @chapel due:2016-05-30"
+    /// );
+    ///
     /// assert_eq!(task.tags(), vec![
     ///     Tag::Project("chapelShelving"),
     ///     Tag::Context("chapel"),
@@ -252,35 +256,75 @@ impl<'a> Task<'a> {
         }
     }
 
+    /// Return the completion status.
     #[must_use]
-    pub const fn has_changed(&self) -> bool {
-        matches!(self.description, Cow::Owned(_))
-    }
-
-    #[must_use]
     pub const fn completion(&self) -> &Completion {
         &self.completion
     }
 
+    /// Set the completion status, returning a new [`Task`].
     #[must_use]
+    pub fn set_completion(&self, completion: Completion) -> Task<'a> {
+        Task {
+            completion,
+            priority: self.priority,
+            creation_date: self.creation_date,
+            description: self.description.clone(),
+        }
+    }
+
+    /// Return the priority.
+    #[must_use]
     pub const fn priority(&self) -> u8 {
         self.priority
     }
 
+    /// Set the priority, returning a new [`Task`].
     #[must_use]
+    pub fn set_priority(&self, priority: u8) -> Task<'a> {
+        Task {
+            completion: self.completion,
+            priority,
+            creation_date: self.creation_date,
+            description: self.description.clone(),
+        }
+    }
+
+    /// Return the creation date.
+    #[must_use]
     pub const fn creation_date(&self) -> Option<NaiveDate> {
         self.creation_date
     }
 
+    /// Set the creation date, returning a new [`Task`].
     #[must_use]
+    pub fn set_creation_date(&self, creation_date: Option<NaiveDate>) -> Task<'a> {
+        Task {
+            completion: self.completion,
+            priority: self.priority,
+            creation_date,
+            description: self.description.clone(),
+        }
+    }
+
+    /// Return the description.
+    #[must_use]
     pub fn description(&'a self) -> &'a str {
         self.description.as_ref()
     }
 
-    pub fn set_description(&mut self, description: String) {
-        self.description = Cow::from(description);
+    /// Set the description, returning a new [`Task`].
+    #[must_use]
+    pub fn set_description(&self, description: String) -> Task<'a> {
+        Task {
+            completion: self.completion,
+            priority: self.priority,
+            creation_date: self.creation_date,
+            description: Cow::from(description),
+        }
     }
 
+    /// Return the set tags.
     #[must_use]
     pub fn tags(&self) -> Vec<Tag> {
         parser::description_tags(self.description.as_ref())
@@ -288,6 +332,30 @@ impl<'a> Task<'a> {
             .unwrap_or_default()
     }
 
+    /// Add a new tag to the [`Task`], creating a new one.
+    ///
+    /// The new tag will be added at the end of the description, prefixed with a space if the
+    /// description is not empty and doesn't end with a space.
+    #[must_use]
+    pub fn add_tag(&self, tag: &Tag) -> Task<'a> {
+        let mut description = self.description.clone();
+
+        let new_tag = if !description.is_empty() && !description.ends_with(' ') {
+            String::from(" ")
+        } else {
+            String::new()
+        } + &tag.to_string();
+
+        description.to_mut().push_str(&new_tag);
+
+        Task {
+            completion: self.completion,
+            priority: self.priority,
+            creation_date: self.creation_date,
+            description,
+        }
+    }
+
     /// Return the projects found in the description of the task.
     #[must_use]
     pub fn projects(&'a self) -> Vec<&'a str> {
@@ -326,6 +394,33 @@ impl<'a> Task<'a> {
             .find_map(|t| if let Tag::Due(d) = t { Some(d) } else { None })
     }
 
+    /// Set the due date of the [`Task`], creating a new one.
+    ///
+    /// If the [`Task`] does not yet have a due date, it is being set by appending the `due` tag at
+    /// the end of the description.
+    /// If the date is already set, it modifies it in place, setting the new date.
+    #[must_use]
+    pub fn set_due(&'a self, due: NaiveDate) -> Task<'a> {
+        let re = crate::regex!(r"(due:)\d{4}-\d{2}-\d{2}");
+
+        if re.is_match(self.description.as_ref()) {
+            let description = re.replace(
+                self.description.as_ref(),
+                format!("${{1}}{}", due.format("%Y-%m-%d")),
+            );
+            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::Due(due))
+        }
+    }
+
     /// Return the recurring rule of the task, if there is one.
     #[must_use]
     pub fn rec(&self) -> Option<Recurring> {
@@ -401,8 +496,16 @@ impl<'a> TryFrom<&'a str> for Task<'a> {
     }
 }
 
+#[macro_export]
+macro_rules! regex {
+    ($re:literal $(,)?) => {{
+        static RE: once_cell::sync::OnceCell<regex::Regex> = once_cell::sync::OnceCell::new();
+        RE.get_or_init(|| regex::Regex::new($re).unwrap())
+    }};
+}
+
 #[cfg(test)]
-mod test {
+mod tests {
     use super::*;
 
     #[test]
@@ -450,4 +553,46 @@ mod test {
 
         assert_eq!(task.rec(), Some(rec));
     }
+
+    #[test]
+    fn test_add_tag() {
+        let task_without_space = Task::try_from("task without space at the end").unwrap();
+        let new_tag_task = task_without_space.add_tag(&Tag::Project("project"));
+        assert_eq!(
+            new_tag_task.description(),
+            "task without space at the end +project"
+        );
+        assert_eq!(new_tag_task.tags(), vec![Tag::Project("project")],);
+
+        let task_with_space = Task::try_from("task with space at the end ").unwrap();
+        let new_space_tag_task = task_with_space.add_tag(&Tag::Project("project"));
+        assert_eq!(
+            new_space_tag_task.description(),
+            "task with space at the end +project"
+        );
+        assert_eq!(new_space_tag_task.tags(), vec![Tag::Project("project")]);
+    }
+
+    #[test]
+    fn test_set_due() {
+        let task = Task::try_from("test with due date due:2023-01-08").unwrap();
+        let due = NaiveDate::from_ymd_opt(2023, 1, 9).unwrap();
+        let new_task = task.set_due(due.clone());
+        assert_eq!(
+            task.tags(),
+            vec![Tag::Due(NaiveDate::from_ymd_opt(2023, 1, 8).unwrap())]
+        );
+        assert_eq!(
+            new_task.tags(),
+            vec![Tag::Due(NaiveDate::from_ymd_opt(2023, 1, 9).unwrap())]
+        );
+
+        let no_due_task = Task::try_from("test without due date").unwrap();
+        let new_due_task = no_due_task.set_due(due);
+        assert_eq!(no_due_task.tags(), vec![]);
+        assert_eq!(
+            new_due_task.tags(),
+            vec![Tag::Due(NaiveDate::from_ymd_opt(2023, 1, 9).unwrap())]
+        );
+    }
 }