/// Calculate word count from text content. pub fn word_count(text: &str) -> u32 { text.split_whitespace().count() as u32 } /// Calculate estimated reading time in minutes. /// Assumes average reading speed of 200 words per minute. pub fn reading_time_minutes(word_count: u32) -> u32 { const WORDS_PER_MINUTE: u32 = 200; word_count.div_ceil(WORDS_PER_MINUTE) } /// Extract the title from the first `# Heading` line in markdown. pub fn extract_title(markdown: &str) -> Option { for line in markdown.lines() { let trimmed = line.trim(); if let Some(title) = trimmed.strip_prefix("# ") { return Some(title.trim().to_string()); } if !trimmed.is_empty() && trimmed != "---" { break; } } None } /// Strip the first `# Heading` line so templates can render it separately. pub fn strip_first_heading(markdown: &str) -> String { let mut found = false; markdown .lines() .filter(|line| { if !found { let t = line.trim(); if t.starts_with("# ") && !t.starts_with("## ") { found = true; return false; } } true }) .collect::>() .join("\n") } #[cfg(test)] mod tests { use super::*; #[test] fn word_count_basic() { assert_eq!(word_count("Hello world"), 2); assert_eq!(word_count("One two three four five"), 5); assert_eq!(word_count(""), 0); } #[test] fn word_count_whitespace() { assert_eq!(word_count(" spaced out "), 2); assert_eq!(word_count("\ttabbed\nlines"), 2); } #[test] fn reading_time_basic() { assert_eq!(reading_time_minutes(200), 1); assert_eq!(reading_time_minutes(400), 2); assert_eq!(reading_time_minutes(250), 2); assert_eq!(reading_time_minutes(0), 0); } #[test] fn reading_time_rounds_up() { assert_eq!(reading_time_minutes(1), 1); assert_eq!(reading_time_minutes(201), 2); } #[test] fn extract_title_basic() { assert_eq!( extract_title("# Hello World\n\nBody"), Some("Hello World".to_string()) ); } #[test] fn extract_title_with_leading_blank_lines() { assert_eq!(extract_title("\n# Title\n"), Some("Title".to_string())); } #[test] fn extract_title_none_when_missing() { assert_eq!(extract_title("No heading here"), None); } #[test] fn extract_title_ignores_h2() { assert_eq!(extract_title("## Not H1"), None); } #[test] fn extract_title_skips_horizontal_rules() { assert_eq!( extract_title("---\n# After Rule"), Some("After Rule".to_string()) ); } #[test] fn strip_first_heading_removes_h1() { let md = "# Title\n\nBody text\n## Subheading"; let stripped = strip_first_heading(md); assert!(!stripped.contains("# Title")); assert!(stripped.contains("Body text")); assert!(stripped.contains("## Subheading")); } #[test] fn strip_first_heading_only_removes_first() { let md = "# First\n\n# Second"; let stripped = strip_first_heading(md); assert!(!stripped.contains("# First")); assert!(stripped.contains("# Second")); } #[test] fn strip_first_heading_no_h1() { let md = "## Only H2\n\nBody"; let stripped = strip_first_heading(md); assert_eq!(stripped, md); } }