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  /// Set the title.
136  pub fn set_title(&mut self, title: impl Into<String>) {
137    self.title = title.into();
138  }
139
140  /// Check whether the entry should receive a `@done` tag.
141  ///
142  /// Returns `false` if any pattern in `never_finish` matches this entry's
143  /// tags (patterns starting with `@`) or section name.
144  pub fn should_finish(&self, never_finish: &[String]) -> bool {
145    no_patterns_match(never_finish, &self.tags, &self.section)
146  }
147
148  /// Check whether the entry should receive a date on the `@done` tag.
149  ///
150  /// Returns `false` if any pattern in `never_time` matches this entry's
151  /// tags (patterns starting with `@`) or section name.
152  pub fn should_time(&self, never_time: &[String]) -> bool {
153    no_patterns_match(never_time, &self.tags, &self.section)
154  }
155
156  /// Return the tags.
157  pub fn tags(&self) -> &Tags {
158    &self.tags
159  }
160
161  /// Return a mutable reference to the tags.
162  pub fn tags_mut(&mut self) -> &mut Tags {
163    &mut self.tags
164  }
165
166  /// Return the tag-free title.
167  pub fn title(&self) -> &str {
168    &self.title
169  }
170
171  /// Return whether the entry does not have a `@done` tag.
172  pub fn unfinished(&self) -> bool {
173    !self.finished()
174  }
175
176  /// Return the value of a tag by name, if present.
177  fn tag_value(&self, name: &str) -> Option<&str> {
178    self
179      .tags
180      .iter()
181      .find(|t| t.name().eq_ignore_ascii_case(name))
182      .and_then(|t| t.value())
183  }
184}
185
186impl Display for Entry {
187  /// Format as a full title line: `title @tag1 @tag2(val) <id>`
188  fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
189    write!(f, "{}", self.title)?;
190    if !self.tags.is_empty() {
191      write!(f, " {}", self.tags)?;
192    }
193    write!(f, " <{}>", self.id)
194  }
195}
196
197/// Generate a deterministic 32-character lowercase hex ID from entry content.
198fn gen_id(date: &DateTime<Local>, title: &str, section: &str) -> String {
199  let content = format!("{}{}{}", date.format("%Y-%m-%d %H:%M"), title, section);
200  format!("{:x}", md5::compute(content.as_bytes()))
201}
202
203/// Check whether an entry should receive a particular treatment based on config patterns.
204///
205/// Each pattern is either `@tagname` (matches if the entry has that tag) or a
206/// section name (matches if the entry belongs to that section). If any pattern
207/// matches, returns `false`.
208fn no_patterns_match(patterns: &[String], tags: &Tags, section: &str) -> bool {
209  for pattern in patterns {
210    if let Some(tag_name) = pattern.strip_prefix('@') {
211      if tags.has(tag_name) {
212        return false;
213      }
214    } else if section.eq_ignore_ascii_case(pattern) {
215      return false;
216    }
217  }
218  true
219}
220
221/// Parse a date string from a tag value in `YYYY-MM-DD HH:MM` format.
222fn parse_tag_date(value: &str) -> Option<DateTime<Local>> {
223  let naive = NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M").ok()?;
224  Local.from_local_datetime(&naive).single()
225}
226
227#[cfg(test)]
228mod test {
229  use chrono::TimeZone;
230
231  use super::*;
232  use crate::Tag;
233
234  fn sample_date() -> DateTime<Local> {
235    Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap()
236  }
237
238  fn sample_entry() -> Entry {
239    Entry::new(
240      sample_date(),
241      "Working on project",
242      Tags::from_iter(vec![
243        Tag::new("coding", None::<String>),
244        Tag::new("done", Some("2024-03-17 15:00")),
245      ]),
246      Note::from_str("Some notes here"),
247      "Currently",
248      None::<String>,
249    )
250  }
251
252  mod display {
253    use pretty_assertions::assert_eq;
254
255    use super::*;
256
257    #[test]
258    fn it_formats_title_with_tags_and_id() {
259      let entry = sample_entry();
260
261      let result = entry.to_string();
262
263      assert!(result.starts_with("Working on project @coding @done(2024-03-17 15:00) <"));
264      assert!(result.ends_with(">"));
265      assert_eq!(
266        result.len(),
267        "Working on project @coding @done(2024-03-17 15:00) <".len() + 32 + ">".len()
268      );
269    }
270
271    #[test]
272    fn it_formats_title_without_tags() {
273      let entry = Entry::new(
274        sample_date(),
275        "Just a title",
276        Tags::new(),
277        Note::new(),
278        "Currently",
279        None::<String>,
280      );
281
282      let result = entry.to_string();
283
284      assert!(result.starts_with("Just a title <"));
285      assert!(result.ends_with(">"));
286      assert_eq!(result.len(), "Just a title <".len() + 32 + ">".len());
287    }
288  }
289
290  mod done_date {
291    use pretty_assertions::assert_eq;
292
293    use super::*;
294
295    #[test]
296    fn it_returns_parsed_done_date() {
297      let entry = sample_entry();
298
299      let done = entry.done_date().unwrap();
300
301      assert_eq!(done, Local.with_ymd_and_hms(2024, 3, 17, 15, 0, 0).unwrap());
302    }
303
304    #[test]
305    fn it_returns_none_when_no_done_tag() {
306      let entry = Entry::new(
307        sample_date(),
308        "test",
309        Tags::new(),
310        Note::new(),
311        "Currently",
312        None::<String>,
313      );
314
315      assert!(entry.done_date().is_none());
316    }
317
318    #[test]
319    fn it_returns_none_when_done_tag_has_no_value() {
320      let entry = Entry::new(
321        sample_date(),
322        "test",
323        Tags::from_iter(vec![Tag::new("done", None::<String>)]),
324        Note::new(),
325        "Currently",
326        None::<String>,
327      );
328
329      assert!(entry.done_date().is_none());
330    }
331  }
332
333  mod duration {
334    use super::*;
335
336    #[test]
337    fn it_returns_none_for_finished_entry() {
338      let entry = sample_entry();
339
340      assert!(entry.duration().is_none());
341    }
342
343    #[test]
344    fn it_returns_some_for_unfinished_entry() {
345      let entry = Entry::new(
346        Local::now() - Duration::hours(2),
347        "test",
348        Tags::new(),
349        Note::new(),
350        "Currently",
351        None::<String>,
352      );
353
354      let dur = entry.duration().unwrap();
355
356      assert!(dur.num_minutes() >= 119);
357    }
358  }
359
360  mod finished {
361    use super::*;
362
363    #[test]
364    fn it_returns_true_when_done_tag_present() {
365      let entry = sample_entry();
366
367      assert!(entry.finished());
368    }
369
370    #[test]
371    fn it_returns_false_when_no_done_tag() {
372      let entry = Entry::new(
373        sample_date(),
374        "test",
375        Tags::from_iter(vec![Tag::new("coding", None::<String>)]),
376        Note::new(),
377        "Currently",
378        None::<String>,
379      );
380
381      assert!(!entry.finished());
382    }
383  }
384
385  mod full_title {
386    use pretty_assertions::assert_eq;
387
388    use super::*;
389
390    #[test]
391    fn it_includes_tags_in_title() {
392      let entry = sample_entry();
393
394      assert_eq!(entry.full_title(), "Working on project @coding @done(2024-03-17 15:00)");
395    }
396
397    #[test]
398    fn it_returns_plain_title_when_no_tags() {
399      let entry = Entry::new(
400        sample_date(),
401        "Just a title",
402        Tags::new(),
403        Note::new(),
404        "Currently",
405        None::<String>,
406      );
407
408      assert_eq!(entry.full_title(), "Just a title");
409    }
410  }
411
412  mod gen_id {
413    use pretty_assertions::assert_eq;
414
415    use super::*;
416
417    #[test]
418    fn it_generates_32_char_hex_string() {
419      let id = super::super::gen_id(&sample_date(), "test", "Currently");
420
421      assert_eq!(id.len(), 32);
422      assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
423    }
424
425    #[test]
426    fn it_is_deterministic() {
427      let id1 = super::super::gen_id(&sample_date(), "test", "Currently");
428      let id2 = super::super::gen_id(&sample_date(), "test", "Currently");
429
430      assert_eq!(id1, id2);
431    }
432
433    #[test]
434    fn it_differs_for_different_content() {
435      let id1 = super::super::gen_id(&sample_date(), "task one", "Currently");
436      let id2 = super::super::gen_id(&sample_date(), "task two", "Currently");
437
438      assert_ne!(id1, id2);
439    }
440  }
441
442  mod interval {
443    use pretty_assertions::assert_eq;
444
445    use super::*;
446
447    #[test]
448    fn it_returns_duration_between_start_and_done() {
449      let entry = sample_entry();
450
451      let iv = entry.interval().unwrap();
452
453      assert_eq!(iv.num_minutes(), 30);
454    }
455
456    #[test]
457    fn it_returns_none_when_not_finished() {
458      let entry = Entry::new(
459        sample_date(),
460        "test",
461        Tags::new(),
462        Note::new(),
463        "Currently",
464        None::<String>,
465      );
466
467      assert!(entry.interval().is_none());
468    }
469  }
470
471  mod new {
472    use pretty_assertions::assert_eq;
473
474    use super::*;
475
476    #[test]
477    fn it_generates_id_when_none_provided() {
478      let entry = Entry::new(
479        sample_date(),
480        "test",
481        Tags::new(),
482        Note::new(),
483        "Currently",
484        None::<String>,
485      );
486
487      assert_eq!(entry.id().len(), 32);
488      assert!(entry.id().chars().all(|c| c.is_ascii_hexdigit()));
489    }
490
491    #[test]
492    fn it_uses_provided_id() {
493      let entry = Entry::new(
494        sample_date(),
495        "test",
496        Tags::new(),
497        Note::new(),
498        "Currently",
499        Some("abcdef01234567890abcdef012345678"),
500      );
501
502      assert_eq!(entry.id(), "abcdef01234567890abcdef012345678");
503    }
504  }
505
506  mod overlapping_time {
507    use super::*;
508
509    #[test]
510    fn it_detects_overlapping_entries() {
511      let a = Entry::new(
512        Local.with_ymd_and_hms(2024, 3, 17, 14, 0, 0).unwrap(),
513        "task a",
514        Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:00"))]),
515        Note::new(),
516        "Currently",
517        None::<String>,
518      );
519      let b = Entry::new(
520        Local.with_ymd_and_hms(2024, 3, 17, 14, 30, 0).unwrap(),
521        "task b",
522        Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:30"))]),
523        Note::new(),
524        "Currently",
525        None::<String>,
526      );
527
528      assert!(a.overlapping_time(&b));
529      assert!(b.overlapping_time(&a));
530    }
531
532    #[test]
533    fn it_returns_false_for_non_overlapping_entries() {
534      let a = Entry::new(
535        Local.with_ymd_and_hms(2024, 3, 17, 14, 0, 0).unwrap(),
536        "task a",
537        Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 15:00"))]),
538        Note::new(),
539        "Currently",
540        None::<String>,
541      );
542      let b = Entry::new(
543        Local.with_ymd_and_hms(2024, 3, 17, 15, 0, 0).unwrap(),
544        "task b",
545        Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 16:00"))]),
546        Note::new(),
547        "Currently",
548        None::<String>,
549      );
550
551      assert!(!a.overlapping_time(&b));
552    }
553  }
554
555  mod should_finish {
556    use super::*;
557
558    #[test]
559    fn it_returns_true_when_no_patterns_match() {
560      let entry = sample_entry();
561
562      assert!(entry.should_finish(&[]));
563    }
564
565    #[test]
566    fn it_returns_false_when_tag_pattern_matches() {
567      let entry = sample_entry();
568
569      assert!(!entry.should_finish(&["@coding".to_string()]));
570    }
571
572    #[test]
573    fn it_returns_false_when_section_pattern_matches() {
574      let entry = sample_entry();
575
576      assert!(!entry.should_finish(&["Currently".to_string()]));
577    }
578
579    #[test]
580    fn it_matches_section_case_insensitively() {
581      let entry = sample_entry();
582
583      assert!(!entry.should_finish(&["currently".to_string()]));
584    }
585  }
586
587  mod should_time {
588    use super::*;
589
590    #[test]
591    fn it_returns_true_when_no_patterns_match() {
592      let entry = sample_entry();
593
594      assert!(entry.should_time(&[]));
595    }
596
597    #[test]
598    fn it_returns_false_when_tag_pattern_matches() {
599      let entry = sample_entry();
600
601      assert!(!entry.should_time(&["@coding".to_string()]));
602    }
603  }
604
605  mod unfinished {
606    use super::*;
607
608    #[test]
609    fn it_returns_true_when_no_done_tag() {
610      let entry = Entry::new(
611        sample_date(),
612        "test",
613        Tags::new(),
614        Note::new(),
615        "Currently",
616        None::<String>,
617      );
618
619      assert!(entry.unfinished());
620    }
621
622    #[test]
623    fn it_returns_false_when_done_tag_present() {
624      let entry = sample_entry();
625
626      assert!(!entry.unfinished());
627    }
628  }
629}