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  pub fn len(&self) -> usize {
94    self.sections.len()
95  }
96
97  /// Return non-entry content from the bottom of the file.
98  pub fn other_content_bottom(&self) -> &[String] {
99    &self.other_content_bottom
100  }
101
102  /// Return a mutable reference to non-entry content from the bottom of the file.
103  pub fn other_content_bottom_mut(&mut self) -> &mut Vec<String> {
104    &mut self.other_content_bottom
105  }
106
107  /// Return non-entry content from the top of the file.
108  pub fn other_content_top(&self) -> &[String] {
109    &self.other_content_top
110  }
111
112  /// Return a mutable reference to non-entry content from the top of the file.
113  pub fn other_content_top_mut(&mut self) -> &mut Vec<String> {
114    &mut self.other_content_top
115  }
116
117  /// Remove a section by name (case-insensitive), returning the number removed.
118  pub fn remove_section(&mut self, name: &str) -> usize {
119    let before = self.sections.len();
120    self.sections.retain(|s| !s.title().eq_ignore_ascii_case(name));
121    before - self.sections.len()
122  }
123
124  /// Look up a section by name (case-insensitive).
125  pub fn section_by_name(&self, name: &str) -> Option<&Section> {
126    self.sections.iter().find(|s| s.title().eq_ignore_ascii_case(name))
127  }
128
129  /// Look up a mutable section by name (case-insensitive).
130  pub fn section_by_name_mut(&mut self, name: &str) -> Option<&mut Section> {
131    self.sections.iter_mut().find(|s| s.title().eq_ignore_ascii_case(name))
132  }
133
134  /// Return a slice of all sections.
135  pub fn sections(&self) -> &[Section] {
136    &self.sections
137  }
138
139  /// Return a mutable reference to all sections.
140  pub fn sections_mut(&mut self) -> &mut Vec<Section> {
141    &mut self.sections
142  }
143
144  /// Sort entries within each section by date then title, in ascending order.
145  /// If `reverse` is true, sort in descending order.
146  pub fn sort_entries(&mut self, reverse: bool) {
147    for section in &mut self.sections {
148      section
149        .entries_mut()
150        .sort_by(|a, b| a.date().cmp(&b.date()).then_with(|| a.title().cmp(b.title())));
151      if reverse {
152        section.entries_mut().reverse();
153      }
154    }
155  }
156}
157
158impl Default for Document {
159  fn default() -> Self {
160    Self::new()
161  }
162}
163
164impl Display for Document {
165  /// Format as a complete TaskPaper doing file.
166  fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
167    for line in &self.other_content_top {
168      writeln!(f, "{line}")?;
169    }
170    for (i, section) in self.sections.iter().enumerate() {
171      if i > 0 || !self.other_content_top.is_empty() {
172        writeln!(f)?;
173      }
174      write!(f, "{section}")?;
175    }
176    for line in &self.other_content_bottom {
177      write!(f, "\n{line}")?;
178    }
179    Ok(())
180  }
181}
182
183#[cfg(test)]
184mod test {
185  use super::*;
186
187  mod add_section {
188    use chrono::Local;
189    use pretty_assertions::assert_eq;
190
191    use super::*;
192    use crate::{Note, Tags};
193
194    #[test]
195    fn it_adds_a_section() {
196      let mut doc = Document::new();
197      doc.add_section(Section::new("Currently"));
198
199      assert_eq!(doc.len(), 1);
200    }
201
202    #[test]
203    fn it_merges_duplicate_section_entries() {
204      let mut doc = Document::new();
205      let mut s1 = Section::new("Archive");
206      s1.add_entry(Entry::new(
207        Local::now(),
208        "Task A",
209        Tags::new(),
210        Note::new(),
211        "Archive",
212        None::<String>,
213      ));
214      let mut s2 = Section::new("Archive");
215      s2.add_entry(Entry::new(
216        Local::now(),
217        "Task B",
218        Tags::new(),
219        Note::new(),
220        "Archive",
221        None::<String>,
222      ));
223      doc.add_section(s1);
224      doc.add_section(s2);
225
226      assert_eq!(doc.len(), 1);
227      assert_eq!(doc.section_by_name("Archive").unwrap().len(), 2);
228    }
229
230    #[test]
231    fn it_merges_duplicate_section_names_case_insensitively() {
232      let mut doc = Document::new();
233      doc.add_section(Section::new("Currently"));
234      doc.add_section(Section::new("currently"));
235
236      assert_eq!(doc.len(), 1);
237    }
238  }
239
240  mod all_entries {
241    use chrono::Local;
242    use pretty_assertions::assert_eq;
243
244    use super::*;
245    use crate::{Note, Tags};
246
247    #[test]
248    fn it_returns_entries_across_all_sections() {
249      let mut doc = Document::new();
250      let mut s1 = Section::new("Currently");
251      s1.add_entry(Entry::new(
252        Local::now(),
253        "Task A",
254        Tags::new(),
255        Note::new(),
256        "Currently",
257        None::<String>,
258      ));
259      let mut s2 = Section::new("Archive");
260      s2.add_entry(Entry::new(
261        Local::now(),
262        "Task B",
263        Tags::new(),
264        Note::new(),
265        "Archive",
266        None::<String>,
267      ));
268      doc.add_section(s1);
269      doc.add_section(s2);
270
271      assert_eq!(doc.all_entries().len(), 2);
272    }
273  }
274
275  mod dedup {
276    use chrono::Local;
277    use pretty_assertions::assert_eq;
278
279    use super::*;
280    use crate::{Note, Tags};
281
282    #[test]
283    fn it_removes_duplicate_entries_by_id() {
284      let entry = Entry::new(
285        Local::now(),
286        "Task A",
287        Tags::new(),
288        Note::new(),
289        "Currently",
290        Some("aaaabbbbccccddddeeeeffffaaaabbbb"),
291      );
292      let mut s1 = Section::new("Currently");
293      s1.add_entry(entry.clone());
294      let mut s2 = Section::new("Archive");
295      s2.add_entry(entry);
296      let mut doc = Document::new();
297      doc.add_section(s1);
298      doc.add_section(s2);
299
300      doc.dedup();
301
302      assert_eq!(doc.all_entries().len(), 1);
303      assert_eq!(doc.sections()[0].len(), 1);
304      assert_eq!(doc.sections()[1].len(), 0);
305    }
306  }
307
308  mod display {
309    use pretty_assertions::assert_eq;
310
311    use super::*;
312
313    #[test]
314    fn it_formats_empty_document() {
315      let doc = Document::new();
316
317      assert_eq!(format!("{doc}"), "");
318    }
319
320    #[test]
321    fn it_formats_sections_in_order() {
322      let mut doc = Document::new();
323      doc.add_section(Section::new("Currently"));
324      doc.add_section(Section::new("Archive"));
325
326      let output = format!("{doc}");
327
328      assert!(output.starts_with("Currently:"));
329      assert!(output.contains("\nArchive:"));
330    }
331
332    #[test]
333    fn it_includes_other_content_top() {
334      let mut doc = Document::new();
335      doc.other_content_top_mut().push("# My Doing File".to_string());
336      doc.add_section(Section::new("Currently"));
337
338      let output = format!("{doc}");
339
340      assert!(output.starts_with("# My Doing File\n"));
341      assert!(output.contains("Currently:"));
342    }
343
344    #[test]
345    fn it_includes_other_content_bottom() {
346      let mut doc = Document::new();
347      doc.add_section(Section::new("Currently"));
348      doc.other_content_bottom_mut().push("# Footer".to_string());
349
350      let output = format!("{doc}");
351
352      assert!(output.contains("Currently:"));
353      assert!(output.ends_with("# Footer"));
354    }
355  }
356
357  mod entries_in_section {
358    use chrono::Local;
359    use pretty_assertions::assert_eq;
360
361    use super::*;
362    use crate::{Note, Tags};
363
364    #[test]
365    fn it_returns_entries_from_named_section() {
366      let mut doc = Document::new();
367      let mut section = Section::new("Currently");
368      section.add_entry(Entry::new(
369        Local::now(),
370        "Task A",
371        Tags::new(),
372        Note::new(),
373        "Currently",
374        None::<String>,
375      ));
376      doc.add_section(section);
377
378      assert_eq!(doc.entries_in_section("currently").len(), 1);
379    }
380
381    #[test]
382    fn it_returns_all_entries_for_all() {
383      let mut doc = Document::new();
384      let mut s1 = Section::new("Currently");
385      s1.add_entry(Entry::new(
386        Local::now(),
387        "Task A",
388        Tags::new(),
389        Note::new(),
390        "Currently",
391        None::<String>,
392      ));
393      let mut s2 = Section::new("Archive");
394      s2.add_entry(Entry::new(
395        Local::now(),
396        "Task B",
397        Tags::new(),
398        Note::new(),
399        "Archive",
400        None::<String>,
401      ));
402      doc.add_section(s1);
403      doc.add_section(s2);
404
405      assert_eq!(doc.entries_in_section("All").len(), 2);
406    }
407
408    #[test]
409    fn it_returns_empty_for_unknown_section() {
410      let doc = Document::new();
411
412      assert_eq!(doc.entries_in_section("Nonexistent").len(), 0);
413    }
414  }
415
416  mod has_section {
417    use pretty_assertions::assert_eq;
418
419    use super::*;
420
421    #[test]
422    fn it_finds_section_case_insensitively() {
423      let mut doc = Document::new();
424      doc.add_section(Section::new("Currently"));
425
426      assert_eq!(doc.has_section("currently"), true);
427      assert_eq!(doc.has_section("CURRENTLY"), true);
428    }
429
430    #[test]
431    fn it_returns_false_for_missing_section() {
432      let doc = Document::new();
433
434      assert_eq!(doc.has_section("Currently"), false);
435    }
436  }
437
438  mod remove_section {
439    use pretty_assertions::assert_eq;
440
441    use super::*;
442
443    #[test]
444    fn it_removes_matching_section() {
445      let mut doc = Document::new();
446      doc.add_section(Section::new("Currently"));
447
448      let removed = doc.remove_section("currently");
449
450      assert_eq!(removed, 1);
451      assert_eq!(doc.len(), 0);
452    }
453
454    #[test]
455    fn it_returns_zero_when_no_match() {
456      let mut doc = Document::new();
457
458      let removed = doc.remove_section("Nonexistent");
459
460      assert_eq!(removed, 0);
461    }
462  }
463
464  mod section_by_name {
465    use pretty_assertions::assert_eq;
466
467    use super::*;
468
469    #[test]
470    fn it_finds_section_case_insensitively() {
471      let mut doc = Document::new();
472      doc.add_section(Section::new("Currently"));
473
474      let section = doc.section_by_name("currently");
475
476      assert!(section.is_some());
477      assert_eq!(section.unwrap().title(), "Currently");
478    }
479
480    #[test]
481    fn it_returns_none_for_missing_section() {
482      let doc = Document::new();
483
484      assert!(doc.section_by_name("Currently").is_none());
485    }
486  }
487
488  mod sections {
489    use pretty_assertions::assert_eq;
490
491    use super::*;
492
493    #[test]
494    fn it_returns_sections_in_order() {
495      let mut doc = Document::new();
496      doc.add_section(Section::new("Currently"));
497      doc.add_section(Section::new("Archive"));
498
499      let names: Vec<&str> = doc.sections().iter().map(|s| s.title()).collect();
500      assert_eq!(names, vec!["Currently", "Archive"]);
501    }
502  }
503}