Skip to main content

ralph/commands/context/
merge.rs

1//! Markdown section merging utilities for AGENTS.md updates.
2//!
3//! Responsibilities:
4//! - Parse markdown content into structured sections while preserving formatting.
5//! - Merge new content into existing sections, preserving footers and structure.
6//! - Support both interactive and file-based update workflows.
7//!
8//! Not handled here:
9//! - File I/O (callers read/write files).
10//! - User interaction (handled by the wizard module).
11//!
12//! Invariants/assumptions:
13//! - Sections are defined by `## ` headings.
14//! - The footer is detected by `---` followed by "Generated by" or "Template version".
15//! - Section order is preserved; new sections are appended after existing ones.
16
17/// Parsed markdown document with sections.
18#[derive(Debug, Clone, PartialEq)]
19pub struct ParsedDocument {
20    /// Content before the first section heading (preamble).
21    pub preamble: String,
22    /// Sections in order: (title, content).
23    pub sections: Vec<(String, String)>,
24    /// Footer block (typically "Generated by..."), if present.
25    pub footer: Option<String>,
26}
27
28impl ParsedDocument {
29    /// Create an empty parsed document.
30    pub fn empty() -> Self {
31        Self {
32            preamble: String::new(),
33            sections: Vec::new(),
34            footer: None,
35        }
36    }
37
38    /// Reconstruct the full markdown content.
39    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            // Ensure content ends with newline if not empty
45            if !content.is_empty() {
46                result.push_str(content);
47                if !content.ends_with('\n') {
48                    result.push('\n');
49                }
50            }
51            // Add blank line between sections
52            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    /// Get section titles in order.
66    pub fn section_titles(&self) -> Vec<&str> {
67        self.sections.iter().map(|(t, _)| t.as_str()).collect()
68    }
69
70    /// Get mutable reference to a section's content by title.
71    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    /// Add a new section at the end.
79    pub fn add_section(&mut self, title: String, content: String) {
80        self.sections.push((title, content));
81    }
82}
83
84/// Parse markdown content into structured sections.
85///
86/// Recognizes:
87/// - Preamble: everything before the first `## ` heading.
88/// - Sections: content under `## ` headings.
89/// - Footer: content starting with `---` followed by "Generated by" or "Template version".
90pub 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        // Check for footer marker (--- followed by Generated by/Template version)
104        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        // Check for section heading
121        if let Some(title) = line.strip_prefix("## ") {
122            // Save previous section if exists
123            if let Some((_, content_lines)) = sections.last_mut() {
124                *content_lines = std::mem::take(&mut current_section_lines);
125            }
126            // Start new section
127            sections.push((title.trim().to_string(), Vec::new()));
128        } else if sections.is_empty() {
129            // Still in preamble
130            preamble_lines.push(line.to_string());
131        } else {
132            // In section content
133            current_section_lines.push(line.to_string());
134        }
135
136        i += 1;
137    }
138
139    // Save last section's content
140    if let Some((_, content_lines)) = sections.last_mut() {
141        *content_lines = current_section_lines;
142    }
143
144    // Convert section content from Vec<String> to String
145    let sections: Vec<(String, String)> = sections
146        .into_iter()
147        .map(|(title, lines)| {
148            let content = lines.join("\n");
149            // Trim trailing whitespace but preserve internal formatting
150            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
171/// Merge updates into an existing document.
172///
173/// For each update:
174/// - If the section exists, append the new content to it.
175/// - If the section doesn't exist, create it at the end.
176///
177/// Returns the merged document and a list of section names that were updated.
178pub 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            // Section exists - append content
188            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            // Section doesn't exist - create it
195            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
203/// Parse markdown sections from content (legacy compatibility).
204/// Returns vector of (section_name, section_content) pairs.
205pub fn parse_markdown_sections(content: &str) -> Vec<(String, String)> {
206    let doc = parse_markdown_document(content);
207    doc.sections
208}
209
210/// Extract section titles from markdown content (legacy compatibility).
211pub 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        // Check that reconstructed content has all parts
357        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}