Skip to main content

max / makenotwork

3.5 KB · 132 lines History Blame Raw
1 /// Calculate word count from text content.
2 pub fn word_count(text: &str) -> u32 {
3 text.split_whitespace().count() as u32
4 }
5
6 /// Calculate estimated reading time in minutes.
7 /// Assumes average reading speed of 200 words per minute.
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 /// Extract the title from the first `# Heading` line in markdown.
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 /// Strip the first `# Heading` line so templates can render it separately.
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