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 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 §ions[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;