Skip to main content

doing_taskpaper/
serializer.rs

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