Skip to main content

doing_taskpaper/
section.rs

1use std::fmt::{Display, Formatter, Result as FmtResult};
2
3use crate::Entry;
4
5/// A named section in a TaskPaper doing file containing an ordered list of entries.
6///
7/// Sections correspond to top-level headings in the doing file format (e.g. `Currently:`,
8/// `Archive:`). Each section holds a title and a sequence of entries that belong to it.
9#[derive(Clone, Debug)]
10pub struct Section {
11  entries: Vec<Entry>,
12  title: String,
13  trailing_content: Vec<String>,
14}
15
16impl Section {
17  /// Create a new section with the given title and no entries.
18  pub fn new(title: impl Into<String>) -> Self {
19    Self {
20      entries: Vec::new(),
21      title: title.into(),
22      trailing_content: Vec::new(),
23    }
24  }
25
26  /// Add an entry to the end of this section.
27  pub fn add_entry(&mut self, entry: Entry) {
28    self.entries.push(entry);
29  }
30
31  /// Return a slice of all entries in this section.
32  pub fn entries(&self) -> &[Entry] {
33    &self.entries
34  }
35
36  /// Return a mutable slice of all entries in this section.
37  pub fn entries_mut(&mut self) -> &mut Vec<Entry> {
38    &mut self.entries
39  }
40
41  /// Consume the section and return its entries.
42  pub fn into_entries(self) -> Vec<Entry> {
43    self.entries
44  }
45
46  /// Return `true` if this section contains no entries.
47  pub fn is_empty(&self) -> bool {
48    self.entries.is_empty()
49  }
50
51  /// Return the number of entries in this section.
52  pub fn len(&self) -> usize {
53    self.entries.len()
54  }
55
56  /// Remove all entries whose ID matches the given ID, returning the number removed.
57  pub fn remove_entry(&mut self, id: &str) -> usize {
58    let before = self.entries.len();
59    self.entries.retain(|e| e.id() != id);
60    before - self.entries.len()
61  }
62
63  /// Return the section title.
64  pub fn title(&self) -> &str {
65    &self.title
66  }
67
68  /// Return a slice of trailing content lines (non-entry lines after entries).
69  pub fn trailing_content(&self) -> &[String] {
70    &self.trailing_content
71  }
72
73  /// Return a mutable reference to trailing content lines.
74  pub fn trailing_content_mut(&mut self) -> &mut Vec<String> {
75    &mut self.trailing_content
76  }
77}
78
79impl Display for Section {
80  /// Format as a TaskPaper section: title line followed by indented entries with notes.
81  fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
82    write!(f, "{}:", self.title)?;
83    for entry in &self.entries {
84      write!(f, "\n\t- {} | {}", entry.date().format("%Y-%m-%d %H:%M"), entry)?;
85      if !entry.note().is_empty() {
86        write!(f, "\n{}", entry.note())?;
87      }
88    }
89    for line in &self.trailing_content {
90      write!(f, "\n{line}")?;
91    }
92    Ok(())
93  }
94}
95
96#[cfg(test)]
97mod test {
98  use super::*;
99
100  mod display {
101    use chrono::Local;
102    use pretty_assertions::assert_eq;
103
104    use super::*;
105    use crate::{Note, Tags};
106
107    #[test]
108    fn it_formats_empty_section() {
109      let section = Section::new("Currently");
110
111      assert_eq!(format!("{section}"), "Currently:");
112    }
113
114    #[test]
115    fn it_formats_section_with_entries() {
116      let date = Local::now();
117      let formatted_date = date.format("%Y-%m-%d %H:%M");
118      let entry = Entry::new(
119        date,
120        "Working on feature",
121        Tags::new(),
122        Note::new(),
123        "Currently",
124        None::<String>,
125      );
126      let mut section = Section::new("Currently");
127      section.add_entry(entry.clone());
128
129      let output = format!("{section}");
130
131      assert!(output.starts_with("Currently:"));
132      assert!(output.contains(&format!("\t- {formatted_date} | Working on feature")));
133    }
134
135    #[test]
136    fn it_formats_section_with_notes() {
137      let date = Local::now();
138      let entry = Entry::new(
139        date,
140        "Working on feature",
141        Tags::new(),
142        Note::from_text("A note line"),
143        "Currently",
144        None::<String>,
145      );
146      let mut section = Section::new("Currently");
147      section.add_entry(entry);
148
149      let output = format!("{section}");
150
151      assert!(output.contains("\t\tA note line"));
152    }
153  }
154
155  mod is_empty {
156    use chrono::Local;
157
158    use super::*;
159    use crate::{Note, Tags};
160
161    #[test]
162    fn it_returns_true_when_empty() {
163      let section = Section::new("Currently");
164
165      assert!(section.is_empty());
166    }
167
168    #[test]
169    fn it_returns_false_when_not_empty() {
170      let mut section = Section::new("Currently");
171      section.add_entry(Entry::new(
172        Local::now(),
173        "Test",
174        Tags::new(),
175        Note::new(),
176        "Currently",
177        None::<String>,
178      ));
179
180      assert!(!section.is_empty());
181    }
182  }
183
184  mod len {
185    use chrono::Local;
186    use pretty_assertions::assert_eq;
187
188    use super::*;
189    use crate::{Note, Tags};
190
191    #[test]
192    fn it_returns_entry_count() {
193      let mut section = Section::new("Currently");
194
195      assert_eq!(section.len(), 0);
196
197      section.add_entry(Entry::new(
198        Local::now(),
199        "First",
200        Tags::new(),
201        Note::new(),
202        "Currently",
203        None::<String>,
204      ));
205      section.add_entry(Entry::new(
206        Local::now(),
207        "Second",
208        Tags::new(),
209        Note::new(),
210        "Currently",
211        None::<String>,
212      ));
213
214      assert_eq!(section.len(), 2);
215    }
216  }
217
218  mod remove_entry {
219    use chrono::Local;
220    use pretty_assertions::assert_eq;
221
222    use super::*;
223    use crate::{Note, Tags};
224
225    #[test]
226    fn it_removes_matching_entry() {
227      let entry = Entry::new(
228        Local::now(),
229        "Test",
230        Tags::new(),
231        Note::new(),
232        "Currently",
233        None::<String>,
234      );
235      let id = entry.id().to_string();
236      let mut section = Section::new("Currently");
237      section.add_entry(entry);
238
239      let removed = section.remove_entry(&id);
240
241      assert_eq!(removed, 1);
242      assert_eq!(section.len(), 0);
243    }
244
245    #[test]
246    fn it_returns_zero_when_no_match() {
247      let mut section = Section::new("Currently");
248      section.add_entry(Entry::new(
249        Local::now(),
250        "Test",
251        Tags::new(),
252        Note::new(),
253        "Currently",
254        None::<String>,
255      ));
256
257      let removed = section.remove_entry("nonexistent");
258
259      assert_eq!(removed, 0);
260      assert_eq!(section.len(), 1);
261    }
262  }
263}