#[derive(Debug, Clone, PartialEq)]
pub struct ParsedDocument {
pub preamble: String,
pub sections: Vec<(String, String)>,
pub footer: Option<String>,
}
impl ParsedDocument {
pub fn empty() -> Self {
Self {
preamble: String::new(),
sections: Vec::new(),
footer: None,
}
}
pub fn to_content(&self) -> String {
let mut result = self.preamble.clone();
for (title, content) in &self.sections {
result.push_str(&format!("## {}\n", title));
if !content.is_empty() {
result.push_str(content);
if !content.ends_with('\n') {
result.push('\n');
}
}
result.push('\n');
}
if let Some(footer) = &self.footer {
result.push_str(footer);
if !footer.ends_with('\n') {
result.push('\n');
}
}
result
}
pub fn section_titles(&self) -> Vec<&str> {
self.sections.iter().map(|(t, _)| t.as_str()).collect()
}
pub fn get_section_mut(&mut self, title: &str) -> Option<&mut String> {
self.sections
.iter_mut()
.find(|(t, _)| t.eq_ignore_ascii_case(title))
.map(|(_, content)| content)
}
pub fn add_section(&mut self, title: String, content: String) {
self.sections.push((title, content));
}
}
pub fn parse_markdown_document(content: &str) -> ParsedDocument {
let mut preamble_lines = Vec::new();
let mut sections: Vec<(String, Vec<String>)> = Vec::new();
let mut footer_lines = Vec::new();
let mut in_footer = false;
let mut current_section_lines: Vec<String> = Vec::new();
let lines: Vec<&str> = content.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if line.trim() == "---" && i + 1 < lines.len() {
let next_line = lines[i + 1];
if next_line.contains("Generated by") || next_line.contains("Template version") {
in_footer = true;
footer_lines.push(line.to_string());
i += 1;
continue;
}
}
if in_footer {
footer_lines.push(line.to_string());
i += 1;
continue;
}
if let Some(title) = line.strip_prefix("## ") {
if let Some((_, content_lines)) = sections.last_mut() {
*content_lines = std::mem::take(&mut current_section_lines);
}
sections.push((title.trim().to_string(), Vec::new()));
} else if sections.is_empty() {
preamble_lines.push(line.to_string());
} else {
current_section_lines.push(line.to_string());
}
i += 1;
}
if let Some((_, content_lines)) = sections.last_mut() {
*content_lines = current_section_lines;
}
let sections: Vec<(String, String)> = sections
.into_iter()
.map(|(title, lines)| {
let content = lines.join("\n");
let content = content.trim_end().to_string();
(title, content)
})
.collect();
let preamble = preamble_lines.join("\n");
let preamble = preamble.trim_end().to_string();
let footer = if footer_lines.is_empty() {
None
} else {
Some(footer_lines.join("\n"))
};
ParsedDocument {
preamble,
sections,
footer,
}
}
pub fn merge_section_updates(
existing: &ParsedDocument,
updates: &[(String, String)],
) -> (ParsedDocument, Vec<String>) {
let mut result = existing.clone();
let mut sections_updated = Vec::new();
for (section_name, new_content) in updates {
if let Some(existing_content) = result.get_section_mut(section_name) {
if !existing_content.is_empty() && !existing_content.ends_with('\n') {
existing_content.push('\n');
}
existing_content.push_str(new_content);
sections_updated.push(section_name.clone());
} else {
result.add_section(section_name.clone(), new_content.clone());
sections_updated.push(section_name.clone());
}
}
(result, sections_updated)
}
pub fn parse_markdown_sections(content: &str) -> Vec<(String, String)> {
let doc = parse_markdown_document(content);
doc.sections
}
pub fn extract_section_titles(content: &str) -> Vec<String> {
let doc = parse_markdown_document(content);
doc.section_titles().into_iter().map(String::from).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_empty_content() {
let doc = parse_markdown_document("");
assert!(doc.preamble.is_empty());
assert!(doc.sections.is_empty());
assert!(doc.footer.is_none());
}
#[test]
fn parse_preamble_only() {
let content = "# Title\n\nSome description here.";
let doc = parse_markdown_document(content);
assert_eq!(doc.preamble, "# Title\n\nSome description here.");
assert!(doc.sections.is_empty());
}
#[test]
fn parse_sections() {
let content = r#"# Title
## Section One
Content one.
More content.
## Section Two
Content two.
"#;
let doc = parse_markdown_document(content);
assert_eq!(doc.preamble, "# Title");
assert_eq!(doc.sections.len(), 2);
assert_eq!(doc.sections[0].0, "Section One");
assert!(doc.sections[0].1.contains("Content one."));
assert_eq!(doc.sections[1].0, "Section Two");
}
#[test]
fn parse_with_footer() {
let content = r#"# Title
## Section One
Content.
---
*Generated by Ralph v1.0.0*
*Template version: 1*
"#;
let doc = parse_markdown_document(content);
assert_eq!(doc.sections.len(), 1);
assert!(doc.footer.is_some());
let footer = doc.footer.unwrap();
assert!(footer.contains("Generated by Ralph"));
assert!(footer.contains("Template version"));
}
#[test]
fn merge_updates_existing_section() {
let existing = parse_markdown_document(
r#"# Title
## Section One
Original content.
"#,
);
let updates = vec![("Section One".to_string(), "Appended content.".to_string())];
let (merged, updated) = merge_section_updates(&existing, &updates);
assert_eq!(updated, vec!["Section One"]);
assert!(merged.sections[0].1.contains("Original content."));
assert!(merged.sections[0].1.contains("Appended content."));
}
#[test]
fn merge_creates_new_section() {
let existing = parse_markdown_document(
r#"# Title
## Section One
Content.
"#,
);
let updates = vec![("Section Two".to_string(), "New content.".to_string())];
let (merged, updated) = merge_section_updates(&existing, &updates);
assert_eq!(updated, vec!["Section Two"]);
assert_eq!(merged.sections.len(), 2);
assert_eq!(merged.sections[1].0, "Section Two");
assert!(merged.sections[1].1.contains("New content."));
}
#[test]
fn merge_preserves_footer() {
let existing = parse_markdown_document(
r#"# Title
## Section One
Content.
---
*Generated by Ralph v1.0.0*
"#,
);
let updates = vec![("Section One".to_string(), "More content.".to_string())];
let (merged, _) = merge_section_updates(&existing, &updates);
assert!(merged.footer.is_some());
assert!(merged.footer.unwrap().contains("Generated by Ralph"));
}
#[test]
fn to_content_reconstructs_document() {
let original = r#"# Title
## Section One
Content one.
## Section Two
Content two.
---
*Generated by Ralph*
"#;
let doc = parse_markdown_document(original);
let reconstructed = doc.to_content();
assert!(reconstructed.contains("# Title"));
assert!(reconstructed.contains("## Section One"));
assert!(reconstructed.contains("Content one."));
assert!(reconstructed.contains("## Section Two"));
assert!(reconstructed.contains("---"));
assert!(reconstructed.contains("Generated by Ralph"));
}
#[test]
fn extract_section_titles_finds_all_sections() {
let content = r#"# Title
## Section One
Content one.
## Section Two
Content two.
### Subsection
More content.
"#;
let titles = extract_section_titles(content);
assert_eq!(titles, vec!["Section One", "Section Two"]);
}
#[test]
fn merge_multiple_updates() {
let existing = parse_markdown_document(
r#"# Title
## Section One
Content one.
"#,
);
let updates = vec![
("Section One".to_string(), "Updated one.".to_string()),
(
"Section Two".to_string(),
"New section content.".to_string(),
),
];
let (merged, updated) = merge_section_updates(&existing, &updates);
assert_eq!(updated.len(), 2);
assert!(updated.contains(&"Section One".to_string()));
assert!(updated.contains(&"Section Two".to_string()));
assert_eq!(merged.sections.len(), 2);
}
}