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