Skip to main content

doing_taskpaper/
serializer.rs

1use 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
9/// Serialize a `Document` into the doing file format string.
10///
11/// Deduplicates entries by ID and strips any ANSI color codes from the output.
12/// Callers are responsible for sorting entries before calling this function.
13pub 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
54/// Remove ANSI escape sequences from a string.
55fn 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_text("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}