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