Skip to main content

bake_readme/
document.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4use socketry_markdown::{
5    MarkdownOptions, ParseOptions,
6    mdast::{Html, InlineCode, Link, List, ListItem, Node, Paragraph, Text},
7    to_mdast,
8};
9use std::ops::Range;
10
11const RELEASES_SECTION: &str =
12    "## Releases\n\nSee [releases.md](releases.md) for the release history.";
13const RELEASES_START: &str = "<!-- bake-readme:releases:start -->";
14const RELEASES_END: &str = "<!-- bake-readme:releases:end -->";
15const PACKAGE_MARKER: &str = "<!-- bake-readme:package -->";
16const RECENT_RELEASE_COUNT: usize = 3;
17
18struct Heading {
19    title: String,
20    level: u8,
21    start: usize,
22    body_start: usize,
23}
24
25/// Cargo package fields used to add a generated entry to the `readme.md` See Also section.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct PackageMetadata {
28    /// Cargo package name.
29    pub name: String,
30    /// Optional Cargo package description.
31    pub description: Option<String>,
32    /// Optional Cargo repository URL.
33    pub repository: Option<String>,
34}
35
36/// A release entry parsed from the project's `releases.md` file.
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct Release {
39    /// Release heading, usually a version such as `v1.2.3`.
40    pub name: String,
41    /// Markdown content beneath the release heading.
42    pub notes: String,
43}
44
45fn parse_markdown(document: &str) -> Node {
46    to_mdast(document, &ParseOptions::default())
47        .expect("CommonMark input should always parse into an AST")
48}
49
50fn heading_text(node: &Node) -> String {
51    node.text_content()
52}
53
54fn after_line_ending(document: &str, offset: usize) -> usize {
55    let rest = &document[offset..];
56
57    if rest.starts_with("\r\n") {
58        offset + 2
59    } else if rest.starts_with('\r') || rest.starts_with('\n') {
60        offset + 1
61    } else {
62        offset
63    }
64}
65
66fn headings(document: &str) -> Vec<Heading> {
67    let root = parse_markdown(document);
68    let children = root
69        .children()
70        .expect("a Markdown document AST should be a root node");
71    let mut headings = Vec::new();
72
73    for node in children {
74        let Node::Heading(heading) = node else {
75            continue;
76        };
77        let position = heading
78            .position
79            .as_ref()
80            .expect("parsed Markdown headings should include source positions");
81        headings.push(Heading {
82            title: heading_text(node),
83            level: heading.depth,
84            start: position.start.offset,
85            body_start: after_line_ending(document, position.end.offset),
86        });
87    }
88
89    headings
90}
91
92fn trailing_blank_lines_start(document: &str, position: usize) -> Range<usize> {
93    let prefix = &document[..position];
94    let mut start = position;
95    for line in prefix.split_inclusive('\n').rev() {
96        if line.trim().is_empty() {
97            start -= line.len();
98        } else {
99            break;
100        }
101    }
102    start..position
103}
104
105fn newline(document: &str) -> &'static str {
106    if document.contains("\r\n") {
107        "\r\n"
108    } else {
109        "\n"
110    }
111}
112
113fn rendered_package_entry(package: &PackageMetadata) -> String {
114    let name = package.name.trim();
115    let name_node = match package
116        .repository
117        .as_deref()
118        .filter(|value| !value.trim().is_empty())
119    {
120        Some(repository) => Node::Link(Link {
121            children: vec![Node::Text(Text {
122                value: name.to_owned(),
123                position: None,
124            })],
125            position: None,
126            url: repository.to_owned(),
127            title: None,
128        }),
129        None => Node::InlineCode(InlineCode {
130            value: name.to_owned(),
131            position: None,
132            lang: None,
133        }),
134    };
135    let description = package
136        .description
137        .as_deref()
138        .map(str::split_whitespace)
139        .map(|words| words.collect::<Vec<_>>())
140        .filter(|words| !words.is_empty())
141        .map(|words| words.join(" "));
142
143    let mut children = vec![name_node];
144    if let Some(description) = description {
145        children.push(Node::Text(Text {
146            value: format!(" — {description}"),
147            position: None,
148        }));
149    }
150    children.push(Node::Html(Html {
151        value: PACKAGE_MARKER.to_owned(),
152        position: None,
153    }));
154
155    let options = MarkdownOptions {
156        bullet: '-',
157        ..MarkdownOptions::default()
158    };
159
160    Node::List(List {
161        children: vec![Node::ListItem(ListItem {
162            children: vec![Node::Paragraph(Paragraph {
163                children,
164                position: None,
165            })],
166            position: None,
167            spread: false,
168            checked: None,
169        })],
170        position: None,
171        ordered: false,
172        start: None,
173        spread: false,
174    })
175    .to_markdown_with_options(&options)
176    .expect("generated package entries are valid Markdown")
177    .trim_end()
178    .to_owned()
179}
180
181fn section_end(document: &str, headings: &[Heading], index: usize) -> usize {
182    let heading = &headings[index];
183    headings
184        .iter()
185        .skip(index + 1)
186        .find(|next| next.level <= heading.level)
187        .map_or(document.len(), |next| next.start)
188}
189
190/// Parse the newest versioned release entries, excluding the Unreleased section.
191pub fn recent_releases(document: &str) -> Vec<Release> {
192    let headings = headings(document);
193    let Some((releases_index, releases_heading)) = headings
194        .iter()
195        .enumerate()
196        .find(|(_, heading)| heading.level == 1 && heading.title == "Releases")
197    else {
198        return Vec::new();
199    };
200    let releases_end = section_end(document, &headings, releases_index);
201
202    headings
203        .iter()
204        .enumerate()
205        .filter(|(_, heading)| {
206            heading.level == 2
207                && heading.start >= releases_heading.body_start
208                && heading.start < releases_end
209                && !heading.title.eq_ignore_ascii_case("Unreleased")
210        })
211        .take(RECENT_RELEASE_COUNT)
212        .map(|(index, heading)| Release {
213            name: heading.title.to_owned(),
214            notes: document[heading.body_start..section_end(document, &headings, index)]
215                .trim()
216                .to_owned(),
217        })
218        .collect()
219}
220
221fn rendered_recent_releases(releases: &[Release], line_ending: &str) -> String {
222    let mut content = String::from("See [releases.md](releases.md) for the full release history.");
223
224    for release in releases {
225        content.push_str(line_ending);
226        content.push_str(line_ending);
227        content.push_str("### ");
228        content.push_str(&release.name);
229        if !release.notes.is_empty() {
230            content.push_str(line_ending);
231            content.push_str(line_ending);
232            content.push_str(
233                &release
234                    .notes
235                    .replace("\r\n", "\n")
236                    .replace('\n', line_ending),
237            );
238        }
239    }
240
241    content
242}
243
244fn marker_line(document: &str, range: Range<usize>, marker: &str) -> Option<(usize, usize)> {
245    let root = parse_markdown(document);
246    let mut found = None;
247    root.walk(|node| {
248        if found.is_some() {
249            return;
250        }
251        let Node::Html(html) = node else {
252            return;
253        };
254        if html.value.trim() != marker {
255            return;
256        }
257        let position = node
258            .position()
259            .expect("parsed Markdown nodes should include source positions");
260        let offset = position.start.offset;
261        if offset < range.start || offset >= range.end {
262            return;
263        }
264        found = Some(source_line_range(document, offset));
265    });
266    found
267}
268
269fn source_line_range(document: &str, offset: usize) -> (usize, usize) {
270    let start = document[..offset]
271        .rfind('\n')
272        .map_or(0, |newline| newline + 1);
273    let end = document[offset..]
274        .find('\n')
275        .map_or(document.len(), |newline| offset + newline + 1);
276    (start, end)
277}
278
279fn contains_link_to(document: &str, range: Range<usize>, destination: &str) -> bool {
280    let root = parse_markdown(document);
281    let mut definitions = Vec::new();
282    root.walk(|node| {
283        if let Node::Definition(definition) = node
284            && definition.url == destination
285        {
286            definitions.push(definition.identifier.clone());
287        }
288    });
289    let mut found = false;
290    root.walk(|node| {
291        if found {
292            return;
293        }
294        let matches = match node {
295            Node::Link(link) => link.url == destination,
296            Node::LinkReference(reference) => definitions.contains(&reference.identifier),
297            _ => false,
298        };
299        if matches
300            && range.contains(
301                &node
302                    .position()
303                    .expect("parsed Markdown nodes should include source positions")
304                    .start
305                    .offset,
306            )
307        {
308            found = true;
309        }
310    });
311    found
312}
313
314fn replace_generated_releases(
315    document: &str,
316    body: Range<usize>,
317    releases: &[Release],
318) -> Option<String> {
319    let start = marker_line(document, body.clone(), RELEASES_START);
320    let end = marker_line(document, body.clone(), RELEASES_END);
321    let line_ending = newline(document);
322    let content = rendered_recent_releases(releases, line_ending);
323
324    if let (Some((_, start_end)), Some((end_start, _))) = (start, end) {
325        if start_end <= end_start {
326            let mut updated = String::with_capacity(document.len() + content.len());
327            updated.push_str(&document[..start_end]);
328            updated.push_str(&content);
329            updated.push_str(line_ending);
330            updated.push_str(&document[end_start..]);
331            return Some(updated);
332        }
333        return None;
334    }
335
336    // Upgrade the simple link generated by earlier versions of this task.
337    if document[body.clone()].trim() == "See [releases.md](releases.md) for the release history." {
338        let replacement = format!(
339            "{line_ending}{RELEASES_START}{line_ending}{content}{line_ending}{RELEASES_END}{line_ending}{line_ending}"
340        );
341        let mut updated = String::with_capacity(document.len() + replacement.len());
342        updated.push_str(&document[..body.start]);
343        updated.push_str(&replacement);
344        updated.push_str(&document[body.end..]);
345        return Some(updated);
346    }
347
348    None
349}
350
351/// Add or refresh a marked summary of the most recent releases.
352///
353/// Project-authored Releases sections remain untouched. Sections generated by
354/// this task are marked so later updates can refresh their release entries.
355pub fn update_releases_section(document: &str, releases: &[Release]) -> String {
356    let document_headings = headings(document);
357    if let Some((index, heading)) = document_headings
358        .iter()
359        .enumerate()
360        .find(|(_, heading)| heading.title == "Releases")
361    {
362        let body = heading.body_start..section_end(document, &document_headings, index);
363        return replace_generated_releases(document, body, releases)
364            .unwrap_or_else(|| document.to_owned());
365    }
366
367    let document = ensure_releases_section(document);
368    let generated_headings = headings(&document);
369    let (index, heading) = generated_headings
370        .iter()
371        .enumerate()
372        .find(|(_, heading)| heading.title == "Releases")
373        .expect("ensure_releases_section should create a Releases heading");
374    let body = heading.body_start..section_end(&document, &generated_headings, index);
375    replace_generated_releases(&document, body, releases).unwrap_or(document)
376}
377
378fn update_see_also_section(document: &str, package: Option<&PackageMetadata>) -> String {
379    let headings = headings(document);
380    let Some(package) = package else {
381        return document.to_owned();
382    };
383    let entry = rendered_package_entry(package);
384    let newline = newline(document);
385
386    if let Some((index, heading)) = headings
387        .iter()
388        .enumerate()
389        .find(|(_, heading)| heading.title == "See Also")
390    {
391        let end = section_end(document, &headings, index);
392        let body = &document[heading.body_start..end];
393        let repository_is_linked_elsewhere = package
394            .repository
395            .as_deref()
396            .filter(|repository| !repository.trim().is_empty())
397            .is_some_and(|repository| {
398                contains_link_to(document, 0..heading.start, repository)
399                    || contains_link_to(document, end..document.len(), repository)
400            });
401        if let Some((line_start, line_end)) =
402            marker_line(document, heading.body_start..end, PACKAGE_MARKER)
403        {
404            let line = &document[line_start..line_end];
405            let line_ending = if line.ends_with("\r\n") {
406                "\r\n"
407            } else if line.ends_with('\n') {
408                "\n"
409            } else {
410                ""
411            };
412            if repository_is_linked_elsewhere
413                && package.repository.as_deref().is_some_and(|repository| {
414                    contains_link_to(document, line_start..line_end, repository)
415                })
416            {
417                let remaining_body = format!(
418                    "{}{}",
419                    &document[heading.body_start..line_start],
420                    &document[line_end..end]
421                );
422
423                if remaining_body.trim().is_empty() {
424                    let section_start = trailing_blank_lines_start(document, heading.start).start;
425                    let mut updated = String::with_capacity(document.len());
426                    updated.push_str(&document[..section_start]);
427                    if !updated.is_empty() {
428                        updated.push_str(newline);
429                    }
430                    updated.push_str(&document[end..]);
431                    return updated;
432                }
433
434                let mut updated = String::with_capacity(document.len());
435                updated.push_str(&document[..line_start]);
436                updated.push_str(&document[line_end..]);
437                return updated;
438            }
439
440            let mut updated = String::with_capacity(document.len() + entry.len());
441            updated.push_str(&document[..line_start]);
442            updated.push_str(&entry);
443            updated.push_str(line_ending);
444            updated.push_str(&document[line_end..]);
445            return updated;
446        }
447
448        if repository_is_linked_elsewhere
449            || package.repository.as_deref().is_some_and(|repository| {
450                contains_link_to(document, heading.body_start..end, repository)
451            })
452        {
453            return document.to_owned();
454        }
455
456        let mut insertion = heading.body_start;
457        while insertion < end {
458            let remaining = &document[insertion..end];
459            let Some(line_end) = remaining.find('\n').map(|index| insertion + index + 1) else {
460                break;
461            };
462            if document[insertion..line_end].trim().is_empty() {
463                insertion = line_end;
464            } else {
465                break;
466            }
467        }
468        let prefix = &document[..insertion];
469        let leading = if insertion == heading.body_start
470            && (!prefix.ends_with('\n') || !body.starts_with('\n'))
471        {
472            newline
473        } else {
474            ""
475        };
476        let mut updated = String::with_capacity(document.len() + entry.len() + 4);
477        updated.push_str(prefix);
478        updated.push_str(leading);
479        updated.push_str(&entry);
480        updated.push_str(newline);
481        updated.push_str(newline);
482        updated.push_str(&document[insertion..]);
483        return updated;
484    }
485
486    let target = headings
487        .iter()
488        .find(|heading| heading.title == "Contributing");
489    if package
490        .repository
491        .as_deref()
492        .is_some_and(|repository| contains_link_to(document, 0..document.len(), repository))
493    {
494        return document.to_owned();
495    }
496    let section = format!("## See Also{newline}{newline}{entry}");
497
498    if let Some(target) = target {
499        let insertion = trailing_blank_lines_start(document, target.start).start;
500        let prefix = &document[..insertion];
501        let preceding_newlines = prefix
502            .chars()
503            .rev()
504            .take_while(|character| *character == '\n')
505            .count();
506        let leading = if insertion == 0 || preceding_newlines >= 2 {
507            ""
508        } else {
509            newline
510        };
511        let mut updated = String::with_capacity(document.len() + section.len() + 8);
512        updated.push_str(prefix);
513        updated.push_str(leading);
514        updated.push_str(&section);
515        updated.push_str(newline);
516        updated.push_str(newline);
517        updated.push_str(&document[target.start..]);
518        updated
519    } else {
520        let mut updated = String::with_capacity(document.len() + section.len() + 4);
521        updated.push_str(document);
522        if !document.is_empty() && !document.ends_with("\n\n") {
523            updated.push_str(newline);
524            if !document.ends_with('\n') {
525                updated.push_str(newline);
526            }
527        }
528        updated.push_str(&section);
529        updated.push_str(newline);
530        updated
531    }
532}
533
534/// Add the default Releases section if the document has no real Releases heading.
535///
536/// The section is inserted before a `See Also` or `Contributing` heading when
537/// present; otherwise it is appended. Headings inside fenced code blocks and
538/// block quotes are ignored. If a Releases section already exists, the original
539/// document is returned byte for byte.
540pub fn ensure_releases_section(document: &str) -> String {
541    let headings = headings(document);
542    if headings.iter().any(|heading| heading.title == "Releases") {
543        return document.to_owned();
544    }
545
546    let target = headings
547        .iter()
548        .find(|heading| matches!(heading.title.as_str(), "See Also" | "Contributing"));
549
550    let newline = newline(document);
551    let section = RELEASES_SECTION.replace('\n', newline);
552
553    if let Some(target) = target {
554        let insertion = trailing_blank_lines_start(document, target.start).start;
555        let prefix = &document[..insertion];
556        let preceding_newlines = prefix
557            .chars()
558            .rev()
559            .take_while(|character| *character == '\n')
560            .count();
561        let leading = if insertion == 0 || preceding_newlines >= 2 {
562            ""
563        } else {
564            newline
565        };
566        let mut updated = String::with_capacity(document.len() + section.len() + 8);
567        updated.push_str(prefix);
568        updated.push_str(leading);
569        updated.push_str(&section);
570        updated.push_str(newline);
571        updated.push_str(newline);
572        updated.push_str(&document[target.start..]);
573        updated
574    } else {
575        let mut updated = String::with_capacity(document.len() + section.len() + 2 * newline.len());
576        updated.push_str(document);
577        if !document.is_empty() && !document.ends_with("\n\n") {
578            updated.push_str(newline);
579            if !document.ends_with('\n') {
580                updated.push_str(newline);
581            }
582        }
583        updated.push_str(&section);
584        updated.push_str(newline);
585        updated
586    }
587}
588
589/// Add or refresh metadata-derived See Also content and the standard Releases section.
590///
591/// The generated package entry is marked with an HTML comment so subsequent
592/// updates can refresh it while preserving the rest of a project-owned section.
593pub fn update_document(document: &str, package: Option<&PackageMetadata>) -> String {
594    let updated = update_see_also_section(document, package);
595    ensure_releases_section(&updated)
596}
597
598/// Add or refresh metadata-derived See Also content and recent release entries.
599pub fn update_document_with_releases(
600    document: &str,
601    package: Option<&PackageMetadata>,
602    releases: &[Release],
603) -> String {
604    let updated = update_see_also_section(document, package);
605    update_releases_section(&updated, releases)
606}
607
608#[cfg(test)]
609#[path = "document_tests.rs"]
610mod tests;