Skip to main content

logicaffeine_web/ui/pages/
changelog.rs

1//! The root `CHANGELOG.md`, read the way the roadmap shows it.
2//!
3//! The roadmap's release history titles each release with a headline, and its Next release block
4//! lists what `[Unreleased]` has added so far. Both read the CHANGELOG through this module. It is
5//! std-only: the web crate's `build.rs` includes this file with `#[path]` and bakes `[Unreleased]`
6//! into the page at build time, and the crate's tests run the same code.
7//!
8//! The headline rule is the one `scripts/generate-roadmap.sh` applies when it writes
9//! `roadmap_history.json` (its `clean_title`), and `tests/roadmap_page.rs` holds the two readings
10//! to the same answer on every release the CHANGELOG records.
11
12/// What `[Unreleased]` holds: every addition's headline, in order, and how many other entries
13/// there are.
14#[derive(Debug, Default, Clone, PartialEq, Eq)]
15pub struct Unreleased {
16    /// The headline of each entry under `### Added`.
17    pub added: Vec<String>,
18    /// Entries under `### Changed`, `### Deprecated` and `### Removed`.
19    pub changed: usize,
20    /// Entries under `### Fixed` and `### Security`.
21    pub fixed: usize,
22}
23
24/// A release's section of the CHANGELOG: the text between its `## [version]` heading line and the
25/// next `## ` heading. `None` when the CHANGELOG has no such release.
26pub fn section<'a>(changelog: &'a str, version: &str) -> Option<&'a str> {
27    let heading = format!("## [{version}]");
28    let dated = format!("{heading} ");
29    let mut offset = 0;
30    let mut start = None;
31    for line in changelog.split_inclusive('\n') {
32        let text = line.trim_end_matches(['\n', '\r']);
33        if text.starts_with("## ") {
34            if let Some(start) = start {
35                return Some(&changelog[start..offset]);
36            }
37            if text == heading || text.starts_with(&dated) {
38                start = Some(offset + line.len());
39            }
40        }
41        offset += line.len();
42    }
43    start.map(|start| &changelog[start..])
44}
45
46/// A release's title, as the release history shows it: the headline of the section's first line
47/// of content, which is its first entry or a prose preamble such as "First public release.".
48pub fn release_title(section: &str) -> String {
49    section
50        .lines()
51        .find(|line| !line.trim().is_empty() && !line.starts_with("### "))
52        .map(|line| headline(line.strip_prefix("- ").unwrap_or(line)))
53        .unwrap_or_default()
54}
55
56/// One line's headline. An entry's is its first bold lead; a line with no bold text keeps its
57/// words, less one pair of wrapping parentheses. Backticks are dropped, runs of whitespace become
58/// one space, a trailing period goes, and a headline over 72 characters is cut back to its last
59/// whole word and ends in an ellipsis.
60pub fn headline(line: &str) -> String {
61    let line = line.strip_prefix("- ").unwrap_or(line);
62    let raw = match line.find("**") {
63        Some(open) => {
64            let lead = &line[open + 2..];
65            &lead[..lead.find('*').unwrap_or(lead.len())]
66        }
67        None => {
68            let line = line.strip_prefix('(').unwrap_or(line);
69            line.strip_suffix(')').unwrap_or(line)
70        }
71    };
72    let unticked = raw.replace('`', "");
73    let joined = unticked.split_whitespace().collect::<Vec<_>>().join(" ");
74    let text = joined.strip_suffix('.').unwrap_or(&joined);
75    if text.chars().count() <= 72 {
76        return text.to_string();
77    }
78    let cut: String = text.chars().take(72).collect();
79    let whole = match cut.rfind(' ') {
80        Some(space) => &cut[..space],
81        None => &cut[..],
82    };
83    format!("{whole}…")
84}
85
86/// The `[Unreleased]` section, read. A CHANGELOG with no `[Unreleased]` section has nothing in
87/// progress.
88pub fn unreleased(changelog: &str) -> Unreleased {
89    let Some(body) = section(changelog, "Unreleased") else { return Unreleased::default() };
90    let mut next = Unreleased::default();
91    let mut kind = "";
92    for line in body.lines() {
93        if let Some(heading) = line.strip_prefix("### ") {
94            kind = heading.trim();
95        } else if line.starts_with("- ") {
96            match kind {
97                "Added" => next.added.push(headline(line)),
98                "Changed" | "Deprecated" | "Removed" => next.changed += 1,
99                "Fixed" | "Security" => next.fixed += 1,
100                _ => {}
101            }
102        }
103    }
104    next
105}
106
107#[cfg(test)]
108#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/pages/changelog/tests.rs"]
109mod tests;