gemini_memory_rs/okf/
document.rs1use std::collections::BTreeMap;
8
9use super::yaml::{self, Yaml};
10use crate::core::MemoryError;
11
12const FENCE: &str = "---";
14
15#[derive(Debug, Clone, PartialEq)]
17pub struct OkfDocument {
18 pub front: Yaml,
20 pub sections: BTreeMap<String, String>,
22 pub section_order: Vec<String>,
24}
25
26impl OkfDocument {
27 pub fn new(front: Yaml, sections: Vec<(&str, String)>) -> Self {
29 let mut order = Vec::new();
30 let mut map = BTreeMap::new();
31 for (heading, body) in sections {
32 order.push(heading.to_string());
33 map.insert(heading.to_string(), body);
34 }
35 Self {
36 front,
37 sections: map,
38 section_order: order,
39 }
40 }
41
42 pub fn section(&self, heading: &str) -> Option<&str> {
44 self.sections.get(heading).map(|s| s.trim())
45 }
46
47 pub fn section_list(&self, heading: &str) -> Vec<String> {
49 self.section(heading)
50 .map(|body| {
51 body.lines()
52 .filter_map(|line| line.trim().strip_prefix("- ").map(str::to_string))
53 .collect()
54 })
55 .unwrap_or_default()
56 }
57
58 pub fn parse(source: &str, path: &str) -> Result<Self, MemoryError> {
60 let source = source.trim_start_matches('\u{feff}');
61 let rest = source
62 .strip_prefix(FENCE)
63 .and_then(|r| r.strip_prefix('\n').or_else(|| r.strip_prefix("\r\n")))
64 .ok_or_else(|| MemoryError::MalformedRecord {
65 path: path.to_string(),
66 message: "file does not begin with a `---` front-matter fence".to_string(),
67 })?;
68
69 let end = rest
70 .find("\n---")
71 .ok_or_else(|| MemoryError::MalformedRecord {
72 path: path.to_string(),
73 message: "front matter is not terminated by `---`".to_string(),
74 })?;
75
76 let front_src = &rest[..end];
77 let body = rest[end + 4..].trim_start_matches(['\r', '\n']);
78
79 let front = yaml::parse(front_src).map_err(|e| MemoryError::MalformedRecord {
80 path: path.to_string(),
81 message: e.to_string(),
82 })?;
83
84 let (sections, section_order) = parse_sections(body);
85 Ok(Self {
86 front,
87 sections,
88 section_order,
89 })
90 }
91
92 pub fn parse_many(source: &str, path: &str) -> Result<Vec<Self>, MemoryError> {
99 let source = source.trim_start_matches('\u{feff}');
100 let lines: Vec<&str> = source.lines().collect();
101 let fences: Vec<usize> = lines
102 .iter()
103 .enumerate()
104 .filter(|(_, l)| l.trim_end() == FENCE)
105 .map(|(i, _)| i)
106 .collect();
107
108 if fences.is_empty() {
109 return if source.trim().is_empty() {
110 Ok(Vec::new())
111 } else {
112 Err(MemoryError::MalformedRecord {
113 path: path.to_string(),
114 message: "file contains no `---` front-matter fence".to_string(),
115 })
116 };
117 }
118 if !fences.len().is_multiple_of(2) {
119 return Err(MemoryError::MalformedRecord {
120 path: path.to_string(),
121 message: format!(
122 "odd number of `---` fences ({}) — a record is unterminated",
123 fences.len()
124 ),
125 });
126 }
127
128 let mut docs = Vec::new();
129 for pair in 0..fences.len() / 2 {
130 let open = fences[pair * 2];
131 let close = fences[pair * 2 + 1];
132 let body_end = fences.get((pair + 1) * 2).copied().unwrap_or(lines.len());
133 let mut chunk = String::new();
134 for line in &lines[open..close.min(body_end)] {
135 chunk.push_str(line);
136 chunk.push('\n');
137 }
138 chunk.push_str(FENCE);
139 chunk.push('\n');
140 for line in &lines[close + 1..body_end] {
141 chunk.push_str(line);
142 chunk.push('\n');
143 }
144 docs.push(Self::parse(&chunk, path)?);
145 }
146 Ok(docs)
147 }
148
149 pub fn render_many(docs: &[Self], path: &str) -> Result<String, MemoryError> {
151 let mut out = String::new();
152 for (idx, doc) in docs.iter().enumerate() {
153 if doc
154 .sections
155 .values()
156 .any(|body| body.lines().any(|l| l.trim_end() == FENCE))
157 {
158 return Err(MemoryError::MalformedRecord {
159 path: path.to_string(),
160 message: "record body contains a `---` line, which would be read as a \
161 record boundary"
162 .to_string(),
163 });
164 }
165 if idx > 0 {
166 out.push('\n');
167 }
168 out.push_str(&doc.to_markdown());
169 }
170 Ok(out)
171 }
172
173 pub fn to_markdown(&self) -> String {
175 let mut out = String::new();
176 out.push_str(FENCE);
177 out.push('\n');
178 out.push_str(&yaml::emit(&self.front));
179 out.push_str(FENCE);
180 out.push('\n');
181 for heading in &self.section_order {
182 if let Some(body) = self.sections.get(heading) {
183 out.push_str("\n# ");
184 out.push_str(heading);
185 out.push('\n');
186 let trimmed = body.trim();
187 if !trimmed.is_empty() {
188 out.push_str(trimmed);
189 out.push('\n');
190 }
191 }
192 }
193 out
194 }
195}
196
197fn parse_sections(body: &str) -> (BTreeMap<String, String>, Vec<String>) {
198 let mut sections = BTreeMap::new();
199 let mut order = Vec::new();
200 let mut current: Option<String> = None;
201 let mut buffer = String::new();
202
203 for line in body.lines() {
204 if let Some(heading) = line.strip_prefix("# ") {
205 if let Some(name) = current.take() {
206 sections.insert(name, buffer.trim().to_string());
207 }
208 buffer = String::new();
209 let heading = heading.trim().to_string();
210 order.push(heading.clone());
211 current = Some(heading);
212 } else if current.is_some() {
213 buffer.push_str(line);
214 buffer.push('\n');
215 }
216 }
217 if let Some(name) = current {
218 sections.insert(name, buffer.trim().to_string());
219 }
220 (sections, order)
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 const SAMPLE: &str = r#"---
228okf: memory/v1
229id: mem_01K4D8P3
230confidence: 1.0
231retrieval:
232 tags:
233 - food
234 - diet
235---
236# Fact
237The user is pescatarian.
238
239# Evidence Summary
240Explicitly stated by the user.
241
242# Supersedes
243- mem_01JVEGETARIAN
244"#;
245
246 #[test]
247 fn parses_front_matter_and_sections() {
248 let doc = OkfDocument::parse(SAMPLE, "sample.md").unwrap();
249 assert_eq!(doc.front.get("id").unwrap().as_str(), Some("mem_01K4D8P3"));
250 assert_eq!(doc.section("Fact"), Some("The user is pescatarian."));
251 assert_eq!(doc.section_list("Supersedes"), vec!["mem_01JVEGETARIAN"]);
252 }
253
254 #[test]
255 fn round_trips_to_markdown() {
256 let doc = OkfDocument::parse(SAMPLE, "sample.md").unwrap();
257 let rendered = doc.to_markdown();
258 let reparsed = OkfDocument::parse(&rendered, "rendered.md").unwrap();
259 assert_eq!(doc.front, reparsed.front);
260 assert_eq!(doc.sections, reparsed.sections);
261 }
262
263 #[test]
264 fn a_missing_fence_names_the_file() {
265 let err = OkfDocument::parse("no front matter", "records/x.md").unwrap_err();
266 match err {
267 MemoryError::MalformedRecord { path, .. } => assert_eq!(path, "records/x.md"),
268 other => panic!("unexpected error: {other}"),
269 }
270 }
271
272 #[test]
273 fn an_unterminated_fence_is_rejected() {
274 assert!(OkfDocument::parse("---\nid: x\n", "x.md").is_err());
275 }
276
277 #[test]
278 fn category_files_hold_several_records() {
279 let first = OkfDocument::parse(SAMPLE, "s.md").unwrap();
280 let second = OkfDocument::new(
281 yaml::parse("okf: memory/v1\nid: mem_two\n").unwrap(),
282 vec![("Fact", "The user drinks flat whites.".to_string())],
283 );
284 let file = OkfDocument::render_many(&[first.clone(), second.clone()], "cat.md").unwrap();
285
286 let parsed = OkfDocument::parse_many(&file, "cat.md").unwrap();
287 assert_eq!(parsed.len(), 2);
288 assert_eq!(parsed[0].front, first.front);
289 assert_eq!(parsed[1].section("Fact"), second.section("Fact"));
290 }
291
292 #[test]
293 fn an_empty_category_file_parses_to_no_records() {
294 assert!(OkfDocument::parse_many("", "cat.md").unwrap().is_empty());
295 assert!(
296 OkfDocument::parse_many("\n\n", "cat.md")
297 .unwrap()
298 .is_empty()
299 );
300 }
301
302 #[test]
303 fn a_body_containing_a_fence_is_refused_at_write_time() {
304 let doc = OkfDocument::new(
305 yaml::parse("okf: memory/v1\n").unwrap(),
306 vec![("Fact", "before\n---\nafter".to_string())],
307 );
308 assert!(OkfDocument::render_many(&[doc], "cat.md").is_err());
309 }
310
311 #[test]
312 fn an_odd_fence_count_names_the_problem() {
313 let err =
314 OkfDocument::parse_many("---\nid: a\n---\n# Fact\nx\n---\n", "cat.md").unwrap_err();
315 assert!(err.to_string().contains("unterminated"));
316 }
317}