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 lines: Vec<&str> = self.compressed_lines().collect();
96    lines.join(separator)
97  }
98
99  /// Return an iterator over compressed lines without cloning or mutating self.
100  fn compressed_lines(&self) -> impl Iterator<Item = &str> {
101    let mut prev_blank = true; // start true to skip leading blanks
102    let mut lines: Vec<&str> = Vec::new();
103    for line in &self.lines {
104      let trimmed = line.trim_end();
105      let is_blank = trimmed.trim().is_empty();
106      if is_blank {
107        if !prev_blank {
108          lines.push("");
109        }
110        prev_blank = true;
111      } else {
112        lines.push(trimmed);
113        prev_blank = false;
114      }
115    }
116    // Remove trailing blank lines
117    while lines.last().is_some_and(|l| l.trim().is_empty()) {
118      lines.pop();
119    }
120    lines.into_iter()
121  }
122}
123
124impl Display for Note {
125  /// Format as multi-line text with each line prefixed by two tabs (TaskPaper note format).
126  fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
127    for (i, line) in self.compressed_lines().enumerate() {
128      if i > 0 {
129        writeln!(f)?;
130      }
131      write!(f, "\t\t{line}")?;
132    }
133    Ok(())
134  }
135}
136
137#[cfg(test)]
138mod test {
139  use super::*;
140
141  mod compress {
142    use pretty_assertions::assert_eq;
143
144    use super::*;
145
146    #[test]
147    fn it_collapses_consecutive_blank_lines() {
148      let mut note = Note::from_lines(vec!["first", "", "", "", "second"]);
149
150      note.compress();
151
152      assert_eq!(note.lines(), &["first", "", "second"]);
153    }
154
155    #[test]
156    fn it_removes_leading_blank_lines() {
157      let mut note = Note::from_lines(vec!["", "", "content"]);
158
159      note.compress();
160
161      assert_eq!(note.lines(), &["content"]);
162    }
163
164    #[test]
165    fn it_removes_trailing_blank_lines() {
166      let mut note = Note::from_lines(vec!["content", "", ""]);
167
168      note.compress();
169
170      assert_eq!(note.lines(), &["content"]);
171    }
172
173    #[test]
174    fn it_trims_trailing_whitespace_from_lines() {
175      let mut note = Note::from_lines(vec!["hello   ", "world  "]);
176
177      note.compress();
178
179      assert_eq!(note.lines(), &["hello", "world"]);
180    }
181  }
182
183  mod display {
184    use pretty_assertions::assert_eq;
185
186    use super::*;
187
188    #[test]
189    fn it_formats_with_tab_prefix() {
190      let note = Note::from_lines(vec!["line one", "line two"]);
191
192      assert_eq!(note.to_string(), "\t\tline one\n\t\tline two");
193    }
194  }
195
196  mod from_str {
197    use pretty_assertions::assert_eq;
198
199    use super::*;
200
201    #[test]
202    fn it_splits_on_newlines() {
203      let note = Note::from_str("line one\nline two\nline three");
204
205      assert_eq!(note.lines(), &["line one", "line two", "line three"]);
206    }
207  }
208
209  mod is_empty {
210    use super::*;
211
212    #[test]
213    fn it_returns_true_for_empty_note() {
214      let note = Note::new();
215
216      assert!(note.is_empty());
217    }
218
219    #[test]
220    fn it_returns_true_for_blank_lines_only() {
221      let note = Note::from_lines(vec!["", "  ", "\t"]);
222
223      assert!(note.is_empty());
224    }
225
226    #[test]
227    fn it_returns_false_for_content() {
228      let note = Note::from_lines(vec!["hello"]);
229
230      assert!(!note.is_empty());
231    }
232  }
233
234  mod to_line {
235    use pretty_assertions::assert_eq;
236
237    use super::*;
238
239    #[test]
240    fn it_joins_with_separator() {
241      let note = Note::from_lines(vec!["one", "two", "three"]);
242
243      assert_eq!(note.to_line(" "), "one two three");
244    }
245
246    #[test]
247    fn it_compresses_before_joining() {
248      let note = Note::from_lines(vec!["", "one", "", "", "two", ""]);
249
250      assert_eq!(note.to_line("|"), "one||two");
251    }
252  }
253}