Skip to main content

rpi_cli/
changelog.rs

1//! Changelog fetching and parsing.
2//!
3//! Port of the upstream changelog utility.
4//! Bundled release notes power startup notices and `/changelog` without a
5//! source checkout or network request. Optional remote fetching remains
6//! available to consumers, with the same versioned-entry parser.
7//!
8//! The remote URL is overridable with `RPI_CHANGELOG_URL` so a fork can point at
9//! its own changelog without a rebuild.
10
11use serde::{Deserialize, Serialize};
12
13const BUNDLED_CHANGELOG: &str = include_str!("../embedded-docs/changelog.md");
14
15/// One released version's changelog section.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct ChangelogEntry {
18    pub major: u64,
19    pub minor: u64,
20    pub patch: u64,
21    /// The section body (everything under the heading, trimmed).
22    pub content: String,
23}
24
25/// Default remote changelog URL. Overridable via `RPI_CHANGELOG_URL`.
26pub const DEFAULT_CHANGELOG_URL: &str =
27    "https://raw.githubusercontent.com/bigfish1913/pi-rust/main/CHANGELOG.md";
28
29/// The remote changelog URL to use, honoring `RPI_CHANGELOG_URL`.
30pub fn changelog_url() -> String {
31    std::env::var("RPI_CHANGELOG_URL").unwrap_or_else(|_| DEFAULT_CHANGELOG_URL.to_string())
32}
33
34/// Parse a semver-ish heading like `## 1.2.3` / `# 1.2.3` / `## v1.2.3`.
35fn 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
51/// Split a changelog into versioned entries (native `parseChangelog`).
52pub 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
88/// Release notes shipped with this binary, usable without network or checkout.
89pub 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
108/// Default command view: the latest shipped release at or below this binary's version.
109pub 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
124/// Migrate only legacy markers that name a release in rpi's bundled history.
125/// A foreign version (for example Pi 0.85.1) must not suppress rpi updates.
126pub 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
149/// Pi parity: first install records a baseline; upgrades show unseen releases;
150/// existing transcripts and downgrades do not consume or regress that baseline.
151pub 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
160/// Existing profiles without an rpi baseline see the current release once;
161/// genuinely fresh installs record a baseline without replaying old history.
162pub 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(&current_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
213/// Fetch the changelog body. `allow_network == false` returns `Ok(None)`.
214pub 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
239/// Fetch and parse the changelog, newest first. Best-effort: a network failure
240/// yields an empty list rather than an error.
241pub 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}