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