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).
18pub fn append(body: &str, entry: &Entry, mode: Mode) -> String {
19    let block = render_turn(entry);
20    match mode {
21        Mode::Journal => join(body, &format!("\n## {}\n{}", entry.time, block)),
22        Mode::Reflect => {
23            let date_header = format!("## {}", entry.date);
24            if body.contains(&date_header) {
25                join(body, &format!("\n### {}\n{}", entry.time, block))
26            } else {
27                join(body, &format!("\n{}\n{}", date_header, block))
28            }
29        }
30    }
31}
32
33fn render_turn(entry: &Entry) -> String {
34    match entry.raw {
35        Some(raw) => format!("<!-- raw: {} -->\n{}\n", sanitize_comment(raw), entry.clean),
36        None => format!("{}\n", entry.clean),
37    }
38}
39
40/// Neutralize anything that could break out of the HTML comment: the `--`
41/// digraph (which terminates/malforms comments) and `<` (a nested `<!--`).
42fn sanitize_comment(raw: &str) -> String {
43    raw.replace('<', "&lt;")
44        .replace("--", "&#45;&#45;")
45        .replace('\n', " ")
46}
47
48fn join(body: &str, section: &str) -> String {
49    format!("{}\n{}", body.trim_end_matches('\n'), section.trim_start_matches('\n'))
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    fn turn<'a>(date: &'a str, time: &'a str, raw: Option<&'a str>, clean: &'a str) -> Entry<'a> {
57        Entry { date, time, raw, clean }
58    }
59
60    #[test]
61    fn reflect_new_date_appends_a_dated_section() {
62        let out = append("", &turn("2026-06-06", "08:14", Some("um the thing is"), "The thing is."), Mode::Reflect);
63        assert!(out.contains("## 2026-06-06"));
64        assert!(out.contains("<!-- raw: um the thing is -->"));
65        assert!(out.contains("The thing is."));
66    }
67
68    #[test]
69    fn reflect_repeat_date_nests_a_timestamped_subsection() {
70        let body = "\n## 2026-06-06\nFirst.\n";
71        let out = append(body, &turn("2026-06-06", "20:15", None, "Second."), Mode::Reflect);
72        assert!(out.contains("### 20:15"));
73        assert_eq!(out.matches("## 2026-06-06").count(), 1);
74    }
75
76    #[test]
77    fn journal_sections_are_time_keyed() {
78        let out = append("", &turn("2026-06-08", "08:14", None, "Morning."), Mode::Journal);
79        assert!(out.contains("## 08:14") && !out.contains("## 2026-06-08"));
80        let out2 = append(&out, &turn("2026-06-08", "21:30", None, "Night."), Mode::Journal);
81        assert!(out2.contains("## 08:14") && out2.contains("## 21:30"));
82    }
83
84    #[test]
85    fn raw_none_omits_the_comment() {
86        let out = append("", &turn("2026-06-06", "08:14", None, "Clean only."), Mode::Reflect);
87        assert!(!out.contains("<!-- raw"));
88    }
89
90    #[test]
91    fn comment_breakout_chars_are_neutralized() {
92        let out = append("", &turn("2026-06-06", "08:14", Some("end --> <!-- x"), "y"), Mode::Reflect);
93        assert_eq!(out.matches("-->").count(), 1); // only the comment's own terminator
94        assert!(!out.contains("<!-- x"));
95    }
96}