Skip to main content

doing_taskpaper/
note.rs

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