Skip to main content

purple_ssh/
changelog.rs

1use std::borrow::Cow;
2use std::sync::OnceLock;
3
4use semver::Version;
5
6pub(crate) const EMBEDDED: &str = include_str!("../CHANGELOG.md");
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum EntryKind {
10    Feature,
11    Change,
12    Fix,
13}
14
15#[derive(Debug, Clone)]
16pub struct Entry {
17    pub kind: EntryKind,
18    pub text: String,
19}
20
21#[derive(Debug, Clone)]
22pub struct Section {
23    pub version: Version,
24    pub date: Option<String>,
25    pub entries: Vec<Entry>,
26}
27
28static CACHE: OnceLock<Vec<Section>> = OnceLock::new();
29
30pub fn cached() -> &'static Vec<Section> {
31    CACHE.get_or_init(|| parse(EMBEDDED))
32}
33
34pub fn parse(input: &str) -> Vec<Section> {
35    let mut sections: Vec<Section> = Vec::new();
36    let mut current: Option<Section> = None;
37
38    for line in input.lines() {
39        if let Some(rest) = line.strip_prefix("## ") {
40            if let Some(sec) = current.take() {
41                if !sec.entries.is_empty() {
42                    sections.push(sec);
43                }
44            }
45            if let Some((version, date)) = parse_header(rest) {
46                current = Some(Section {
47                    version,
48                    date,
49                    entries: Vec::new(),
50                });
51            } else {
52                current = None;
53            }
54            continue;
55        }
56        if let Some(rest) = line.strip_prefix("- ") {
57            if let Some(sec) = current.as_mut() {
58                let (kind, text) = classify(rest.trim());
59                sec.entries.push(Entry { kind, text });
60            }
61        }
62    }
63
64    if let Some(sec) = current.take() {
65        if !sec.entries.is_empty() {
66            sections.push(sec);
67        }
68    }
69
70    sections
71}
72
73fn parse_header(rest: &str) -> Option<(Version, Option<String>)> {
74    let trimmed = rest.trim();
75    if let Some((vpart, dpart)) = trimmed.split_once(" - ") {
76        let version = Version::parse(vpart.trim()).ok()?;
77        let date = dpart.trim().to_string();
78        Some((version, if date.is_empty() { None } else { Some(date) }))
79    } else {
80        Version::parse(trimmed).ok().map(|v| (v, None))
81    }
82}
83
84fn classify(bullet: &str) -> (EntryKind, String) {
85    let lower = bullet.to_ascii_lowercase();
86    for (prefix, kind) in [
87        ("feat:", EntryKind::Feature),
88        ("fix:", EntryKind::Fix),
89        ("change:", EntryKind::Change),
90    ] {
91        if lower.starts_with(prefix) {
92            // prefix is all-ASCII so prefix.len() is a valid byte boundary in bullet.
93            let text = bullet[prefix.len()..].trim().to_string();
94            return (kind, text);
95        }
96    }
97    (EntryKind::Change, bullet.to_string())
98}
99
100fn window_bounds(
101    sections: &[Section],
102    last_seen: Option<&Version>,
103    current: &Version,
104) -> Option<(usize, usize)> {
105    let upper = sections.iter().position(|s| s.version <= *current)?;
106    let lower = match last_seen {
107        Some(seen) => sections
108            .iter()
109            .position(|s| s.version <= *seen)
110            .unwrap_or(sections.len()),
111        None => sections.len(),
112    };
113    Some((upper, lower))
114}
115
116pub fn versions_to_show<'a>(
117    sections: &'a [Section],
118    last_seen: Option<&Version>,
119    current: &Version,
120    cap: usize,
121) -> &'a [Section] {
122    if let Some(seen) = last_seen {
123        if seen >= current {
124            return &[];
125        }
126    }
127    let Some((upper, lower)) = window_bounds(sections, last_seen, current) else {
128        return &[];
129    };
130    let end = lower.min(upper.saturating_add(cap)).min(sections.len());
131    if end <= upper {
132        return &[];
133    }
134    &sections[upper..end]
135}
136
137#[cfg(test)]
138pub mod test_override {
139    use std::sync::Mutex;
140    static OVERRIDE: Mutex<Option<String>> = Mutex::new(None);
141    pub fn set(s: String) {
142        *OVERRIDE.lock().unwrap() = Some(s);
143    }
144    pub fn clear() {
145        *OVERRIDE.lock().unwrap() = None;
146    }
147    pub fn get() -> Option<String> {
148        OVERRIDE.lock().unwrap().clone()
149    }
150}
151
152#[cfg(test)]
153pub fn set_test_override(s: String) {
154    test_override::set(s);
155}
156
157#[cfg(test)]
158pub fn clear_test_override() {
159    test_override::clear();
160}
161
162pub fn current_for_render() -> Cow<'static, [Section]> {
163    #[cfg(test)]
164    if let Some(s) = test_override::get() {
165        return Cow::Owned(parse(&s));
166    }
167    Cow::Borrowed(cached().as_slice())
168}
169
170#[cfg(test)]
171#[path = "changelog_tests.rs"]
172mod tests;