mdbook_newday/
lib.rs

1use chrono::{DateTime, Duration, Local, TimeZone};
2
3const SIGIL: &str = "- [";
4
5fn now() -> DateTime<Local> {
6    Local::now()
7}
8
9fn mdbook_summary_line_for_time<T: TimeZone>(dt: DateTime<T>) -> String
10where
11    T::Offset: std::fmt::Display,
12{
13    dt.format("- [%A, %b %d, %Y](./%Y/%Y-%m/%Y-%m-%d.md)")
14        .to_string()
15}
16
17fn todays_line() -> String {
18    mdbook_summary_line_for_time(now())
19}
20
21fn future_lines(days: u32) -> Vec<String> {
22    let today = now();
23    (1..=days)
24        .map(|day_offset| {
25            let future_date = today + Duration::days(day_offset as i64);
26            mdbook_summary_line_for_time(future_date)
27        })
28        .collect::<Vec<_>>()
29        .into_iter()
30        .rev()
31        .collect()
32}
33
34/// Insert the new lines right before the first line starting with sigil.
35/// If there is no such line, insert lines after all other lines.
36/// If there are already matching lines, don't insert duplicates.
37fn insert_lines_before_sigil(lines: &[String], sigil: &str, text: &str) -> String {
38    let mut new_lines = vec![];
39    let mut sigil_found = false;
40    let mut already_added_lines: std::collections::HashSet<String> =
41        std::collections::HashSet::new();
42
43    // Check what lines are already present and identify exact matches
44    for text_line in text.lines() {
45        for line in lines {
46            if text_line == line {
47                already_added_lines.insert(line.clone());
48            }
49        }
50    }
51
52    // Add the lines before the first sigil if there is one.
53    for text_line in text.lines() {
54        if text_line.starts_with(sigil) && !sigil_found {
55            // Add any new lines that aren't already present
56            for line in lines {
57                if !already_added_lines.contains(line) {
58                    new_lines.push(line.as_str());
59                    already_added_lines.insert(line.clone());
60                }
61            }
62            sigil_found = true;
63        }
64        new_lines.push(text_line);
65    }
66
67    // Add the lines at the end if there is no sigil present.
68    if !sigil_found {
69        for line in lines {
70            if !already_added_lines.contains(line) {
71                new_lines.push(line.as_str());
72            }
73        }
74    }
75
76    let mut with_insert = new_lines.join("\n");
77    with_insert.push('\n');
78    with_insert
79}
80
81fn add_lines_to_file(lines: &[String], sigil: &str, file_path: &str) -> Result<(), std::io::Error> {
82    let file_contents = std::fs::read_to_string(file_path)?;
83    let file_contents = insert_lines_before_sigil(lines, sigil, &file_contents);
84    std::fs::write(file_path, file_contents)
85}
86
87fn add_line_to_file(line: &str, sigil: &str, file_path: &str) -> Result<(), std::io::Error> {
88    add_lines_to_file(&[line.to_string()], sigil, file_path)
89}
90
91// Helper function for tests - wrapper around insert_lines_before_sigil for single lines
92#[cfg(test)]
93fn insert_line_before_sigil(line: &str, sigil: &str, text: &str) -> String {
94    insert_lines_before_sigil(&[line.to_string()], sigil, text)
95}
96
97/// Adds a new line almost to the top of an mdbook summary page.
98/// That is, not quite the top because it skips any lines without the `- [`
99/// prefix like any title or commentary or introduction link.
100///
101/// The new line comes from local time and takes the format
102/// `- [%A, %b %d, %Y](./%Y/%Y-%m/%Y-%m-%d.md)`, ie
103/// `- [Thursday, Jan 01, 1970](./1970/1970-01/1970-01-01.md)`.
104/// If there is no line yet, it will add one.
105///
106/// `mdbook serve` will create the files mentioned in the summary with titles
107/// given by the link text.  So if this function is run right before `mdbook serve`
108/// you'll get not only the summary link but also the file.
109pub fn update_summary(path: &str) -> Result<(), std::io::Error> {
110    add_line_to_file(&todays_line(), SIGIL, path)
111}
112
113/// Adds multiple new lines to an mdbook summary page for the next n days.
114/// Each line takes the same format as update_summary but for consecutive future days.
115///
116/// For example, if called with days=3, it will add lines for tomorrow, the day after,
117/// and the day after that.
118///
119/// Lines are added in chronological order and duplicate lines are avoided.
120pub fn update_summary_plan_ahead(path: &str, days: u32) -> Result<(), std::io::Error> {
121    let lines = future_lines(days);
122    add_lines_to_file(&lines, SIGIL, path)
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use chrono::Utc;
129
130    #[test]
131    fn test_update_summary() -> Result<(), std::io::Error> {
132        let tmp_path = "./tmp_file";
133        let original_line = "original_line";
134
135        // Create the file with only the original line.
136        std::fs::write(tmp_path, original_line)?;
137
138        // Add in the new line.
139        update_summary(tmp_path)?;
140
141        // Check file contents.
142        let added_line = &todays_line();
143        let contents = std::fs::read_to_string(tmp_path)?;
144        assert_eq!(contents, format!("{}\n{}\n", original_line, added_line));
145
146        // Now delete the file again.
147        std::fs::remove_file(tmp_path)?;
148        Ok(())
149    }
150
151    #[test]
152    fn test_place_line_before() {
153        let line = "- [First!](./first.md)";
154        let line_without_sigil = "no sigil";
155        let sigil = "- [";
156        let text_without_sigil = "[Intro](./intro)";
157        let text_with_sigil = "[Intro](./intro)\n- [Second!](./second.md)";
158        let text_with_2_sigils = "[Intro](./intro)\n- [\n- [";
159
160        // If there is no sigil, the line is added to the end of the file.
161        let text = insert_line_before_sigil(line, sigil, text_without_sigil);
162        assert_eq!(text, "[Intro](./intro)\n- [First!](./first.md)\n");
163
164        // Otherwise, the line is added before the first appearance of the sigil.
165        let text = insert_line_before_sigil(line, sigil, text_with_sigil);
166        assert_eq!(
167            text,
168            "[Intro](./intro)\n- [First!](./first.md)\n- [Second!](./second.md)\n"
169        );
170
171        // Idempotent
172        let text = insert_line_before_sigil(line, sigil, &text);
173        assert_eq!(
174            text,
175            "[Intro](./intro)\n- [First!](./first.md)\n- [Second!](./second.md)\n"
176        );
177
178        // Only add the line in one place even if the sigil appears multiple times.
179        let text = insert_line_before_sigil(line, sigil, text_with_2_sigils);
180        assert_eq!(text, "[Intro](./intro)\n- [First!](./first.md)\n- [\n- [\n");
181
182        // A line without a sigil is still added idempotently.
183        let first_pass = insert_line_before_sigil(line_without_sigil, sigil, text_without_sigil);
184        assert_eq!(first_pass, "[Intro](./intro)\nno sigil\n");
185        let second_pass = insert_line_before_sigil(line_without_sigil, sigil, &first_pass);
186        assert_eq!(first_pass, second_pass)
187    }
188
189    #[test]
190    fn test_gives_summary_terminal_newline() {
191        let sigil = "- [";
192        let without_sigil = "[Introduction](introduction.md)";
193        let with_sigil = "- [a](a.md)";
194        let line = "- [Thursday, Jan 01, 1970](./1970/1970-01/1970-01-01.md)";
195
196        let expected = format!("{without_sigil}\n{line}\n{with_sigil}\n");
197
198        // If the terminal newline is already there, we don't lose it.
199        let text = format!("{without_sigil}\n{with_sigil}\n");
200        let with_insert = insert_line_before_sigil(line, sigil, &text);
201        assert_eq!(with_insert, expected);
202
203        // If it was missing, we gain it.
204        let text = format!("{without_sigil}\n{with_sigil}");
205        let with_insert = insert_line_before_sigil(line, sigil, &text);
206        assert_eq!(with_insert, expected);
207    }
208
209    #[test]
210    fn test_summary_line_format() {
211        let dt: DateTime<Utc> = Utc.timestamp_opt(1, 0).unwrap();
212        let formatted = mdbook_summary_line_for_time(dt);
213        assert_eq!(
214            formatted,
215            "- [Thursday, Jan 01, 1970](./1970/1970-01/1970-01-01.md)"
216        );
217    }
218
219    #[test]
220    fn test_todays_line() {
221        // There's no fixed value here because it depends on the time and local timezone.
222        // So rather than create a test this just allows for quick inspection.
223        dbg!(todays_line());
224    }
225    #[test]
226    fn test_lines() {
227        let lines = "a\n";
228        for line in lines.lines() {
229            println!("Here's the line: {line:?}")
230        }
231    }
232
233    #[test]
234    fn test_future_lines() {
235        // Test that future_lines generates the correct number of lines
236        let lines = future_lines(3);
237        assert_eq!(lines.len(), 3);
238
239        // Each line should be a valid mdbook summary line
240        for line in &lines {
241            assert!(line.starts_with("- ["));
242            assert!(line.contains("](./"));
243            assert!(line.ends_with(".md)"));
244        }
245
246        // Lines should be in reverse chronological order (newest first)
247        // This is a simple check that the dates are decreasing
248        // We can't check exact dates due to timezone issues in tests
249        assert!(lines.len() >= 2);
250    }
251
252    #[test]
253    fn test_insert_multiple_lines() {
254        let lines = vec![
255            "- [Day 1](./day1.md)".to_string(),
256            "- [Day 2](./day2.md)".to_string(),
257            "- [Day 3](./day3.md)".to_string(),
258        ];
259        let sigil = "- [";
260        let text_with_sigil = "[Intro](./intro)\n- [Existing](./existing.md)";
261
262        let result = insert_lines_before_sigil(&lines, sigil, text_with_sigil);
263        let expected = "[Intro](./intro)\n- [Day 1](./day1.md)\n- [Day 2](./day2.md)\n- [Day 3](./day3.md)\n- [Existing](./existing.md)\n";
264        assert_eq!(result, expected);
265    }
266
267    #[test]
268    fn test_insert_multiple_lines_no_duplicates() {
269        let lines = vec![
270            "- [Day 1](./day1.md)".to_string(),
271            "- [Day 2](./day2.md)".to_string(),
272        ];
273        let sigil = "- [";
274        // Text that already contains one of the lines
275        let text_with_existing =
276            "[Intro](./intro)\n- [Day 1](./day1.md)\n- [Existing](./existing.md)";
277
278        let result = insert_lines_before_sigil(&lines, sigil, text_with_existing);
279        let expected = "[Intro](./intro)\n- [Day 2](./day2.md)\n- [Day 1](./day1.md)\n- [Existing](./existing.md)\n";
280        assert_eq!(result, expected);
281    }
282
283    #[test]
284    fn test_update_summary_plan_ahead() -> Result<(), std::io::Error> {
285        let tmp_path = "./tmp_plan_ahead_file";
286        let original_content = "[Introduction](intro.md)\n- [Existing Entry](./existing.md)\n";
287
288        // Create the file with original content
289        std::fs::write(tmp_path, original_content)?;
290
291        // Add 2 future days
292        update_summary_plan_ahead(tmp_path, 2)?;
293
294        // Check file contents
295        let contents = std::fs::read_to_string(tmp_path)?;
296
297        // Should have the intro, then 2 new lines, then the existing entry
298        let lines: Vec<&str> = contents.lines().collect();
299        assert_eq!(lines.len(), 4); // intro + 2 new + existing
300        assert_eq!(lines[0], "[Introduction](intro.md)");
301        assert!(lines[1].starts_with("- ["));
302        assert!(lines[2].starts_with("- ["));
303        assert_eq!(lines[3], "- [Existing Entry](./existing.md)");
304
305        // Clean up
306        std::fs::remove_file(tmp_path)?;
307        Ok(())
308    }
309}