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