Skip to main content

doing_taskpaper/
entries.rs

1use std::fmt::{Display, Formatter, Result as FmtResult};
2
3use chrono::{DateTime, Duration, Local, NaiveDateTime, TimeZone};
4
5use crate::{Note, Tags};
6
7/// A single time-tracked entry in a TaskPaper doing file.
8///
9/// Each entry has a start date, a tag-free title, tags, an optional note,
10/// the section it belongs to, and a unique 32-character hex ID.
11#[derive(Clone, Debug)]
12pub struct Entry {
13  date: DateTime<Local>,
14  id: String,
15  note: Note,
16  section: String,
17  tags: Tags,
18  title: String,
19}
20
21impl Entry {
22  /// Create a new entry with the given fields.
23  ///
24  /// If `id` is `None`, a deterministic ID is generated from the entry content.
25  pub fn new(
26    date: DateTime<Local>,
27    title: impl Into<String>,
28    tags: Tags,
29    note: Note,
30    section: impl Into<String>,
31    id: Option<impl Into<String>>,
32  ) -> Self {
33    let title = title.into();
34    let section = section.into();
35    let id = match id {
36      Some(id) => id.into(),
37      None => gen_id(&date, &title, &section),
38    };
39    Self {
40      date,
41      id,
42      note,
43      section,
44      tags,
45      title,
46    }
47  }
48
49  /// Return the start date.
50  pub fn date(&self) -> DateTime<Local> {
51    self.date
52  }
53
54  /// Return the parsed `@done` tag timestamp, if present and valid.
55  pub fn done_date(&self) -> Option<DateTime<Local>> {
56    let value = self.tag_value("done")?;
57    parse_tag_date(value)
58  }
59
60  /// Return elapsed time since the start date.
61  ///
62  /// For finished entries this returns `None` — use [`interval`](Self::interval) instead.
63  pub fn duration(&self) -> Option<Duration> {
64    if self.finished() {
65      return None;
66    }
67    Some(Local::now().signed_duration_since(self.date))
68  }
69
70  /// Return the end date: the `@done` tag timestamp if present, otherwise `None`.
71  pub fn end_date(&self) -> Option<DateTime<Local>> {
72    self.done_date()
73  }
74
75  /// Return whether the entry has a `@done` tag.
76  pub fn finished(&self) -> bool {
77    self.tags.has("done")
78  }
79
80  /// Return the title with inline tags, matching the original entry format.
81  pub fn full_title(&self) -> String {
82    if self.tags.is_empty() {
83      self.title.clone()
84    } else {
85      format!("{} {}", self.title, self.tags)
86    }
87  }
88
89  /// Return the 32-character hex ID.
90  pub fn id(&self) -> &str {
91    &self.id
92  }
93
94  /// Return the time between the start date and the `@done` date.
95  ///
96  /// Returns `None` if the entry is not finished or the done date cannot be parsed.
97  pub fn interval(&self) -> Option<Duration> {
98    let done = self.done_date()?;
99    Some(done.signed_duration_since(self.date))
100  }
101
102  /// Return the note.
103  pub fn note(&self) -> &Note {
104    &self.note
105  }
106
107  /// Return a mutable reference to the note.
108  pub fn note_mut(&mut self) -> &mut Note {
109    &mut self.note
110  }
111
112  /// Check whether this entry's time range overlaps with another entry's.
113  ///
114  /// Uses each entry's start date and end date (from `@done` tag). If either
115  /// entry lacks an end date, the current time is used.
116  pub fn overlapping_time(&self, other: &Entry) -> bool {
117    let now = Local::now();
118    let start_a = self.date;
119    let end_a = self.end_date().unwrap_or(now);
120    let start_b = other.date;
121    let end_b = other.end_date().unwrap_or(now);
122    start_a < end_b && start_b < end_a
123  }
124
125  /// Return the section name.
126  pub fn section(&self) -> &str {
127    &self.section
128  }
129
130  /// Set the start date.
131  pub fn set_date(&mut self, date: DateTime<Local>) {
132    self.date = date;
133  }
134
135  /// Check whether the entry should receive a `@done` tag.
136  ///
137  /// Returns `false` if any pattern in `never_finish` matches this entry's
138  /// tags (patterns starting with `@`) or section name.
139  pub fn should_finish(&self, never_finish: &[String]) -> bool {
140    no_patterns_match(never_finish, &self.tags, &self.section)
141  }
142
143  /// Check whether the entry should receive a date on the `@done` tag.
144  ///
145  /// Returns `false` if any pattern in `never_time` matches this entry's
146  /// tags (patterns starting with `@`) or section name.
147  pub fn should_time(&self, never_time: &[String]) -> bool {
148    no_patterns_match(never_time, &self.tags, &self.section)
149  }
150
151  /// Return the tags.
152  pub fn tags(&self) -> &Tags {
153    &self.tags
154  }
155
156  /// Return a mutable reference to the tags.
157  pub fn tags_mut(&mut self) -> &mut Tags {
158    &mut self.tags
159  }
160
161  /// Return the tag-free title.
162  pub fn title(&self) -> &str {
163    &self.title
164  }
165
166  /// Return whether the entry does not have a `@done` tag.
167  pub fn unfinished(&self) -> bool {
168    !self.finished()
169  }
170
171  /// Return the value of a tag by name, if present.
172  fn tag_value(&self, name: &str) -> Option<&str> {
173    self
174      .tags
175      .iter()
176      .find(|t| t.name().eq_ignore_ascii_case(name))
177      .and_then(|t| t.value())
178  }
179}
180
181impl Display for Entry {
182  /// Format as a full title line: `title @tag1 @tag2(val) <id>`
183  fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
184    write!(f, "{}", self.title)?;
185    if !self.tags.is_empty() {
186      write!(f, " {}", self.tags)?;
187    }
188    write!(f, " <{}>", self.id)
189  }
190}
191
192/// Generate a deterministic 32-character lowercase hex ID from entry content.
193fn gen_id(date: &DateTime<Local>, title: &str, section: &str) -> String {
194  let content = format!("{}{}{}", date.format("%Y-%m-%d %H:%M"), title, section);
195  format!("{:x}", md5::compute(content.as_bytes()))
196}
197
198/// Parse a date string from a tag value in `YYYY-MM-DD HH:MM` format.
199fn parse_tag_date(value: &str) -> Option<DateTime<Local>> {
200  let naive = NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M").ok()?;
201  Local.from_local_datetime(&naive).single()
202}
203
204/// Check whether an entry should receive a particular treatment based on config patterns.
205///
206/// Each pattern is either `@tagname` (matches if the entry has that tag) or a
207/// section name (matches if the entry belongs to that section). If any pattern
208/// matches, returns `false`.
209fn no_patterns_match(patterns: &[String], tags: &Tags, section: &str) -> bool {
210  for pattern in patterns {
211    if let Some(tag_name) = pattern.strip_prefix('@') {
212      if tags.has(tag_name) {
213        return false;
214      }
215    } else if section.eq_ignore_ascii_case(pattern) {
216      return false;
217    }
218  }
219  true
220}
221
222#[cfg(test)]
223mod test {
224  use chrono::TimeZone;
225
226  use super::*;
227  use crate::Tag;
228
229  fn sample_date() -> DateTime<Local> {
230    Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap()
231  }
232
233  fn sample_entry() -> Entry {
234    Entry::new(
235      sample_date(),
236      "Working on project",
237      Tags::from_iter(vec![
238        Tag::new("coding", None::<String>),
239        Tag::new("done", Some("2024-03-17 15:00")),
240      ]),
241      Note::from_str("Some notes here"),
242      "Currently",
243      None::<String>,
244    )
245  }
246
247  mod display {
248    use pretty_assertions::assert_eq;
249
250    use super::*;
251
252    #[test]
253    fn it_formats_title_with_tags_and_id() {
254      let entry = sample_entry();
255
256      let result = entry.to_string();
257
258      assert!(result.starts_with("Working on project @coding @done(2024-03-17 15:00) <"));
259      assert!(result.ends_with(">"));
260      assert_eq!(
261        result.len(),
262        "Working on project @coding @done(2024-03-17 15:00) <".len() + 32 + ">".len()
263      );
264    }
265
266    #[test]
267    fn it_formats_title_without_tags() {
268      let entry = Entry::new(
269        sample_date(),
270        "Just a title",
271        Tags::new(),
272        Note::new(),
273        "Currently",
274        None::<String>,
275      );
276
277      let result = entry.to_string();
278
279      assert!(result.starts_with("Just a title <"));
280      assert!(result.ends_with(">"));
281      assert_eq!(result.len(), "Just a title <".len() + 32 + ">".len());
282    }
283  }
284
285  mod done_date {
286    use pretty_assertions::assert_eq;
287
288    use super::*;
289
290    #[test]
291    fn it_returns_parsed_done_date() {
292      let entry = sample_entry();
293
294      let done = entry.done_date().unwrap();
295
296      assert_eq!(done, Local.with_ymd_and_hms(2024, 3, 17, 15, 0, 0).unwrap());
297    }
298
299    #[test]
300    fn it_returns_none_when_no_done_tag() {
301      let entry = Entry::new(
302        sample_date(),
303        "test",
304        Tags::new(),
305        Note::new(),
306        "Currently",
307        None::<String>,
308      );
309
310      assert!(entry.done_date().is_none());
311    }
312
313    #[test]
314    fn it_returns_none_when_done_tag_has_no_value() {
315      let entry = Entry::new(
316        sample_date(),
317        "test",
318        Tags::from_iter(vec![Tag::new("done", None::<String>)]),
319        Note::new(),
320        "Currently",
321        None::<String>,
322      );
323
324      assert!(entry.done_date().is_none());
325    }
326  }
327
328  mod duration {
329    use super::*;
330
331    #[test]
332    fn it_returns_none_for_finished_entry() {
333      let entry = sample_entry();
334
335      assert!(entry.duration().is_none());
336    }
337
338    #[test]
339    fn it_returns_some_for_unfinished_entry() {
340      let entry = Entry::new(
341        Local::now() - Duration::hours(2),
342        "test",
343        Tags::new(),
344        Note::new(),
345        "Currently",
346        None::<String>,
347      );
348
349      let dur = entry.duration().unwrap();
350
351      assert!(dur.num_minutes() >= 119);
352    }
353  }
354
355  mod finished {
356    use super::*;
357
358    #[test]
359    fn it_returns_true_when_done_tag_present() {
360      let entry = sample_entry();
361
362      assert!(entry.finished());
363    }
364
365    #[test]
366    fn it_returns_false_when_no_done_tag() {
367      let entry = Entry::new(
368        sample_date(),
369        "test",
370        Tags::from_iter(vec![Tag::new("coding", None::<String>)]),
371        Note::new(),
372        "Currently",
373        None::<String>,
374      );
375
376      assert!(!entry.finished());
377    }
378  }
379
380  mod full_title {
381    use pretty_assertions::assert_eq;
382
383    use super::*;
384
385    #[test]
386    fn it_includes_tags_in_title() {
387      let entry = sample_entry();
388
389      assert_eq!(entry.full_title(), "Working on project @coding @done(2024-03-17 15:00)");
390    }
391
392    #[test]
393    fn it_returns_plain_title_when_no_tags() {
394      let entry = Entry::new(
395        sample_date(),
396        "Just a title",
397        Tags::new(),
398        Note::new(),
399        "Currently",
400        None::<String>,
401      );
402
403      assert_eq!(entry.full_title(), "Just a title");
404    }
405  }
406
407  mod gen_id {
408    use pretty_assertions::assert_eq;
409
410    use super::*;
411
412    #[test]
413    fn it_generates_32_char_hex_string() {
414      let id = super::super::gen_id(&sample_date(), "test", "Currently");
415
416      assert_eq!(id.len(), 32);
417      assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
418    }
419
420    #[test]
421    fn it_is_deterministic() {
422      let id1 = super::super::gen_id(&sample_date(), "test", "Currently");
423      let id2 = super::super::gen_id(&sample_date(), "test", "Currently");
424
425      assert_eq!(id1, id2);
426    }
427
428    #[test]
429    fn it_differs_for_different_content() {
430      let id1 = super::super::gen_id(&sample_date(), "task one", "Currently");
431      let id2 = super::super::gen_id(&sample_date(), "task two", "Currently");
432
433      assert_ne!(id1, id2);
434    }
435  }
436
437  mod interval {
438    use pretty_assertions::assert_eq;
439
440    use super::*;
441
442    #[test]
443    fn it_returns_duration_between_start_and_done() {
444      let entry = sample_entry();
445
446      let iv = entry.interval().unwrap();
447
448      assert_eq!(iv.num_minutes(), 30);
449    }
450
451    #[test]
452    fn it_returns_none_when_not_finished() {
453      let entry = Entry::new(
454        sample_date(),
455        "test",
456        Tags::new(),
457        Note::new(),
458        "Currently",
459        None::<String>,
460      );
461
462      assert!(entry.interval().is_none());
463    }
464  }
465
466  mod new {
467    use pretty_assertions::assert_eq;
468
469    use super::*;
470
471    #[test]
472    fn it_generates_id_when_none_provided() {
473      let entry = Entry::new(
474        sample_date(),
475        "test",
476        Tags::new(),
477        Note::new(),
478        "Currently",
479        None::<String>,
480      );
481
482      assert_eq!(entry.id().len(), 32);
483      assert!(entry.id().chars().all(|c| c.is_ascii_hexdigit()));
484    }
485
486    #[test]
487    fn it_uses_provided_id() {
488      let entry = Entry::new(
489        sample_date(),
490        "test",
491        Tags::new(),
492        Note::new(),
493        "Currently",
494        Some("abcdef01234567890abcdef012345678"),
495      );
496
497      assert_eq!(entry.id(), "abcdef01234567890abcdef012345678");
498    }
499  }
500
501  mod overlapping_time {
502    use super::*;
503
504    #[test]
505    fn it_detects_overlapping_entries() {
506      let a = Entry::new(
507        Local.with_ymd_and_hms(2024, 3, 17, 14, 0, 0).unwrap(),
508        "task a",
509        Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:00"))]),
510        Note::new(),
511        "Currently",
512        None::<String>,
513      );
514      let b = Entry::new(
515        Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap(),
516        "task b",
517        Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:30"))]),
518        Note::new(),
519        "Currently",
520        None::<String>,
521      );
522
523      assert!(a.overlapping_time(&b));
524      assert!(b.overlapping_time(&a));
525    }
526
527    #[test]
528    fn it_returns_false_for_non_overlapping_entries() {
529      let a = Entry::new(
530        Local.with_ymd_and_hms(2024, 3, 17, 14, 0, 0).unwrap(),
531        "task a",
532        Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:00"))]),
533        Note::new(),
534        "Currently",
535        None::<String>,
536      );
537      let b = Entry::new(
538        Local.with_ymd_and_hms(2024, 3, 17, 15, 0, 0).unwrap(),
539        "task b",
540        Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 16:00"))]),
541        Note::new(),
542        "Currently",
543        None::<String>,
544      );
545
546      assert!(!a.overlapping_time(&b));
547    }
548  }
549
550  mod should_finish {
551    use super::*;
552
553    #[test]
554    fn it_returns_true_when_no_patterns_match() {
555      let entry = sample_entry();
556
557      assert!(entry.should_finish(&[]));
558    }
559
560    #[test]
561    fn it_returns_false_when_tag_pattern_matches() {
562      let entry = sample_entry();
563
564      assert!(!entry.should_finish(&["@coding".to_string()]));
565    }
566
567    #[test]
568    fn it_returns_false_when_section_pattern_matches() {
569      let entry = sample_entry();
570
571      assert!(!entry.should_finish(&["Currently".to_string()]));
572    }
573
574    #[test]
575    fn it_matches_section_case_insensitively() {
576      let entry = sample_entry();
577
578      assert!(!entry.should_finish(&["currently".to_string()]));
579    }
580  }
581
582  mod should_time {
583    use super::*;
584
585    #[test]
586    fn it_returns_true_when_no_patterns_match() {
587      let entry = sample_entry();
588
589      assert!(entry.should_time(&[]));
590    }
591
592    #[test]
593    fn it_returns_false_when_tag_pattern_matches() {
594      let entry = sample_entry();
595
596      assert!(!entry.should_time(&["@coding".to_string()]));
597    }
598  }
599
600  mod unfinished {
601    use super::*;
602
603    #[test]
604    fn it_returns_true_when_no_done_tag() {
605      let entry = Entry::new(
606        sample_date(),
607        "test",
608        Tags::new(),
609        Note::new(),
610        "Currently",
611        None::<String>,
612      );
613
614      assert!(entry.unfinished());
615    }
616
617    #[test]
618    fn it_returns_false_when_done_tag_present() {
619      let entry = sample_entry();
620
621      assert!(!entry.unfinished());
622    }
623  }
624}