1pub struct Entry<'a> {
3 pub date: &'a str, pub time: &'a str, pub raw: Option<&'a str>, pub clean: &'a str,
7}
8
9#[derive(Clone, Copy, PartialEq, Eq)]
13pub enum Mode { Reflect, Journal }
14
15pub fn append(body: &str, entry: &Entry, mode: Mode) -> String {
21 let block = render_turn(entry);
22 match mode {
23 Mode::Journal => {
24 let sep = if body.trim().is_empty() { "\n" } else { "\n\n---\n\n" };
25 format!("{}{}## {}\n{}", body.trim_end_matches('\n'), sep, entry.time, block)
26 }
27 Mode::Reflect => {
28 let date_header = format!("## {}", entry.date);
29 if body.contains(&date_header) {
30 join(body, &format!("\n### {}\n{}", entry.time, block))
31 } else {
32 join(body, &format!("\n{}\n{}", date_header, block))
33 }
34 }
35 }
36}
37
38fn render_turn(entry: &Entry) -> String {
39 match entry.raw {
40 Some(raw) => format!("<!-- raw: {} -->\n\n{}\n", sanitize_comment(raw), entry.clean),
43 None => format!("{}\n", entry.clean),
44 }
45}
46
47fn sanitize_comment(raw: &str) -> String {
50 raw.replace('<', "<")
51 .replace("--", "--")
52 .replace('\n', " ")
53}
54
55fn join(body: &str, section: &str) -> String {
56 format!("{}\n{}", body.trim_end_matches('\n'), section.trim_start_matches('\n'))
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62
63 fn turn<'a>(date: &'a str, time: &'a str, raw: Option<&'a str>, clean: &'a str) -> Entry<'a> {
64 Entry { date, time, raw, clean }
65 }
66
67 #[test]
68 fn reflect_new_date_appends_a_dated_section() {
69 let out = append("", &turn("2026-06-06", "08:14", Some("um the thing is"), "The thing is."), Mode::Reflect);
70 assert!(out.contains("## 2026-06-06"));
71 assert!(out.contains("<!-- raw: um the thing is -->"));
72 assert!(out.contains("The thing is."));
73 }
74
75 #[test]
76 fn reflect_repeat_date_nests_a_timestamped_subsection() {
77 let body = "\n## 2026-06-06\nFirst.\n";
78 let out = append(body, &turn("2026-06-06", "20:15", None, "Second."), Mode::Reflect);
79 assert!(out.contains("### 20:15"));
80 assert_eq!(out.matches("## 2026-06-06").count(), 1);
81 }
82
83 #[test]
84 fn journal_sections_are_time_keyed_with_a_divider() {
85 let out = append("", &turn("2026-06-08", "08:14", None, "Morning."), Mode::Journal);
86 assert!(out.contains("## 08:14") && !out.contains("## 2026-06-08"));
87 assert!(!out.contains("---"), "the first entry of the day has no leading divider");
88 let out2 = append(&out, &turn("2026-06-08", "21:30", None, "Night."), Mode::Journal);
89 assert!(out2.contains("## 08:14") && out2.contains("## 21:30"));
90 assert!(out2.contains("Morning.\n\n---\n\n## 21:30"), "{out2:?}");
93 }
94
95 #[test]
96 fn raw_comment_is_separated_from_clean_by_a_blank_line() {
97 let out = append("", &turn("2026-06-08", "08:14", Some("um the thing"), "The thing."), Mode::Journal);
98 assert!(out.contains("-->\n\nThe thing."), "{out:?}");
99 }
100
101 #[test]
102 fn raw_none_omits_the_comment() {
103 let out = append("", &turn("2026-06-06", "08:14", None, "Clean only."), Mode::Reflect);
104 assert!(!out.contains("<!-- raw"));
105 }
106
107 #[test]
108 fn comment_breakout_chars_are_neutralized() {
109 let out = append("", &turn("2026-06-06", "08:14", Some("end --> <!-- x"), "y"), Mode::Reflect);
110 assert_eq!(out.matches("-->").count(), 1); assert!(!out.contains("<!-- x"));
112 }
113}