1use serde::{Deserialize, Serialize};
12
13const BUNDLED_CHANGELOG: &str = include_str!("../embedded-docs/changelog.md");
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct ChangelogEntry {
18 pub major: u64,
19 pub minor: u64,
20 pub patch: u64,
21 pub content: String,
23}
24
25pub const DEFAULT_CHANGELOG_URL: &str =
27 "https://raw.githubusercontent.com/bigfish1913/pi-rust/main/CHANGELOG.md";
28
29pub fn changelog_url() -> String {
31 std::env::var("RPI_CHANGELOG_URL").unwrap_or_else(|_| DEFAULT_CHANGELOG_URL.to_string())
32}
33
34fn parse_heading(line: &str) -> Option<(u64, u64, u64)> {
36 let trimmed = line.trim_start_matches('#').trim();
37 let version = trimmed.split_whitespace().next()?.trim_matches(['[', ']']);
38 let version = version.strip_prefix('v').unwrap_or(version);
39 let mut parts = version.split('.');
40 let major = parts.next()?.parse().ok()?;
41 let minor = parts.next().unwrap_or("0").parse().unwrap_or(0);
42 let patch = parts
43 .next()
44 .map(|p| p.split(['-', '+']).next().unwrap_or(p))
45 .unwrap_or("0")
46 .parse()
47 .unwrap_or(0);
48 Some((major, minor, patch))
49}
50
51pub fn parse_changelog(text: &str) -> Vec<ChangelogEntry> {
53 let mut entries: Vec<ChangelogEntry> = Vec::new();
54 let mut current: Option<ChangelogEntry> = None;
55
56 for line in text.lines() {
57 let is_heading = line.trim_start().starts_with('#');
58 if is_heading {
59 if let Some((major, minor, patch)) = parse_heading(line) {
60 if let Some(entry) = current.take() {
61 entries.push(finalize(entry));
62 }
63 current = Some(ChangelogEntry {
64 major,
65 minor,
66 patch,
67 content: String::new(),
68 });
69 continue;
70 }
71 }
72 if let Some(entry) = current.as_mut() {
73 entry.content.push_str(line);
74 entry.content.push('\n');
75 }
76 }
77 if let Some(entry) = current.take() {
78 entries.push(finalize(entry));
79 }
80 entries
81}
82
83fn finalize(mut entry: ChangelogEntry) -> ChangelogEntry {
84 entry.content = entry.content.trim().to_string();
85 entry
86}
87
88pub fn bundled_entries() -> Vec<ChangelogEntry> {
90 let mut entries = parse_changelog(BUNDLED_CHANGELOG);
91 entries.sort_by_key(|entry| std::cmp::Reverse((entry.major, entry.minor, entry.patch)));
92 entries
93}
94
95pub fn format_entries(entries: &[ChangelogEntry]) -> String {
96 entries
97 .iter()
98 .map(|entry| {
99 format!(
100 "## {}.{}.{}\n\n{}",
101 entry.major, entry.minor, entry.patch, entry.content
102 )
103 })
104 .collect::<Vec<_>>()
105 .join("\n\n")
106}
107
108pub fn latest_release(entries: &[ChangelogEntry], current_version: &str) -> Vec<ChangelogEntry> {
110 let Ok(current) = semver::Version::parse(current_version) else {
111 return Vec::new();
112 };
113 entries
114 .iter()
115 .filter(|entry| {
116 (entry.major, entry.minor, entry.patch) <= (current.major, current.minor, current.patch)
117 })
118 .max_by_key(|entry| (entry.major, entry.minor, entry.patch))
119 .cloned()
120 .into_iter()
121 .collect()
122}
123
124pub fn rpi_baseline<'a>(
127 entries: &[ChangelogEntry],
128 dedicated: Option<&'a str>,
129 legacy: Option<&'a str>,
130) -> Option<&'a str> {
131 dedicated.or_else(|| {
132 legacy.filter(|version| {
133 semver::Version::parse(version).is_ok_and(|version| {
134 entries.iter().any(|entry| {
135 (entry.major, entry.minor, entry.patch)
136 == (version.major, version.minor, version.patch)
137 })
138 })
139 })
140 })
141}
142
143#[derive(Debug, Default)]
144pub struct StartupChangelog {
145 pub markdown: Option<String>,
146 pub version_to_record: Option<String>,
147}
148
149pub fn startup_changelog(
152 entries: &[ChangelogEntry],
153 last_version: Option<&str>,
154 current_version: &str,
155 has_messages: bool,
156) -> StartupChangelog {
157 startup_changelog_for_profile(entries, last_version, current_version, has_messages, false)
158}
159
160pub fn startup_changelog_for_profile(
163 entries: &[ChangelogEntry],
164 last_version: Option<&str>,
165 current_version: &str,
166 has_messages: bool,
167 existing_profile: bool,
168) -> StartupChangelog {
169 if has_messages {
170 return StartupChangelog::default();
171 }
172 let Ok(current) = semver::Version::parse(current_version) else {
173 return StartupChangelog::default();
174 };
175 let Some(last) = last_version.and_then(|version| semver::Version::parse(version).ok()) else {
176 return StartupChangelog {
177 markdown: existing_profile
178 .then(|| {
179 let current_notes: Vec<_> = entries
180 .iter()
181 .filter(|entry| {
182 (entry.major, entry.minor, entry.patch)
183 == (current.major, current.minor, current.patch)
184 })
185 .cloned()
186 .collect();
187 format_entries(¤t_notes)
188 })
189 .filter(|markdown| !markdown.is_empty()),
190 version_to_record: Some(current_version.into()),
191 };
192 };
193 if last >= current {
194 return StartupChangelog::default();
195 }
196 let last = (last.major, last.minor, last.patch);
197 let current = (current.major, current.minor, current.patch);
198 let mut unseen: Vec<_> = entries
199 .iter()
200 .filter(|entry| {
201 let version = (entry.major, entry.minor, entry.patch);
202 version > last && version <= current
203 })
204 .cloned()
205 .collect();
206 unseen.sort_by_key(|entry| std::cmp::Reverse((entry.major, entry.minor, entry.patch)));
207 StartupChangelog {
208 markdown: (!unseen.is_empty()).then(|| format_entries(&unseen)),
209 version_to_record: Some(current_version.into()),
210 }
211}
212
213pub async fn fetch_changelog(allow_network: bool) -> Result<Option<String>, String> {
215 if !allow_network {
216 return Ok(None);
217 }
218 let client = reqwest::Client::builder()
219 .timeout(std::time::Duration::from_secs(15))
220 .user_agent(format!("rpi/{}", crate::VERSION))
221 .build()
222 .map_err(|e| e.to_string())?;
223 let response = client
224 .get(changelog_url())
225 .header("accept", "text/plain")
226 .send()
227 .await
228 .map_err(|e| format!("changelog request failed: {e}"))?;
229 if !response.status().is_success() {
230 return Err(format!(
231 "changelog request failed: {}",
232 response.status().as_u16()
233 ));
234 }
235 let text = response.text().await.map_err(|e| e.to_string())?;
236 Ok(Some(text))
237}
238
239pub async fn load_entries(allow_network: bool) -> Vec<ChangelogEntry> {
242 match fetch_changelog(allow_network).await {
243 Ok(Some(text)) => parse_changelog(&text),
244 _ => Vec::new(),
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn parses_versions() {
254 let text = "\
255# Changelog
256
257## 1.2.3
258- fixed a thing
259
260## 0.9.0
261- initial
262
263## v2.0.0-beta.1
264- future
265";
266 let entries = parse_changelog(text);
267 assert_eq!(entries.len(), 3);
268 assert_eq!(
269 (entries[0].major, entries[0].minor, entries[0].patch),
270 (1, 2, 3)
271 );
272 assert!(entries[0].content.contains("fixed a thing"));
273 assert_eq!(
274 (entries[2].major, entries[2].minor, entries[2].patch),
275 (2, 0, 0)
276 );
277 }
278
279 #[test]
280 fn ignores_non_version_headings() {
281 let text = "# Changelog\n\n## Unreleased\n- wip\n\n## 1.0.0\n- out\n";
282 let entries = parse_changelog(text);
283 assert_eq!(entries.len(), 1);
284 assert_eq!(entries[0].content, "- out");
285 }
286
287 #[test]
288 fn parses_keep_a_changelog_headings_and_bundled_notes() {
289 let entries = parse_changelog("## [Unreleased]\n- next\n\n## [0.3.17] - 2026-10-08\n### Fixed\n- reply spacing\n\n## [0.3.16] - 2026-10-06\n- previous");
290 assert_eq!(entries.len(), 2);
291 assert_eq!(
292 (entries[0].major, entries[0].minor, entries[0].patch),
293 (0, 3, 17)
294 );
295 assert!(entries[0].content.contains("reply spacing"));
296 assert!(!entries[0].content.contains("previous"));
297 assert!(!bundled_entries().is_empty());
298 }
299
300 #[test]
301 fn startup_notes_show_once_only_for_new_sessions_after_upgrade() {
302 let entries = parse_changelog(
303 "## [0.3.18]\n- future\n## [0.3.17]\n- current\n## [0.3.16]\n- previous",
304 );
305 let fresh = startup_changelog(&entries, None, "0.3.17", false);
306 assert!(fresh.markdown.is_none());
307 assert_eq!(fresh.version_to_record.as_deref(), Some("0.3.17"));
308 let upgrade = startup_changelog(&entries, Some("0.3.15"), "0.3.17", false);
309 let markdown = upgrade.markdown.unwrap();
310 assert!(markdown.contains("current"));
311 assert!(markdown.contains("previous"));
312 assert!(!markdown.contains("future"));
313 let again = startup_changelog(
314 &entries,
315 upgrade.version_to_record.as_deref(),
316 "0.3.17",
317 false,
318 );
319 assert!(again.markdown.is_none());
320 assert!(again.version_to_record.is_none());
321 let resumed = startup_changelog(&entries, Some("0.3.16"), "0.3.17", true);
322 assert!(resumed.markdown.is_none());
323 assert!(resumed.version_to_record.is_none());
324 assert!(startup_changelog(&entries, Some("0.3.16"), "0.3.17", false)
325 .markdown
326 .is_some());
327 let downgraded = startup_changelog(&entries, Some("0.3.18"), "0.3.17", false);
328 assert!(downgraded.markdown.is_none());
329 assert!(downgraded.version_to_record.is_none());
330 }
331
332 #[test]
333 fn foreign_baseline_does_not_hide_rpi_notes_and_the_new_marker_is_independent() {
334 let entries = parse_changelog("## [0.3.19]\n- current\n## [0.3.18]\n- previous");
335 assert_eq!(rpi_baseline(&entries, None, Some("0.85.1")), None);
336 let notice = startup_changelog_for_profile(
337 &entries,
338 rpi_baseline(&entries, None, Some("0.85.1")),
339 "0.3.19",
340 false,
341 true,
342 );
343 assert_eq!(notice.version_to_record.as_deref(), Some("0.3.19"));
344 let markdown = notice.markdown.unwrap();
345 assert!(markdown.contains("current"));
346 assert!(!markdown.contains("previous"));
347 let baseline = rpi_baseline(&entries, Some("0.3.19"), Some("0.86.0"));
348 let again = startup_changelog_for_profile(&entries, baseline, "0.3.19", false, true);
349 assert!(again.markdown.is_none());
350 assert!(again.version_to_record.is_none());
351 assert_eq!(rpi_baseline(&entries, None, Some("0.3.18")), Some("0.3.18"));
352 assert_eq!(
353 rpi_baseline(&entries, Some("0.3.20"), Some("0.85.1")),
354 Some("0.3.20")
355 );
356 assert!(
357 startup_changelog_for_profile(&entries, Some("0.3.20"), "0.3.19", false, true)
358 .version_to_record
359 .is_none()
360 );
361 }
362
363 #[test]
364 fn existing_profile_without_marker_defers_on_resume_and_shows_only_current_notes_once() {
365 let entries = parse_changelog("## [0.3.19]\n- current\n## [0.3.18]\n- previous");
366 let resumed = startup_changelog_for_profile(&entries, None, "0.3.19", true, true);
367 assert!(resumed.markdown.is_none());
368 assert!(resumed.version_to_record.is_none());
369 let notice = startup_changelog_for_profile(&entries, None, "0.3.19", false, true);
370 assert!(notice.markdown.unwrap().contains("current"));
371 let baseline = notice.version_to_record;
372 assert!(startup_changelog_for_profile(
373 &entries,
374 baseline.as_deref(),
375 "0.3.19",
376 false,
377 true
378 )
379 .markdown
380 .is_none());
381 let fresh = startup_changelog_for_profile(&entries, None, "0.3.19", false, false);
382 assert!(fresh.markdown.is_none());
383 assert_eq!(fresh.version_to_record.as_deref(), Some("0.3.19"));
384 }
385
386 #[test]
387 fn command_selects_one_latest_release_and_never_future_notes() {
388 let entries = parse_changelog(
389 "## [0.3.20]\n- future\n## [0.3.19]\n- current\n## [0.3.18]\n- previous",
390 );
391 let latest = latest_release(&entries, "0.3.19");
392 assert_eq!(latest.len(), 1);
393 assert_eq!(latest[0].content, "- current");
394 assert_eq!(latest_release(&entries, "0.3.21")[0].content, "- future");
395 }
396
397 #[test]
398 fn offline_is_empty() {
399 let rt = tokio::runtime::Builder::new_current_thread()
400 .enable_all()
401 .build()
402 .unwrap();
403 let entries = rt.block_on(load_entries(false));
404 assert!(entries.is_empty());
405 }
406}