1use std::fmt::{Display, Formatter, Result as FmtResult};
2
3#[derive(Clone, Debug, Default, Eq, PartialEq)]
8pub struct Note {
9 lines: Vec<String>,
10}
11
12impl Note {
13 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 #[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 pub fn new() -> Self {
30 Self::default()
31 }
32
33 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 pub fn compress(&mut self) {
45 for line in &mut self.lines {
47 let trimmed = line.trim_end().to_string();
48 *line = trimmed;
49 }
50
51 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 let leading = self.lines.iter().take_while(|l| l.trim().is_empty()).count();
70 if leading > 0 {
71 self.lines.drain(..leading);
72 }
73 while self.lines.last().is_some_and(|l| l.trim().is_empty()) {
75 self.lines.pop();
76 }
77 }
78
79 pub fn is_empty(&self) -> bool {
81 self.lines.is_empty() || self.lines.iter().all(|l| l.trim().is_empty())
82 }
83
84 #[allow(dead_code)]
86 pub fn len(&self) -> usize {
87 self.lines.len()
88 }
89
90 pub fn lines(&self) -> &[String] {
92 &self.lines
93 }
94
95 pub fn to_line(&self, separator: &str) -> String {
97 let lines: Vec<&str> = self.compressed_lines().collect();
98 lines.join(separator)
99 }
100
101 fn compressed_lines(&self) -> impl Iterator<Item = &str> {
103 let mut prev_blank = true; 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 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 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}