Skip to main content

tj_core/
frontmatter.rs

1//! Pure renderer: a task's settled knowledge → a Claude-memory frontmatter file.
2//! One-directional (Task-Journal → Claude memory). No fs, no DB, no JSONL.
3
4/// A task's settled knowledge, pre-fetched by the caller (CLI).
5pub struct MemoryInput<'a> {
6    pub title: &'a str,
7    pub meta: &'a crate::db::TaskMetadata,
8    pub decisions: &'a [String],
9    pub constraints: &'a [String],
10}
11
12/// Kebab-case slug: lowercase, non-alphanumeric runs → single `-`, trimmed.
13pub fn slugify(title: &str) -> String {
14    let mut out = String::new();
15    let mut prev_dash = true; // suppress leading dash
16    for c in title.chars() {
17        if c.is_alphanumeric() {
18            out.extend(c.to_lowercase());
19            prev_dash = false;
20        } else if !prev_dash {
21            out.push('-');
22            prev_dash = true;
23        }
24    }
25    while out.ends_with('-') {
26        out.pop();
27    }
28    if out.is_empty() {
29        out.push_str("task");
30    }
31    out
32}
33
34/// One safe YAML double-quoted scalar: collapse whitespace/newlines to single
35/// spaces, escape `\` and `"`.
36fn yaml_quote(s: &str) -> String {
37    let collapsed = s.split_whitespace().collect::<Vec<_>>().join(" ");
38    let escaped = collapsed.replace('\\', "\\\\").replace('"', "\\\"");
39    format!("\"{escaped}\"")
40}
41
42/// Render frontmatter + body. Empty sections are omitted.
43pub fn render_memory(input: &MemoryInput<'_>) -> String {
44    let title = input.title;
45    let goal = input.meta.goal.as_deref().filter(|s| !s.is_empty());
46    let description = goal.unwrap_or(title);
47
48    let mut s = String::new();
49    s.push_str("---\n");
50    s.push_str(&format!("name: {}\n", slugify(title)));
51    s.push_str(&format!("description: {}\n", yaml_quote(description)));
52    s.push_str("metadata:\n  type: project\n");
53    s.push_str("---\n");
54
55    s.push_str(&format!("# {title}\n\n"));
56    s.push_str(&format!("**Goal:** {}\n", goal.unwrap_or("(not set)")));
57    if let Some(o) = input.meta.outcome.as_deref().filter(|s| !s.is_empty()) {
58        s.push_str(&format!("**Outcome:** {o}\n"));
59    }
60
61    if !input.decisions.is_empty() {
62        s.push_str("\n## Key decisions\n");
63        for d in input.decisions {
64            s.push_str(&format!("- {d}\n"));
65        }
66    }
67    if !input.constraints.is_empty() {
68        s.push_str("\n## Constraints\n");
69        for c in input.constraints {
70            s.push_str(&format!("- {c}\n"));
71        }
72    }
73    s
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn slugify_kebabs_and_trims() {
82        assert_eq!(slugify("Add Close Gate!"), "add-close-gate");
83        assert_eq!(slugify("  Foo: Bar  "), "foo-bar");
84    }
85
86    #[test]
87    fn render_has_frontmatter_block_with_type_project() {
88        let meta = crate::db::TaskMetadata {
89            goal: Some("Ship X".into()),
90            ..Default::default()
91        };
92        let input = MemoryInput {
93            title: "Ship X",
94            meta: &meta,
95            decisions: &[],
96            constraints: &[],
97        };
98        let out = render_memory(&input);
99        assert!(out.starts_with("---\n"));
100        assert!(out.contains("name: ship-x"));
101        assert!(out.contains("metadata:\n  type: project"));
102        assert!(out.contains("\n---\n")); // closing fence
103    }
104
105    #[test]
106    fn render_quotes_and_escapes_description() {
107        let meta = crate::db::TaskMetadata {
108            goal: Some("fix: a\nb \"q\"".into()),
109            ..Default::default()
110        };
111        let input = MemoryInput {
112            title: "T",
113            meta: &meta,
114            decisions: &[],
115            constraints: &[],
116        };
117        let out = render_memory(&input);
118        // description is one quoted scalar: newline collapsed, quotes escaped.
119        assert!(out.contains(r#"description: "fix: a b \"q\"""#));
120        // no raw newline inside the frontmatter description value
121        let fm = out.split("\n---\n").next().unwrap();
122        assert!(!fm.contains("fix: a\nb"));
123    }
124
125    #[test]
126    fn render_omits_empty_sections_and_includes_filled_ones() {
127        let meta = crate::db::TaskMetadata {
128            goal: Some("G".into()),
129            outcome: Some("O".into()),
130            ..Default::default()
131        };
132        let input = MemoryInput {
133            title: "T",
134            meta: &meta,
135            decisions: &["chose A".to_string()],
136            constraints: &[],
137        };
138        let out = render_memory(&input);
139        assert!(out.contains("## Key decisions"));
140        assert!(out.contains("- chose A"));
141        assert!(!out.contains("## Constraints"));
142        assert!(out.contains("**Outcome:**"));
143        assert!(out.contains("O"));
144    }
145}