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