Skip to main content

doing_taskpaper/
document.rs

1use std::{
2  collections::HashSet,
3  fmt::{Display, Formatter, Result as FmtResult},
4  path::Path,
5};
6
7use doing_error::Result;
8
9use crate::{Entry, Section};
10
11/// A complete TaskPaper doing file represented as an ordered list of sections.
12///
13/// The document preserves section ordering from the original file and can track
14/// non-entry content at the top and bottom of the file for round-trip fidelity.
15#[derive(Clone, Debug)]
16pub struct Document {
17  other_content_bottom: Vec<String>,
18  other_content_top: Vec<String>,
19  sections: Vec<Section>,
20}
21
22impl Document {
23  /// Create a new doing file at `path` with a single default section.
24  ///
25  /// If the file already exists and is non-empty, this is a no-op.
26  /// Creates parent directories as needed.
27  pub fn create_file(path: &Path, default_section: &str) -> Result<()> {
28    crate::io::create_file(path, default_section)
29  }
30
31  /// Create a new empty document.
32  pub fn new() -> Self {
33    Self {
34      other_content_bottom: Vec::new(),
35      other_content_top: Vec::new(),
36      sections: Vec::new(),
37    }
38  }
39
40  /// Parse a doing file string into a structured `Document`.
41  pub fn parse(content: &str) -> Self {
42    crate::parser::parse(content)
43  }
44
45  /// Add a section to the document. If a section with the same name (case-insensitive)
46  /// already exists, merge entries from the new section into the existing one.
47  pub fn add_section(&mut self, section: Section) {
48    if let Some(existing) = self.section_by_name_mut(section.title()) {
49      for entry in section.into_entries() {
50        existing.add_entry(entry);
51      }
52    } else {
53      self.sections.push(section);
54    }
55  }
56
57  /// Return all entries across all sections.
58  pub fn all_entries(&self) -> Vec<&Entry> {
59    self.sections.iter().flat_map(|s| s.entries()).collect()
60  }
61
62  /// Deduplicate entries across all sections by ID, keeping the first occurrence.
63  pub fn dedup(&mut self) {
64    let mut seen = HashSet::new();
65    for section in &mut self.sections {
66      section.entries_mut().retain(|e| seen.insert(e.id().to_string()));
67    }
68  }
69
70  /// Return entries from a specific section by name (case-insensitive).
71  /// If `name` is "all" (case-insensitive), returns entries from all sections.
72  pub fn entries_in_section(&self, name: &str) -> Vec<&Entry> {
73    if name.eq_ignore_ascii_case("all") {
74      return self.all_entries();
75    }
76    self
77      .section_by_name(name)
78      .map(|s| s.entries().iter().collect())
79      .unwrap_or_default()
80  }
81
82  /// Return `true` if a section with the given name exists (case-insensitive).
83  pub fn has_section(&self, name: &str) -> bool {
84    self.sections.iter().any(|s| s.title().eq_ignore_ascii_case(name))
85  }
86
87  /// Return `true` if the document has no sections.
88  pub fn is_empty(&self) -> bool {
89    self.sections.is_empty()
90  }
91
92  /// Return the number of sections in the document.
93  #[allow(dead_code)]
94  pub fn len(&self) -> usize {
95    self.sections.len()
96  }
97
98  /// Return non-entry content from the bottom of the file.
99  pub fn other_content_bottom(&self) -> &[String] {
100    &self.other_content_bottom
101  }
102
103  /// Return a mutable reference to non-entry content from the bottom of the file.
104  pub fn other_content_bottom_mut(&mut self) -> &mut Vec<String> {
105    &mut self.other_content_bottom
106  }
107
108  /// Return non-entry content from the top of the file.
109  pub fn other_content_top(&self) -> &[String] {
110    &self.other_content_top
111  }
112
113  /// Return a mutable reference to non-entry content from the top of the file.
114  pub fn other_content_top_mut(&mut self) -> &mut Vec<String> {
115    &mut self.other_content_top
116  }
117
118  /// Remove a section by name (case-insensitive), returning the number removed.
119  pub fn remove_section(&mut self, name: &str) -> usize {
120    let before = self.sections.len();
121    self.sections.retain(|s| !s.title().eq_ignore_ascii_case(name));
122    before - self.sections.len()
123  }
124
125  /// Look up a section by name (case-insensitive).
126  pub fn section_by_name(&self, name: &str) -> Option<&Section> {
127    self.sections.iter().find(|s| s.title().eq_ignore_ascii_case(name))
128  }
129
130  /// Look up a mutable section by name (case-insensitive).
131  pub fn section_by_name_mut(&mut self, name: &str) -> Option<&mut Section> {
132    self.sections.iter_mut().find(|s| s.title().eq_ignore_ascii_case(name))
133  }
134
135  /// Return a slice of all sections.
136  pub fn sections(&self) -> &[Section] {
137    &self.sections
138  }
139
140  /// Return a mutable reference to all sections.
141  pub fn sections_mut(&mut self) -> &mut Vec<Section> {
142    &mut self.sections
143  }
144
145  /// Sort entries within each section by date then title, in ascending order.
146  /// If `reverse` is true, sort in descending order.
147  pub fn sort_entries(&mut self, reverse: bool) {
148    for section in &mut self.sections {
149      section
150        .entries_mut()
151        .sort_by(|a, b| a.date().cmp(&b.date()).then_with(|| a.title().cmp(b.title())));
152      if reverse {
153        section.entries_mut().reverse();
154      }
155    }
156  }
157}
158
159impl Default for Document {
160  fn default() -> Self {
161    Self::new()
162  }
163}
164
165impl Display for Document {
166  /// Format as a complete TaskPaper doing file.
167  fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
168    for line in &self.other_content_top {
169      writeln!(f, "{line}")?;
170    }
171    for (i, section) in self.sections.iter().enumerate() {
172      if i > 0 || !self.other_content_top.is_empty() {
173        writeln!(f)?;
174      }
175      write!(f, "{section}")?;
176    }
177    for line in &self.other_content_bottom {
178      write!(f, "\n{line}")?;
179    }
180    Ok(())
181  }
182}
183
184#[cfg(test)]
185mod test {
186  use super::*;
187
188  mod add_section {
189    use chrono::Local;
190    use pretty_assertions::assert_eq;
191
192    use super::*;
193    use crate::{Note, Tags};
194
195    #[test]
196    fn it_adds_a_section() {
197      let mut doc = Document::new();
198      doc.add_section(Section::new("Currently"));
199
200      assert_eq!(doc.len(), 1);
201    }
202
203    #[test]
204    fn it_merges_duplicate_section_entries() {
205      let mut doc = Document::new();
206      let mut s1 = Section::new("Archive");
207      s1.add_entry(Entry::new(
208        Local::now(),
209        "Task A",
210        Tags::new(),
211        Note::new(),
212        "Archive",
213        None::<String>,
214      ));
215      let mut s2 = Section::new("Archive");
216      s2.add_entry(Entry::new(
217        Local::now(),
218        "Task B",
219        Tags::new(),
220        Note::new(),
221        "Archive",
222        None::<String>,
223      ));
224      doc.add_section(s1);
225      doc.add_section(s2);
226
227      assert_eq!(doc.len(), 1);
228      assert_eq!(doc.section_by_name("Archive").unwrap().len(), 2);
229    }
230
231    #[test]
232    fn it_merges_duplicate_section_names_case_insensitively() {
233      let mut doc = Document::new();
234      doc.add_section(Section::new("Currently"));
235      doc.add_section(Section::new("currently"));
236
237      assert_eq!(doc.len(), 1);
238    }
239  }
240
241  mod all_entries {
242    use chrono::Local;
243    use pretty_assertions::assert_eq;
244
245    use super::*;
246    use crate::{Note, Tags};
247
248    #[test]
249    fn it_returns_entries_across_all_sections() {
250      let mut doc = Document::new();
251      let mut s1 = Section::new("Currently");
252      s1.add_entry(Entry::new(
253        Local::now(),
254        "Task A",
255        Tags::new(),
256        Note::new(),
257        "Currently",
258        None::<String>,
259      ));
260      let mut s2 = Section::new("Archive");
261      s2.add_entry(Entry::new(
262        Local::now(),
263        "Task B",
264        Tags::new(),
265        Note::new(),
266        "Archive",
267        None::<String>,
268      ));
269      doc.add_section(s1);
270      doc.add_section(s2);
271
272      assert_eq!(doc.all_entries().len(), 2);
273    }
274  }
275
276  mod dedup {
277    use chrono::Local;
278    use pretty_assertions::assert_eq;
279
280    use super::*;
281    use crate::{Note, Tags};
282
283    #[test]
284    fn it_removes_duplicate_entries_by_id() {
285      let entry = Entry::new(
286        Local::now(),
287        "Task A",
288        Tags::new(),
289        Note::new(),
290        "Currently",
291        Some("aaaabbbbccccddddeeeeffffaaaabbbb"),
292      );
293      let mut s1 = Section::new("Currently");
294      s1.add_entry(entry.clone());
295      let mut s2 = Section::new("Archive");
296      s2.add_entry(entry);
297      let mut doc = Document::new();
298      doc.add_section(s1);
299      doc.add_section(s2);
300
301      doc.dedup();
302
303      assert_eq!(doc.all_entries().len(), 1);
304      assert_eq!(doc.sections()[0].len(), 1);
305      assert_eq!(doc.sections()[1].len(), 0);
306    }
307  }
308
309  mod display {
310    use pretty_assertions::assert_eq;
311
312    use super::*;
313
314    #[test]
315    fn it_formats_empty_document() {
316      let doc = Document::new();
317
318      assert_eq!(format!("{doc}"), "");
319    }
320
321    #[test]
322    fn it_formats_sections_in_order() {
323      let mut doc = Document::new();
324      doc.add_section(Section::new("Currently"));
325      doc.add_section(Section::new("Archive"));
326
327      let output = format!("{doc}");
328
329      assert!(output.starts_with("Currently:"));
330      assert!(output.contains("\nArchive:"));
331    }
332
333    #[test]
334    fn it_includes_other_content_top() {
335      let mut doc = Document::new();
336      doc.other_content_top_mut().push("# My Doing File".to_string());
337      doc.add_section(Section::new("Currently"));
338
339      let output = format!("{doc}");
340
341      assert!(output.starts_with("# My Doing File\n"));
342      assert!(output.contains("Currently:"));
343    }
344
345    #[test]
346    fn it_includes_other_content_bottom() {
347      let mut doc = Document::new();
348      doc.add_section(Section::new("Currently"));
349      doc.other_content_bottom_mut().push("# Footer".to_string());
350
351      let output = format!("{doc}");
352
353      assert!(output.contains("Currently:"));
354      assert!(output.ends_with("# Footer"));
355    }
356  }
357
358  mod entries_in_section {
359    use chrono::Local;
360    use pretty_assertions::assert_eq;
361
362    use super::*;
363    use crate::{Note, Tags};
364
365    #[test]
366    fn it_returns_entries_from_named_section() {
367      let mut doc = Document::new();
368      let mut section = Section::new("Currently");
369      section.add_entry(Entry::new(
370        Local::now(),
371        "Task A",
372        Tags::new(),
373        Note::new(),
374        "Currently",
375        None::<String>,
376      ));
377      doc.add_section(section);
378
379      assert_eq!(doc.entries_in_section("currently").len(), 1);
380    }
381
382    #[test]
383    fn it_returns_all_entries_for_all() {
384      let mut doc = Document::new();
385      let mut s1 = Section::new("Currently");
386      s1.add_entry(Entry::new(
387        Local::now(),
388        "Task A",
389        Tags::new(),
390        Note::new(),
391        "Currently",
392        None::<String>,
393      ));
394      let mut s2 = Section::new("Archive");
395      s2.add_entry(Entry::new(
396        Local::now(),
397        "Task B",
398        Tags::new(),
399        Note::new(),
400        "Archive",
401        None::<String>,
402      ));
403      doc.add_section(s1);
404      doc.add_section(s2);
405
406      assert_eq!(doc.entries_in_section("All").len(), 2);
407    }
408
409    #[test]
410    fn it_returns_empty_for_unknown_section() {
411      let doc = Document::new();
412
413      assert_eq!(doc.entries_in_section("Nonexistent").len(), 0);
414    }
415  }
416
417  mod has_section {
418    use pretty_assertions::assert_eq;
419
420    use super::*;
421
422    #[test]
423    fn it_finds_section_case_insensitively() {
424      let mut doc = Document::new();
425      doc.add_section(Section::new("Currently"));
426
427      assert_eq!(doc.has_section("currently"), true);
428      assert_eq!(doc.has_section("CURRENTLY"), true);
429    }
430
431    #[test]
432    fn it_returns_false_for_missing_section() {
433      let doc = Document::new();
434
435      assert_eq!(doc.has_section("Currently"), false);
436    }
437  }
438
439  mod remove_section {
440    use pretty_assertions::assert_eq;
441
442    use super::*;
443
444    #[test]
445    fn it_removes_matching_section() {
446      let mut doc = Document::new();
447      doc.add_section(Section::new("Currently"));
448
449      let removed = doc.remove_section("currently");
450
451      assert_eq!(removed, 1);
452      assert_eq!(doc.len(), 0);
453    }
454
455    #[test]
456    fn it_returns_zero_when_no_match() {
457      let mut doc = Document::new();
458
459      let removed = doc.remove_section("Nonexistent");
460
461      assert_eq!(removed, 0);
462    }
463  }
464
465  mod section_by_name {
466    use pretty_assertions::assert_eq;
467
468    use super::*;
469
470    #[test]
471    fn it_finds_section_case_insensitively() {
472      let mut doc = Document::new();
473      doc.add_section(Section::new("Currently"));
474
475      let section = doc.section_by_name("currently");
476
477      assert!(section.is_some());
478      assert_eq!(section.unwrap().title(), "Currently");
479    }
480
481    #[test]
482    fn it_returns_none_for_missing_section() {
483      let doc = Document::new();
484
485      assert!(doc.section_by_name("Currently").is_none());
486    }
487  }
488
489  mod sections {
490    use pretty_assertions::assert_eq;
491
492    use super::*;
493
494    #[test]
495    fn it_returns_sections_in_order() {
496      let mut doc = Document::new();
497      doc.add_section(Section::new("Currently"));
498      doc.add_section(Section::new("Archive"));
499
500      let names: Vec<&str> = doc.sections().iter().map(|s| s.title()).collect();
501      assert_eq!(names, vec!["Currently", "Archive"]);
502    }
503  }
504}