1pub 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
12pub fn slugify(title: &str) -> String {
14 let mut out = String::new();
15 let mut prev_dash = true; 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
34fn 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
42pub 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")); }
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 assert!(out.contains(r#"description: "fix: a b \"q\"""#));
120 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}