ralph/commands/context/
merge.rs1#[derive(Debug, Clone, PartialEq)]
19pub struct ParsedDocument {
20 pub preamble: String,
22 pub sections: Vec<(String, String)>,
24 pub footer: Option<String>,
26}
27
28impl ParsedDocument {
29 pub fn empty() -> Self {
31 Self {
32 preamble: String::new(),
33 sections: Vec::new(),
34 footer: None,
35 }
36 }
37
38 pub fn to_content(&self) -> String {
40 let mut result = self.preamble.clone();
41
42 for (title, content) in &self.sections {
43 result.push_str(&format!("## {}\n", title));
44 if !content.is_empty() {
46 result.push_str(content);
47 if !content.ends_with('\n') {
48 result.push('\n');
49 }
50 }
51 result.push('\n');
53 }
54
55 if let Some(footer) = &self.footer {
56 result.push_str(footer);
57 if !footer.ends_with('\n') {
58 result.push('\n');
59 }
60 }
61
62 result
63 }
64
65 pub fn section_titles(&self) -> Vec<&str> {
67 self.sections.iter().map(|(t, _)| t.as_str()).collect()
68 }
69
70 pub fn get_section_mut(&mut self, title: &str) -> Option<&mut String> {
72 self.sections
73 .iter_mut()
74 .find(|(t, _)| t.eq_ignore_ascii_case(title))
75 .map(|(_, content)| content)
76 }
77
78 pub fn add_section(&mut self, title: String, content: String) {
80 self.sections.push((title, content));
81 }
82}
83
84pub fn parse_markdown_document(content: &str) -> ParsedDocument {
91 let mut preamble_lines = Vec::new();
92 let mut sections: Vec<(String, Vec<String>)> = Vec::new();
93 let mut footer_lines = Vec::new();
94 let mut in_footer = false;
95 let mut current_section_lines: Vec<String> = Vec::new();
96
97 let lines: Vec<&str> = content.lines().collect();
98 let mut i = 0;
99
100 while i < lines.len() {
101 let line = lines[i];
102
103 if line.trim() == "---" && i + 1 < lines.len() {
105 let next_line = lines[i + 1];
106 if next_line.contains("Generated by") || next_line.contains("Template version") {
107 in_footer = true;
108 footer_lines.push(line.to_string());
109 i += 1;
110 continue;
111 }
112 }
113
114 if in_footer {
115 footer_lines.push(line.to_string());
116 i += 1;
117 continue;
118 }
119
120 if let Some(title) = line.strip_prefix("## ") {
122 if let Some((_, content_lines)) = sections.last_mut() {
124 *content_lines = std::mem::take(&mut current_section_lines);
125 }
126 sections.push((title.trim().to_string(), Vec::new()));
128 } else if sections.is_empty() {
129 preamble_lines.push(line.to_string());
131 } else {
132 current_section_lines.push(line.to_string());
134 }
135
136 i += 1;
137 }
138
139 if let Some((_, content_lines)) = sections.last_mut() {
141 *content_lines = current_section_lines;
142 }
143
144 let sections: Vec<(String, String)> = sections
146 .into_iter()
147 .map(|(title, lines)| {
148 let content = lines.join("\n");
149 let content = content.trim_end().to_string();
151 (title, content)
152 })
153 .collect();
154
155 let preamble = preamble_lines.join("\n");
156 let preamble = preamble.trim_end().to_string();
157
158 let footer = if footer_lines.is_empty() {
159 None
160 } else {
161 Some(footer_lines.join("\n"))
162 };
163
164 ParsedDocument {
165 preamble,
166 sections,
167 footer,
168 }
169}
170
171pub fn merge_section_updates(
179 existing: &ParsedDocument,
180 updates: &[(String, String)],
181) -> (ParsedDocument, Vec<String>) {
182 let mut result = existing.clone();
183 let mut sections_updated = Vec::new();
184
185 for (section_name, new_content) in updates {
186 if let Some(existing_content) = result.get_section_mut(section_name) {
187 if !existing_content.is_empty() && !existing_content.ends_with('\n') {
189 existing_content.push('\n');
190 }
191 existing_content.push_str(new_content);
192 sections_updated.push(section_name.clone());
193 } else {
194 result.add_section(section_name.clone(), new_content.clone());
196 sections_updated.push(section_name.clone());
197 }
198 }
199
200 (result, sections_updated)
201}
202
203pub fn parse_markdown_sections(content: &str) -> Vec<(String, String)> {
206 let doc = parse_markdown_document(content);
207 doc.sections
208}
209
210pub fn extract_section_titles(content: &str) -> Vec<String> {
212 let doc = parse_markdown_document(content);
213 doc.section_titles().into_iter().map(String::from).collect()
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn parse_empty_content() {
222 let doc = parse_markdown_document("");
223 assert!(doc.preamble.is_empty());
224 assert!(doc.sections.is_empty());
225 assert!(doc.footer.is_none());
226 }
227
228 #[test]
229 fn parse_preamble_only() {
230 let content = "# Title\n\nSome description here.";
231 let doc = parse_markdown_document(content);
232 assert_eq!(doc.preamble, "# Title\n\nSome description here.");
233 assert!(doc.sections.is_empty());
234 }
235
236 #[test]
237 fn parse_sections() {
238 let content = r#"# Title
239
240## Section One
241
242Content one.
243
244More content.
245
246## Section Two
247
248Content two.
249"#;
250 let doc = parse_markdown_document(content);
251 assert_eq!(doc.preamble, "# Title");
252 assert_eq!(doc.sections.len(), 2);
253 assert_eq!(doc.sections[0].0, "Section One");
254 assert!(doc.sections[0].1.contains("Content one."));
255 assert_eq!(doc.sections[1].0, "Section Two");
256 }
257
258 #[test]
259 fn parse_with_footer() {
260 let content = r#"# Title
261
262## Section One
263
264Content.
265
266---
267*Generated by Ralph v1.0.0*
268*Template version: 1*
269"#;
270 let doc = parse_markdown_document(content);
271 assert_eq!(doc.sections.len(), 1);
272 assert!(doc.footer.is_some());
273 let footer = doc.footer.unwrap();
274 assert!(footer.contains("Generated by Ralph"));
275 assert!(footer.contains("Template version"));
276 }
277
278 #[test]
279 fn merge_updates_existing_section() {
280 let existing = parse_markdown_document(
281 r#"# Title
282
283## Section One
284
285Original content.
286"#,
287 );
288 let updates = vec![("Section One".to_string(), "Appended content.".to_string())];
289
290 let (merged, updated) = merge_section_updates(&existing, &updates);
291
292 assert_eq!(updated, vec!["Section One"]);
293 assert!(merged.sections[0].1.contains("Original content."));
294 assert!(merged.sections[0].1.contains("Appended content."));
295 }
296
297 #[test]
298 fn merge_creates_new_section() {
299 let existing = parse_markdown_document(
300 r#"# Title
301
302## Section One
303
304Content.
305"#,
306 );
307 let updates = vec![("Section Two".to_string(), "New content.".to_string())];
308
309 let (merged, updated) = merge_section_updates(&existing, &updates);
310
311 assert_eq!(updated, vec!["Section Two"]);
312 assert_eq!(merged.sections.len(), 2);
313 assert_eq!(merged.sections[1].0, "Section Two");
314 assert!(merged.sections[1].1.contains("New content."));
315 }
316
317 #[test]
318 fn merge_preserves_footer() {
319 let existing = parse_markdown_document(
320 r#"# Title
321
322## Section One
323
324Content.
325
326---
327*Generated by Ralph v1.0.0*
328"#,
329 );
330 let updates = vec![("Section One".to_string(), "More content.".to_string())];
331
332 let (merged, _) = merge_section_updates(&existing, &updates);
333
334 assert!(merged.footer.is_some());
335 assert!(merged.footer.unwrap().contains("Generated by Ralph"));
336 }
337
338 #[test]
339 fn to_content_reconstructs_document() {
340 let original = r#"# Title
341
342## Section One
343
344Content one.
345
346## Section Two
347
348Content two.
349
350---
351*Generated by Ralph*
352"#;
353 let doc = parse_markdown_document(original);
354 let reconstructed = doc.to_content();
355
356 assert!(reconstructed.contains("# Title"));
358 assert!(reconstructed.contains("## Section One"));
359 assert!(reconstructed.contains("Content one."));
360 assert!(reconstructed.contains("## Section Two"));
361 assert!(reconstructed.contains("---"));
362 assert!(reconstructed.contains("Generated by Ralph"));
363 }
364
365 #[test]
366 fn extract_section_titles_finds_all_sections() {
367 let content = r#"# Title
368
369## Section One
370
371Content one.
372
373## Section Two
374
375Content two.
376
377### Subsection
378
379More content.
380"#;
381 let titles = extract_section_titles(content);
382 assert_eq!(titles, vec!["Section One", "Section Two"]);
383 }
384
385 #[test]
386 fn merge_multiple_updates() {
387 let existing = parse_markdown_document(
388 r#"# Title
389
390## Section One
391
392Content one.
393"#,
394 );
395 let updates = vec![
396 ("Section One".to_string(), "Updated one.".to_string()),
397 (
398 "Section Two".to_string(),
399 "New section content.".to_string(),
400 ),
401 ];
402
403 let (merged, updated) = merge_section_updates(&existing, &updates);
404
405 assert_eq!(updated.len(), 2);
406 assert!(updated.contains(&"Section One".to_string()));
407 assert!(updated.contains(&"Section Two".to_string()));
408 assert_eq!(merged.sections.len(), 2);
409 }
410}