Skip to main content

bake_readme/
document.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4use std::ops::Range;
5
6const RELEASES_SECTION: &str =
7    "## Releases\n\nSee [releases.md](releases.md) for the release history.";
8const RELEASES_START: &str = "<!-- bake-readme:releases:start -->";
9const RELEASES_END: &str = "<!-- bake-readme:releases:end -->";
10const RECENT_RELEASE_COUNT: usize = 3;
11
12struct Heading<'document> {
13    title: &'document str,
14    level: usize,
15    start: usize,
16    body_start: usize,
17}
18
19/// Cargo package fields used to add a generated entry to the `readme.md` See Also section.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct PackageMetadata {
22    /// Cargo package name.
23    pub name: String,
24    /// Optional Cargo package description.
25    pub description: Option<String>,
26    /// Optional Cargo repository URL.
27    pub repository: Option<String>,
28}
29
30/// A release entry parsed from the project's `releases.md` file.
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct Release {
33    /// Release heading, usually a version such as `v1.2.3`.
34    pub name: String,
35    /// Markdown content beneath the release heading.
36    pub notes: String,
37}
38
39fn headings(document: &str) -> Vec<Heading<'_>> {
40    let mut headings = Vec::new();
41    let mut offset = 0;
42    let mut fence: Option<(u8, usize)> = None;
43
44    for line in document.split_inclusive('\n') {
45        let text = line.trim_end_matches(['\r', '\n']);
46        let trimmed = text.trim_start_matches(' ');
47        let indentation = text.len() - trimmed.len();
48        let marker = trimmed.as_bytes().first().copied().unwrap_or_default();
49        let count = trimmed.bytes().take_while(|byte| *byte == marker).count();
50
51        if let Some((open_marker, open_count)) = fence {
52            if indentation <= 3
53                && marker == open_marker
54                && count >= open_count
55                && trimmed[count..].trim().is_empty()
56            {
57                fence = None;
58            }
59        } else if indentation <= 3
60            && matches!(marker, b'`' | b'~')
61            && count >= 3
62            && (marker != b'`' || !trimmed[count..].contains('`'))
63        {
64            fence = Some((marker, count));
65        } else if indentation == 0
66            && marker == b'#'
67            && (1..=6).contains(&count)
68            && (text.len() == count || text.as_bytes()[count].is_ascii_whitespace())
69        {
70            let title = trimmed[count..].trim();
71            let title = trim_closing_hashes(title);
72            headings.push(Heading {
73                title,
74                level: count,
75                start: offset,
76                body_start: offset + line.len(),
77            });
78        }
79
80        offset += line.len();
81    }
82
83    headings
84}
85
86fn trim_closing_hashes(title: &str) -> &str {
87    let without_hashes = title.trim_end_matches('#');
88    if without_hashes.len() < title.len() && without_hashes.ends_with([' ', '\t']) {
89        without_hashes.trim_end()
90    } else {
91        title
92    }
93}
94
95fn trailing_blank_lines_start(document: &str, position: usize) -> Range<usize> {
96    let prefix = &document[..position];
97    let mut start = position;
98    for line in prefix.split_inclusive('\n').rev() {
99        if line.trim().is_empty() {
100            start -= line.len();
101        } else {
102            break;
103        }
104    }
105    start..position
106}
107
108fn newline(document: &str) -> &'static str {
109    if document.contains("\r\n") {
110        "\r\n"
111    } else {
112        "\n"
113    }
114}
115
116fn rendered_package_entry(package: &PackageMetadata) -> String {
117    let name = package.name.trim();
118    let name_link = match package
119        .repository
120        .as_deref()
121        .filter(|value| !value.trim().is_empty())
122    {
123        Some(repository) => format!("[{name}]({repository})"),
124        None => format!("`{name}`"),
125    };
126    let description = package
127        .description
128        .as_deref()
129        .map(str::split_whitespace)
130        .map(|words| words.collect::<Vec<_>>())
131        .filter(|words| !words.is_empty())
132        .map(|words| format!(" — {}", words.join(" ")))
133        .unwrap_or_default();
134
135    format!("- {name_link}{description} <!-- bake-readme:package -->")
136}
137
138fn section_end(document: &str, headings: &[Heading<'_>], index: usize) -> usize {
139    let heading = &headings[index];
140    headings
141        .iter()
142        .skip(index + 1)
143        .find(|next| next.level <= heading.level)
144        .map_or(document.len(), |next| next.start)
145}
146
147/// Parse the newest versioned release entries, excluding the Unreleased section.
148pub fn recent_releases(document: &str) -> Vec<Release> {
149    let headings = headings(document);
150    let Some((releases_index, releases_heading)) = headings
151        .iter()
152        .enumerate()
153        .find(|(_, heading)| heading.level == 1 && heading.title == "Releases")
154    else {
155        return Vec::new();
156    };
157    let releases_end = section_end(document, &headings, releases_index);
158
159    headings
160        .iter()
161        .enumerate()
162        .filter(|(_, heading)| {
163            heading.level == 2
164                && heading.start >= releases_heading.body_start
165                && heading.start < releases_end
166                && !heading.title.eq_ignore_ascii_case("Unreleased")
167        })
168        .take(RECENT_RELEASE_COUNT)
169        .map(|(index, heading)| Release {
170            name: heading.title.to_owned(),
171            notes: document[heading.body_start..section_end(document, &headings, index)]
172                .trim()
173                .to_owned(),
174        })
175        .collect()
176}
177
178fn rendered_recent_releases(releases: &[Release], line_ending: &str) -> String {
179    let mut content = String::from("See [releases.md](releases.md) for the full release history.");
180
181    for release in releases {
182        content.push_str(line_ending);
183        content.push_str(line_ending);
184        content.push_str("### ");
185        content.push_str(&release.name);
186        if !release.notes.is_empty() {
187            content.push_str(line_ending);
188            content.push_str(line_ending);
189            content.push_str(
190                &release
191                    .notes
192                    .replace("\r\n", "\n")
193                    .replace('\n', line_ending),
194            );
195        }
196    }
197
198    content
199}
200
201fn marker_line(document: &str, range: Range<usize>, marker: &str) -> Option<(usize, usize)> {
202    let mut offset = range.start;
203    for line in document[range.clone()].split_inclusive('\n') {
204        let content = line.trim_end_matches(['\r', '\n']).trim();
205        if content == marker {
206            return Some((offset, offset + line.len()));
207        }
208        offset += line.len();
209    }
210    None
211}
212
213fn replace_generated_releases(
214    document: &str,
215    body: Range<usize>,
216    releases: &[Release],
217) -> Option<String> {
218    let start = marker_line(document, body.clone(), RELEASES_START);
219    let end = marker_line(document, body.clone(), RELEASES_END);
220    let line_ending = newline(document);
221    let content = rendered_recent_releases(releases, line_ending);
222
223    if let (Some((_, start_end)), Some((end_start, _))) = (start, end) {
224        if start_end <= end_start {
225            let mut updated = String::with_capacity(document.len() + content.len());
226            updated.push_str(&document[..start_end]);
227            updated.push_str(&content);
228            updated.push_str(line_ending);
229            updated.push_str(&document[end_start..]);
230            return Some(updated);
231        }
232        return None;
233    }
234
235    // Upgrade the simple link generated by earlier versions of this task.
236    if document[body.clone()].trim() == "See [releases.md](releases.md) for the release history." {
237        let replacement = format!(
238            "{line_ending}{RELEASES_START}{line_ending}{content}{line_ending}{RELEASES_END}{line_ending}{line_ending}"
239        );
240        let mut updated = String::with_capacity(document.len() + replacement.len());
241        updated.push_str(&document[..body.start]);
242        updated.push_str(&replacement);
243        updated.push_str(&document[body.end..]);
244        return Some(updated);
245    }
246
247    None
248}
249
250/// Add or refresh a marked summary of the most recent releases.
251///
252/// Project-authored Releases sections remain untouched. Sections generated by
253/// this task are marked so later updates can refresh their release entries.
254pub fn update_releases_section(document: &str, releases: &[Release]) -> String {
255    let document_headings = headings(document);
256    if let Some((index, heading)) = document_headings
257        .iter()
258        .enumerate()
259        .find(|(_, heading)| heading.title == "Releases")
260    {
261        let body = heading.body_start..section_end(document, &document_headings, index);
262        return replace_generated_releases(document, body, releases)
263            .unwrap_or_else(|| document.to_owned());
264    }
265
266    let document = ensure_releases_section(document);
267    let generated_headings = headings(&document);
268    let Some((index, heading)) = generated_headings
269        .iter()
270        .enumerate()
271        .find(|(_, heading)| heading.title == "Releases")
272    else {
273        return document;
274    };
275    let body = heading.body_start..section_end(&document, &generated_headings, index);
276    replace_generated_releases(&document, body, releases).unwrap_or(document)
277}
278
279fn update_see_also_section(document: &str, package: Option<&PackageMetadata>) -> String {
280    let headings = headings(document);
281    let Some(package) = package else {
282        return document.to_owned();
283    };
284    let entry = rendered_package_entry(package);
285    let newline = newline(document);
286
287    if let Some((index, heading)) = headings
288        .iter()
289        .enumerate()
290        .find(|(_, heading)| heading.title == "See Also")
291    {
292        let end = section_end(document, &headings, index);
293        let body = &document[heading.body_start..end];
294        if let Some((line_start, line_end, line_ending)) = body
295            .split_inclusive('\n')
296            .scan(heading.body_start, |offset, line| {
297                let start = *offset;
298                *offset += line.len();
299                Some((start, *offset, line))
300            })
301            .find_map(|(start, end, line)| {
302                line.contains("<!-- bake-readme:package -->").then(|| {
303                    let line_ending = if line.ends_with("\r\n") {
304                        "\r\n"
305                    } else if line.ends_with('\n') {
306                        "\n"
307                    } else {
308                        ""
309                    };
310                    (start, end, line_ending)
311                })
312            })
313        {
314            let mut updated = String::with_capacity(document.len() + entry.len());
315            updated.push_str(&document[..line_start]);
316            updated.push_str(&entry);
317            updated.push_str(line_ending);
318            updated.push_str(&document[line_end..]);
319            return updated;
320        }
321
322        if let Some(repository) = package.repository.as_deref()
323            && body.contains(repository)
324        {
325            return document.to_owned();
326        }
327
328        let mut insertion = heading.body_start;
329        while insertion < end {
330            let remaining = &document[insertion..end];
331            let Some(line_end) = remaining.find('\n').map(|index| insertion + index + 1) else {
332                break;
333            };
334            if document[insertion..line_end].trim().is_empty() {
335                insertion = line_end;
336            } else {
337                break;
338            }
339        }
340        let prefix = &document[..insertion];
341        let leading = if insertion == heading.body_start
342            && (!prefix.ends_with('\n') || !body.starts_with('\n'))
343        {
344            newline
345        } else {
346            ""
347        };
348        let mut updated = String::with_capacity(document.len() + entry.len() + 4);
349        updated.push_str(prefix);
350        updated.push_str(leading);
351        updated.push_str(&entry);
352        updated.push_str(newline);
353        updated.push_str(newline);
354        updated.push_str(&document[insertion..]);
355        return updated;
356    }
357
358    let target = headings
359        .iter()
360        .find(|heading| heading.title == "Contributing");
361    let section = format!("## See Also{newline}{newline}{entry}");
362
363    if let Some(target) = target {
364        let insertion = trailing_blank_lines_start(document, target.start).start;
365        let prefix = &document[..insertion];
366        let preceding_newlines = prefix
367            .chars()
368            .rev()
369            .take_while(|character| *character == '\n')
370            .count();
371        let leading = if insertion == 0 || preceding_newlines >= 2 {
372            ""
373        } else if preceding_newlines == 1 {
374            newline
375        } else if newline == "\r\n" {
376            "\r\n\r\n"
377        } else {
378            "\n\n"
379        };
380        let mut updated = String::with_capacity(document.len() + section.len() + 8);
381        updated.push_str(prefix);
382        updated.push_str(leading);
383        updated.push_str(&section);
384        updated.push_str(newline);
385        updated.push_str(newline);
386        updated.push_str(&document[target.start..]);
387        updated
388    } else {
389        let separator = if document.is_empty() || document.ends_with("\n\n") {
390            ""
391        } else if document.ends_with('\n') {
392            newline
393        } else if newline == "\r\n" {
394            "\r\n\r\n"
395        } else {
396            "\n\n"
397        };
398        let mut updated = String::with_capacity(document.len() + section.len() + 4);
399        updated.push_str(document);
400        updated.push_str(separator);
401        updated.push_str(&section);
402        updated.push_str(newline);
403        updated
404    }
405}
406
407/// Add the default Releases section if the document has no real Releases heading.
408///
409/// The section is inserted before a `See Also` or `Contributing` heading when
410/// present; otherwise it is appended. Headings inside fenced code blocks and
411/// block quotes are ignored. If a Releases section already exists, the original
412/// document is returned byte for byte.
413pub fn ensure_releases_section(document: &str) -> String {
414    let headings = headings(document);
415    if headings.iter().any(|heading| heading.title == "Releases") {
416        return document.to_owned();
417    }
418
419    let target = headings
420        .iter()
421        .find(|heading| matches!(heading.title, "See Also" | "Contributing"));
422
423    let newline = newline(document);
424    let section = RELEASES_SECTION.replace('\n', newline);
425
426    if let Some(target) = target {
427        let insertion = trailing_blank_lines_start(document, target.start).start;
428        let prefix = &document[..insertion];
429        let preceding_newlines = prefix
430            .chars()
431            .rev()
432            .take_while(|character| *character == '\n')
433            .count();
434        let leading = if insertion == 0 || preceding_newlines >= 2 {
435            ""
436        } else if preceding_newlines == 1 {
437            newline
438        } else {
439            if newline == "\r\n" {
440                "\r\n\r\n"
441            } else {
442                "\n\n"
443            }
444        };
445        let mut updated = String::with_capacity(document.len() + section.len() + 8);
446        updated.push_str(prefix);
447        updated.push_str(leading);
448        updated.push_str(&section);
449        updated.push_str(newline);
450        updated.push_str(newline);
451        updated.push_str(&document[target.start..]);
452        updated
453    } else {
454        let separator = if document.is_empty() || document.ends_with("\n\n") {
455            ""
456        } else if document.ends_with('\n') {
457            newline
458        } else if newline == "\r\n" {
459            "\r\n\r\n"
460        } else {
461            "\n\n"
462        };
463        let mut updated =
464            String::with_capacity(document.len() + section.len() + separator.len() + 1);
465        updated.push_str(document);
466        updated.push_str(separator);
467        updated.push_str(&section);
468        updated.push_str(newline);
469        updated
470    }
471}
472
473/// Add or refresh metadata-derived See Also content and the standard Releases section.
474///
475/// The generated package entry is marked with an HTML comment so subsequent
476/// updates can refresh it while preserving the rest of a project-owned section.
477pub fn update_document(document: &str, package: Option<&PackageMetadata>) -> String {
478    let updated = update_see_also_section(document, package);
479    ensure_releases_section(&updated)
480}
481
482/// Add or refresh metadata-derived See Also content and recent release entries.
483pub fn update_document_with_releases(
484    document: &str,
485    package: Option<&PackageMetadata>,
486    releases: &[Release],
487) -> String {
488    let updated = update_see_also_section(document, package);
489    update_releases_section(&updated, releases)
490}