doing_taskpaper/
serializer.rs1use std::{collections::HashSet, sync::LazyLock};
2
3use regex::Regex;
4
5use crate::Document;
6
7static STRIP_ANSI_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").unwrap());
8
9pub fn serialize(doc: &Document) -> String {
14 let mut seen = HashSet::new();
15 let mut out = String::new();
16
17 for line in doc.other_content_top() {
18 out.push_str(line);
19 out.push('\n');
20 }
21
22 for (i, section) in doc.sections().iter().enumerate() {
23 if i > 0 || !doc.other_content_top().is_empty() {
24 out.push('\n');
25 }
26
27 out.push_str(section.title());
28 out.push(':');
29
30 for entry in section.entries() {
31 if !seen.insert(entry.id()) {
32 continue;
33 }
34 out.push_str(&format!("\n\t- {} | {}", entry.date().format("%Y-%m-%d %H:%M"), entry));
35 if !entry.note().is_empty() {
36 out.push_str(&format!("\n{}", entry.note()));
37 }
38 }
39
40 for line in section.trailing_content() {
41 out.push('\n');
42 out.push_str(line);
43 }
44 }
45
46 for line in doc.other_content_bottom() {
47 out.push('\n');
48 out.push_str(line);
49 }
50
51 strip_ansi(&out)
52}
53
54fn strip_ansi(text: &str) -> String {
56 STRIP_ANSI_RE.replace_all(text, "").into_owned()
57}
58
59#[cfg(test)]
60mod test {
61 use chrono::{Local, TimeZone};
62
63 use super::*;
64 use crate::{Entry, Note, Section, Tag, Tags};
65
66 fn sample_date(hour: u32, minute: u32) -> chrono::DateTime<Local> {
67 Local.with_ymd_and_hms(2024, 3, 17, hour, minute, 0).unwrap()
68 }
69
70 mod serialize {
71 use pretty_assertions::assert_eq;
72
73 use super::*;
74
75 #[test]
76 fn it_produces_empty_string_for_empty_document() {
77 let doc = Document::new();
78
79 assert_eq!(serialize(&doc), "");
80 }
81
82 #[test]
83 fn it_round_trips_a_well_formed_document() {
84 let content = "\
85Currently:
86\t- 2024-03-17 14:30 | Working on feature @coding <aaaabbbbccccddddeeeeffffaaaabbbb>
87\t\tA note about the work
88Archive:
89\t- 2024-03-16 10:00 | Old task @done(2024-03-16 11:00) <bbbbccccddddeeeeffffaaaabbbbcccc>";
90 let doc = Document::parse(content);
91
92 let output = serialize(&doc);
93
94 assert_eq!(output, content);
95 }
96
97 #[test]
98 fn it_deduplicates_entries_by_id() {
99 let mut doc = Document::new();
100 let entry = Entry::new(
101 sample_date(14, 30),
102 "Task A",
103 Tags::new(),
104 Note::new(),
105 "Currently",
106 Some("aaaabbbbccccddddeeeeffffaaaabbbb"),
107 );
108 let mut s1 = Section::new("Currently");
109 s1.add_entry(entry.clone());
110 let mut s2 = Section::new("Archive");
111 s2.add_entry(entry);
112 doc.add_section(s1);
113 doc.add_section(s2);
114
115 let output = serialize(&doc);
116
117 assert_eq!(output.matches("Task A").count(), 1);
118 }
119
120 #[test]
121 fn it_strips_ansi_color_codes() {
122 let mut doc = Document::new();
123 let mut section = Section::new("Currently");
124 section.add_entry(Entry::new(
125 sample_date(14, 30),
126 "\x1b[31mRed task\x1b[0m",
127 Tags::new(),
128 Note::new(),
129 "Currently",
130 Some("aaaabbbbccccddddeeeeffffaaaabbbb"),
131 ));
132 doc.add_section(section);
133
134 let output = serialize(&doc);
135
136 assert!(!output.contains("\x1b["));
137 assert!(output.contains("Red task"));
138 }
139
140 #[test]
141 fn it_preserves_other_content_top() {
142 let mut doc = Document::new();
143 doc.other_content_top_mut().push("# My Doing File".to_string());
144 doc.add_section(Section::new("Currently"));
145
146 let output = serialize(&doc);
147
148 assert!(output.starts_with("# My Doing File\n"));
149 }
150
151 #[test]
152 fn it_preserves_other_content_bottom() {
153 let mut doc = Document::new();
154 doc.add_section(Section::new("Currently"));
155 doc.other_content_bottom_mut().push("# Footer".to_string());
156
157 let output = serialize(&doc);
158
159 assert!(output.ends_with("# Footer"));
160 }
161
162 #[test]
163 fn it_includes_notes() {
164 let mut doc = Document::new();
165 let mut section = Section::new("Currently");
166 section.add_entry(Entry::new(
167 sample_date(14, 30),
168 "Task with notes",
169 Tags::new(),
170 Note::from_str("A note line\nAnother note"),
171 "Currently",
172 Some("aaaabbbbccccddddeeeeffffaaaabbbb"),
173 ));
174 doc.add_section(section);
175
176 let output = serialize(&doc);
177
178 assert!(output.contains("\t\tA note line"));
179 assert!(output.contains("\t\tAnother note"));
180 }
181
182 #[test]
183 fn it_includes_tags() {
184 let mut doc = Document::new();
185 let mut section = Section::new("Currently");
186 section.add_entry(Entry::new(
187 sample_date(14, 30),
188 "Tagged task",
189 Tags::from_iter(vec![
190 Tag::new("coding", None::<String>),
191 Tag::new("done", Some("2024-03-17 15:00")),
192 ]),
193 Note::new(),
194 "Currently",
195 Some("aaaabbbbccccddddeeeeffffaaaabbbb"),
196 ));
197 doc.add_section(section);
198
199 let output = serialize(&doc);
200
201 assert!(output.contains("@coding"));
202 assert!(output.contains("@done(2024-03-17 15:00)"));
203 }
204 }
205
206 mod strip_ansi {
207 use pretty_assertions::assert_eq;
208
209 use super::*;
210
211 #[test]
212 fn it_removes_ansi_escape_sequences() {
213 let input = "\x1b[31mhello\x1b[0m world";
214
215 assert_eq!(strip_ansi(input), "hello world");
216 }
217
218 #[test]
219 fn it_returns_unchanged_string_without_ansi() {
220 let input = "hello world";
221
222 assert_eq!(strip_ansi(input), "hello world");
223 }
224 }
225}