Skip to main content

talk_core/
entry.rs

1/// One recorded turn, already cleaned by `cleanup`.
2pub struct Entry<'a> {
3    pub date: &'a str, // YYYY-MM-DD
4    pub time: &'a str, // HH:MM
5    pub raw: Option<&'a str>, // None when keep_raw = false or ephemeral
6    pub clean: &'a str,
7}
8
9/// Reflect files hold many dates in one question-file, so their sections are
10/// dates (`## 2026-06-06`); journal files are one-per-day, so their sections are
11/// times (`## 08:14`) per spec §8.
12#[derive(Clone, Copy, PartialEq, Eq)]
13pub enum Mode { Reflect, Journal }
14
15/// Append `entry` to `body` (the part after frontmatter), returning the new body.
16/// - Reflect: a new date appends `## DATE`; a repeat date nests `### HH:MM`.
17/// - Journal: every turn appends `## HH:MM` (the file is already one date); a `---`
18///   rule separates same-day entries. The blank line BEFORE the rule is required —
19///   `---` directly under text is a setext heading underline, not a divider.
20pub 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        // Blank line between the verbatim comment and the clean text, so the formatted
41        // entry reads as its own block (and isn't crowded against the raw in source view).
42        Some(raw) => format!("<!-- raw: {} -->\n\n{}\n", sanitize_comment(raw), entry.clean),
43        None => format!("{}\n", entry.clean),
44    }
45}
46
47/// Neutralize anything that could break out of the HTML comment: the `--`
48/// digraph (which terminates/malforms comments) and `<` (a nested `<!--`).
49fn sanitize_comment(raw: &str) -> String {
50    raw.replace('<', "&lt;")
51        .replace("--", "&#45;&#45;")
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        // a `---` rule separates same-day entries, with the blank line above it that
91        // markdown needs (else `---` underlines the line above into a heading).
92        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); // only the comment's own terminator
111        assert!(!out.contains("<!-- x"));
112    }
113}