//! Markdown conversion utilities for BodyElement arrays. //! //! Provides bidirectional conversion between structured BodyElement[] and markdown strings. use crate::db::models::BodyElement; /// Convert a slice of BodyElements to a markdown string. /// /// Handles: /// - Headings: `# heading` through `###### heading` based on level /// - Paragraphs: plain text with blank lines between /// - Code blocks: ````language\ncontent\n```` /// - Lists: ordered (1. 2. 3.) and unordered (- - -) /// - Charts: rendered as fenced JSON with chart type /// - Images: rendered as markdown image syntax pub fn body_to_markdown(elements: &[BodyElement]) -> String { elements .iter() .filter_map(|elem| match elem { BodyElement::Heading { level, text } => { let hashes = "#".repeat((*level).min(6) as usize); Some(format!("{} {}", hashes, text)) } BodyElement::Paragraph { text } => Some(text.clone()), BodyElement::Code { language, content } => { let lang = language.as_deref().unwrap_or(""); Some(format!("```{}\n{}\n```", lang, content)) } BodyElement::List { ordered, items } => { let list: Vec = items .iter() .enumerate() .map(|(i, item)| { if *ordered { format!("{}. {}", i + 1, item) } else { format!("- {}", item) } }) .collect(); Some(list.join("\n")) } BodyElement::Chart { chart_type, title, data, config: _, } => { // Render chart as a fenced block with metadata let title_str = title .as_ref() .map(|t| format!(" - {}", t)) .unwrap_or_default(); let data_str = serde_json::to_string_pretty(data).unwrap_or_default(); Some(format!( "```chart:{:?}{}\n{}\n```", chart_type, title_str, data_str )) } BodyElement::Image { src, alt, caption } => { let alt_text = alt.as_deref().unwrap_or("image"); let caption_str = caption .as_ref() .map(|c| format!("\n*{}*", c)) .unwrap_or_default(); Some(format!("![{}]({}){}", alt_text, src, caption_str)) } // Markdown elements output their content directly - it's already markdown BodyElement::Markdown { content } => Some(content.clone()), }) .collect::>() .join("\n\n") } /// Parse a markdown string into a vector of BodyElements. /// /// Handles: /// - Headings: lines starting with # through ###### /// - Code blocks: ````language ... ```` /// - Ordered lists: lines starting with 1. 2. etc. /// - Unordered lists: lines starting with - or * /// - Paragraphs: all other non-empty lines pub fn markdown_to_body(markdown: &str) -> Vec { let mut elements = Vec::new(); let lines: Vec<&str> = markdown.lines().collect(); let mut i = 0; while i < lines.len() { let line = lines[i]; let trimmed = line.trim(); // Skip empty lines if trimmed.is_empty() { i += 1; continue; } // Check for code blocks if trimmed.starts_with("```") { let language = trimmed.trim_start_matches('`').trim(); let language = if language.is_empty() { None } else { Some(language.to_string()) }; let mut content_lines = Vec::new(); i += 1; // Collect content until closing ``` while i < lines.len() && !lines[i].trim().starts_with("```") { content_lines.push(lines[i]); i += 1; } // Skip the closing ``` if i < lines.len() { i += 1; } elements.push(BodyElement::Code { language, content: content_lines.join("\n"), }); continue; } // Check for headings if trimmed.starts_with('#') { let level = trimmed.chars().take_while(|&c| c == '#').count() as u8; let text = trimmed.trim_start_matches('#').trim().to_string(); elements.push(BodyElement::Heading { level, text }); i += 1; continue; } // Check for unordered lists (- or *) if trimmed.starts_with("- ") || trimmed.starts_with("* ") { let mut items = Vec::new(); while i < lines.len() { let current = lines[i].trim(); if current.starts_with("- ") || current.starts_with("* ") { items.push(current[2..].to_string()); i += 1; } else if current.is_empty() { i += 1; break; } else { break; } } elements.push(BodyElement::List { ordered: false, items, }); continue; } // Check for ordered lists (1. 2. etc.) if let Some(rest) = try_parse_ordered_list_item(trimmed) { let mut items = Vec::new(); items.push(rest.to_string()); i += 1; while i < lines.len() { let current = lines[i].trim(); if let Some(item_rest) = try_parse_ordered_list_item(current) { items.push(item_rest.to_string()); i += 1; } else if current.is_empty() { i += 1; break; } else { break; } } elements.push(BodyElement::List { ordered: true, items, }); continue; } // Default: paragraph (collect consecutive non-empty lines) let mut para_lines = Vec::new(); while i < lines.len() { let current = lines[i].trim(); if current.is_empty() || current.starts_with('#') || current.starts_with("```") || current.starts_with("- ") || current.starts_with("* ") || try_parse_ordered_list_item(current).is_some() { break; } para_lines.push(current); i += 1; } if !para_lines.is_empty() { elements.push(BodyElement::Paragraph { text: para_lines.join(" "), }); } } elements } /// Try to parse an ordered list item (e.g., "1. Item text") /// Returns the text after the number and period, or None if not a list item. fn try_parse_ordered_list_item(s: &str) -> Option<&str> { let mut chars = s.char_indices(); // Must start with a digit let (_, first) = chars.next()?; if !first.is_ascii_digit() { return None; } // Consume remaining digits let mut last_digit_end = 1; for (idx, c) in chars.by_ref() { if c.is_ascii_digit() { last_digit_end = idx + 1; } else if c == '.' { // Found the period - check for space after let rest = &s[last_digit_end + 1..]; let rest = rest.trim_start(); if !rest.is_empty() || s.ends_with(". ") { return Some(rest); } return None; } else { return None; } } None } #[cfg(test)] mod tests { use super::*; #[test] fn test_body_to_markdown_heading() { let elements = vec![BodyElement::Heading { level: 2, text: "Hello World".to_string(), }]; assert_eq!(body_to_markdown(&elements), "## Hello World"); } #[test] fn test_body_to_markdown_paragraph() { let elements = vec![BodyElement::Paragraph { text: "This is a paragraph.".to_string(), }]; assert_eq!(body_to_markdown(&elements), "This is a paragraph."); } #[test] fn test_body_to_markdown_code() { let elements = vec![BodyElement::Code { language: Some("rust".to_string()), content: "fn main() {}".to_string(), }]; assert_eq!( body_to_markdown(&elements), "```rust\nfn main() {}\n```" ); } #[test] fn test_body_to_markdown_list() { let elements = vec![BodyElement::List { ordered: false, items: vec!["Item 1".to_string(), "Item 2".to_string()], }]; assert_eq!(body_to_markdown(&elements), "- Item 1\n- Item 2"); } #[test] fn test_markdown_to_body_heading() { let md = "## Hello World"; let elements = markdown_to_body(md); assert_eq!(elements.len(), 1); match &elements[0] { BodyElement::Heading { level, text } => { assert_eq!(*level, 2); assert_eq!(text, "Hello World"); } _ => panic!("Expected Heading"), } } #[test] fn test_markdown_to_body_code() { let md = "```rust\nfn main() {}\n```"; let elements = markdown_to_body(md); assert_eq!(elements.len(), 1); match &elements[0] { BodyElement::Code { language, content } => { assert_eq!(language.as_deref(), Some("rust")); assert_eq!(content, "fn main() {}"); } _ => panic!("Expected Code"), } } #[test] fn test_roundtrip() { let original = vec![ BodyElement::Heading { level: 1, text: "Title".to_string(), }, BodyElement::Paragraph { text: "Some text here.".to_string(), }, BodyElement::List { ordered: false, items: vec!["A".to_string(), "B".to_string()], }, ]; let markdown = body_to_markdown(&original); let parsed = markdown_to_body(&markdown); assert_eq!(parsed.len(), 3); } }