Skip to main content

doing_taskpaper/
note.rs

1use std::fmt::{Display, Formatter, Result as FmtResult};
2
3/// A multi-line note attached to a TaskPaper entry.
4///
5/// Internally stores lines as a `Vec<String>`. Supports conversion to/from
6/// single-line format and whitespace compression.
7#[derive(Clone, Debug, Default, Eq, PartialEq)]
8pub struct Note {
9  lines: Vec<String>,
10}
11
12impl Note {
13  /// Create a note from a list of lines.
14  pub fn from_lines(lines: impl IntoIterator<Item = impl Into<String>>) -> Self {
15    Self {
16      lines: lines.into_iter().map(Into::into).collect(),
17    }
18  }
19
20  /// Create a note by splitting a single string on newlines.
21  #[allow(clippy::should_implement_trait)]
22  pub fn from_str(text: &str) -> Self {
23    Self {
24      lines: text.lines().map(String::from).collect(),
25    }
26  }
27
28  /// Create a new empty note.
29  pub fn new() -> Self {
30    Self::default()
31  }
32
33  /// Append lines to the note.
34  pub fn add(&mut self, text: impl Into<String>) {
35    let text = text.into();
36    for line in text.lines() {
37      self.lines.push(line.to_string());
38    }
39  }
40
41  /// Compress whitespace: collapse consecutive blank lines into one, remove
42  /// leading and trailing blank lines, and trim trailing whitespace from each
43  /// line.
44  pub fn compress(&mut self) {
45    // Trim trailing whitespace from each line
46    for line in &mut self.lines {
47      let trimmed = line.trim_end().to_string();
48      *line = trimmed;
49    }
50
51    // Collapse consecutive blank lines into one
52    let mut compressed = Vec::new();
53    let mut prev_blank = false;
54    for line in &self.lines {
55      let is_blank = line.trim().is_empty();
56      if is_blank {
57        if !prev_blank {
58          compressed.push(String::new());
59        }
60        prev_blank = true;
61      } else {
62        compressed.push(line.clone());
63        prev_blank = false;
64      }
65    }
66    self.lines = compressed;
67
68    // Remove leading and trailing blank lines
69    while self.lines.first().is_some_and(|l| l.trim().is_empty()) {
70      self.lines.remove(0);
71    }
72    while self.lines.last().is_some_and(|l| l.trim().is_empty()) {
73      self.lines.pop();
74    }
75  }
76
77  /// Return whether the note has no content.
78  pub fn is_empty(&self) -> bool {
79    self.lines.is_empty() || self.lines.iter().all(|l| l.trim().is_empty())
80  }
81
82  /// Return the number of lines.
83  #[allow(dead_code)]
84  pub fn len(&self) -> usize {
85    self.lines.len()
86  }
87
88  /// Return the lines as a slice.
89  pub fn lines(&self) -> &[String] {
90    &self.lines
91  }
92
93  /// Convert to a single-line string with the given separator between lines.
94  pub fn to_line(&self, separator: &str) -> String {
95    let mut note = self.clone();
96    note.compress();
97    note.lines.join(separator)
98  }
99}
100
101impl Display for Note {
102  /// Format as multi-line text with each line prefixed by two tabs (TaskPaper note format).
103  fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
104    let mut note = self.clone();
105    note.compress();
106    for (i, line) in note.lines.iter().enumerate() {
107      if i > 0 {
108        writeln!(f)?;
109      }
110      write!(f, "\t\t{line}")?;
111    }
112    Ok(())
113  }
114}
115
116#[cfg(test)]
117mod test {
118  use super::*;
119
120  mod compress {
121    use pretty_assertions::assert_eq;
122
123    use super::*;
124
125    #[test]
126    fn it_collapses_consecutive_blank_lines() {
127      let mut note = Note::from_lines(vec!["first", "", "", "", "second"]);
128
129      note.compress();
130
131      assert_eq!(note.lines(), &["first", "", "second"]);
132    }
133
134    #[test]
135    fn it_removes_leading_blank_lines() {
136      let mut note = Note::from_lines(vec!["", "", "content"]);
137
138      note.compress();
139
140      assert_eq!(note.lines(), &["content"]);
141    }
142
143    #[test]
144    fn it_removes_trailing_blank_lines() {
145      let mut note = Note::from_lines(vec!["content", "", ""]);
146
147      note.compress();
148
149      assert_eq!(note.lines(), &["content"]);
150    }
151
152    #[test]
153    fn it_trims_trailing_whitespace_from_lines() {
154      let mut note = Note::from_lines(vec!["hello   ", "world  "]);
155
156      note.compress();
157
158      assert_eq!(note.lines(), &["hello", "world"]);
159    }
160  }
161
162  mod display {
163    use pretty_assertions::assert_eq;
164
165    use super::*;
166
167    #[test]
168    fn it_formats_with_tab_prefix() {
169      let note = Note::from_lines(vec!["line one", "line two"]);
170
171      assert_eq!(note.to_string(), "\t\tline one\n\t\tline two");
172    }
173  }
174
175  mod from_str {
176    use pretty_assertions::assert_eq;
177
178    use super::*;
179
180    #[test]
181    fn it_splits_on_newlines() {
182      let note = Note::from_str("line one\nline two\nline three");
183
184      assert_eq!(note.lines(), &["line one", "line two", "line three"]);
185    }
186  }
187
188  mod is_empty {
189    use super::*;
190
191    #[test]
192    fn it_returns_true_for_empty_note() {
193      let note = Note::new();
194
195      assert!(note.is_empty());
196    }
197
198    #[test]
199    fn it_returns_true_for_blank_lines_only() {
200      let note = Note::from_lines(vec!["", "  ", "\t"]);
201
202      assert!(note.is_empty());
203    }
204
205    #[test]
206    fn it_returns_false_for_content() {
207      let note = Note::from_lines(vec!["hello"]);
208
209      assert!(!note.is_empty());
210    }
211  }
212
213  mod to_line {
214    use pretty_assertions::assert_eq;
215
216    use super::*;
217
218    #[test]
219    fn it_joins_with_separator() {
220      let note = Note::from_lines(vec!["one", "two", "three"]);
221
222      assert_eq!(note.to_line(" "), "one two three");
223    }
224
225    #[test]
226    fn it_compresses_before_joining() {
227      let note = Note::from_lines(vec!["", "one", "", "", "two", ""]);
228
229      assert_eq!(note.to_line("|"), "one||two");
230    }
231  }
232}