| 1 |
|
| 2 |
pub fn word_count(text: &str) -> u32 { |
| 3 |
text.split_whitespace().count() as u32 |
| 4 |
} |
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
pub fn reading_time_minutes(word_count: u32) -> u32 { |
| 9 |
const WORDS_PER_MINUTE: u32 = 200; |
| 10 |
word_count.div_ceil(WORDS_PER_MINUTE) |
| 11 |
} |
| 12 |
|
| 13 |
|
| 14 |
pub fn extract_title(markdown: &str) -> Option<String> { |
| 15 |
for line in markdown.lines() { |
| 16 |
let trimmed = line.trim(); |
| 17 |
if let Some(title) = trimmed.strip_prefix("# ") { |
| 18 |
return Some(title.trim().to_string()); |
| 19 |
} |
| 20 |
if !trimmed.is_empty() && trimmed != "---" { |
| 21 |
break; |
| 22 |
} |
| 23 |
} |
| 24 |
None |
| 25 |
} |
| 26 |
|
| 27 |
|
| 28 |
pub fn strip_first_heading(markdown: &str) -> String { |
| 29 |
let mut found = false; |
| 30 |
markdown |
| 31 |
.lines() |
| 32 |
.filter(|line| { |
| 33 |
if !found { |
| 34 |
let t = line.trim(); |
| 35 |
if t.starts_with("# ") && !t.starts_with("## ") { |
| 36 |
found = true; |
| 37 |
return false; |
| 38 |
} |
| 39 |
} |
| 40 |
true |
| 41 |
}) |
| 42 |
.collect::<Vec<_>>() |
| 43 |
.join("\n") |
| 44 |
} |
| 45 |
|
| 46 |
#[cfg(test)] |
| 47 |
mod tests { |
| 48 |
use super::*; |
| 49 |
|
| 50 |
#[test] |
| 51 |
fn word_count_basic() { |
| 52 |
assert_eq!(word_count("Hello world"), 2); |
| 53 |
assert_eq!(word_count("One two three four five"), 5); |
| 54 |
assert_eq!(word_count(""), 0); |
| 55 |
} |
| 56 |
|
| 57 |
#[test] |
| 58 |
fn word_count_whitespace() { |
| 59 |
assert_eq!(word_count(" spaced out "), 2); |
| 60 |
assert_eq!(word_count("\ttabbed\nlines"), 2); |
| 61 |
} |
| 62 |
|
| 63 |
#[test] |
| 64 |
fn reading_time_basic() { |
| 65 |
assert_eq!(reading_time_minutes(200), 1); |
| 66 |
assert_eq!(reading_time_minutes(400), 2); |
| 67 |
assert_eq!(reading_time_minutes(250), 2); |
| 68 |
assert_eq!(reading_time_minutes(0), 0); |
| 69 |
} |
| 70 |
|
| 71 |
#[test] |
| 72 |
fn reading_time_rounds_up() { |
| 73 |
assert_eq!(reading_time_minutes(1), 1); |
| 74 |
assert_eq!(reading_time_minutes(201), 2); |
| 75 |
} |
| 76 |
|
| 77 |
#[test] |
| 78 |
fn extract_title_basic() { |
| 79 |
assert_eq!( |
| 80 |
extract_title("# Hello World\n\nBody"), |
| 81 |
Some("Hello World".to_string()) |
| 82 |
); |
| 83 |
} |
| 84 |
|
| 85 |
#[test] |
| 86 |
fn extract_title_with_leading_blank_lines() { |
| 87 |
assert_eq!(extract_title("\n# Title\n"), Some("Title".to_string())); |
| 88 |
} |
| 89 |
|
| 90 |
#[test] |
| 91 |
fn extract_title_none_when_missing() { |
| 92 |
assert_eq!(extract_title("No heading here"), None); |
| 93 |
} |
| 94 |
|
| 95 |
#[test] |
| 96 |
fn extract_title_ignores_h2() { |
| 97 |
assert_eq!(extract_title("## Not H1"), None); |
| 98 |
} |
| 99 |
|
| 100 |
#[test] |
| 101 |
fn extract_title_skips_horizontal_rules() { |
| 102 |
assert_eq!( |
| 103 |
extract_title("---\n# After Rule"), |
| 104 |
Some("After Rule".to_string()) |
| 105 |
); |
| 106 |
} |
| 107 |
|
| 108 |
#[test] |
| 109 |
fn strip_first_heading_removes_h1() { |
| 110 |
let md = "# Title\n\nBody text\n## Subheading"; |
| 111 |
let stripped = strip_first_heading(md); |
| 112 |
assert!(!stripped.contains("# Title")); |
| 113 |
assert!(stripped.contains("Body text")); |
| 114 |
assert!(stripped.contains("## Subheading")); |
| 115 |
} |
| 116 |
|
| 117 |
#[test] |
| 118 |
fn strip_first_heading_only_removes_first() { |
| 119 |
let md = "# First\n\n# Second"; |
| 120 |
let stripped = strip_first_heading(md); |
| 121 |
assert!(!stripped.contains("# First")); |
| 122 |
assert!(stripped.contains("# Second")); |
| 123 |
} |
| 124 |
|
| 125 |
#[test] |
| 126 |
fn strip_first_heading_no_h1() { |
| 127 |
let md = "## Only H2\n\nBody"; |
| 128 |
let stripped = strip_first_heading(md); |
| 129 |
assert_eq!(stripped, md); |
| 130 |
} |
| 131 |
} |
| 132 |
|