Skip to main content

rpi_cli/
changelog.rs

1//! Changelog fetching and parsing.
2//!
3//! Port of the upstream changelog utility.
4//! reads a local `CHANGELOG.md` when present and can otherwise fetch it from
5//! GitHub. rpi keeps the same split: parse a local/remote markdown changelog into
6//! versioned entries.
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
13/// One released version's changelog section.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct ChangelogEntry {
16    pub major: u64,
17    pub minor: u64,
18    pub patch: u64,
19    /// The section body (everything under the heading, trimmed).
20    pub content: String,
21}
22
23/// Default remote changelog URL. Overridable via `RPI_CHANGELOG_URL`.
24pub const DEFAULT_CHANGELOG_URL: &str =
25    "https://raw.githubusercontent.com/bigfish1913/pi-rust/main/CHANGELOG.md";
26
27/// The remote changelog URL to use, honoring `RPI_CHANGELOG_URL`.
28pub fn changelog_url() -> String {
29    std::env::var("RPI_CHANGELOG_URL").unwrap_or_else(|_| DEFAULT_CHANGELOG_URL.to_string())
30}
31
32/// Parse a semver-ish heading like `## 1.2.3` / `# 1.2.3` / `## v1.2.3`.
33fn parse_heading(line: &str) -> Option<(u64, u64, u64)> {
34    let trimmed = line.trim_start_matches('#').trim();
35    let trimmed = trimmed.strip_prefix('v').unwrap_or(trimmed);
36    let version = trimmed.split_whitespace().next()?;
37    let mut parts = version.split('.');
38    let major = parts.next()?.parse().ok()?;
39    let minor = parts.next().unwrap_or("0").parse().unwrap_or(0);
40    let patch = parts
41        .next()
42        .map(|p| p.split(['-', '+']).next().unwrap_or(p))
43        .unwrap_or("0")
44        .parse()
45        .unwrap_or(0);
46    Some((major, minor, patch))
47}
48
49/// Split a changelog into versioned entries (native `parseChangelog`).
50pub fn parse_changelog(text: &str) -> Vec<ChangelogEntry> {
51    let mut entries: Vec<ChangelogEntry> = Vec::new();
52    let mut current: Option<ChangelogEntry> = None;
53
54    for line in text.lines() {
55        let is_heading = line.trim_start().starts_with('#');
56        if is_heading {
57            if let Some((major, minor, patch)) = parse_heading(line) {
58                if let Some(entry) = current.take() {
59                    entries.push(finalize(entry));
60                }
61                current = Some(ChangelogEntry {
62                    major,
63                    minor,
64                    patch,
65                    content: String::new(),
66                });
67                continue;
68            }
69        }
70        if let Some(entry) = current.as_mut() {
71            entry.content.push_str(line);
72            entry.content.push('\n');
73        }
74    }
75    if let Some(entry) = current.take() {
76        entries.push(finalize(entry));
77    }
78    entries
79}
80
81fn finalize(mut entry: ChangelogEntry) -> ChangelogEntry {
82    entry.content = entry.content.trim().to_string();
83    entry
84}
85
86/// Fetch the changelog body. `allow_network == false` returns `Ok(None)`.
87pub async fn fetch_changelog(allow_network: bool) -> Result<Option<String>, String> {
88    if !allow_network {
89        return Ok(None);
90    }
91    let client = reqwest::Client::builder()
92        .timeout(std::time::Duration::from_secs(15))
93        .user_agent(format!("rpi/{}", crate::VERSION))
94        .build()
95        .map_err(|e| e.to_string())?;
96    let response = client
97        .get(changelog_url())
98        .header("accept", "text/plain")
99        .send()
100        .await
101        .map_err(|e| format!("changelog request failed: {e}"))?;
102    if !response.status().is_success() {
103        return Err(format!(
104            "changelog request failed: {}",
105            response.status().as_u16()
106        ));
107    }
108    let text = response.text().await.map_err(|e| e.to_string())?;
109    Ok(Some(text))
110}
111
112/// Fetch and parse the changelog, newest first. Best-effort: a network failure
113/// yields an empty list rather than an error.
114pub async fn load_entries(allow_network: bool) -> Vec<ChangelogEntry> {
115    match fetch_changelog(allow_network).await {
116        Ok(Some(text)) => parse_changelog(&text),
117        _ => Vec::new(),
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn parses_versions() {
127        let text = "\
128# Changelog
129
130## 1.2.3
131- fixed a thing
132
133## 0.9.0
134- initial
135
136## v2.0.0-beta.1
137- future
138";
139        let entries = parse_changelog(text);
140        assert_eq!(entries.len(), 3);
141        assert_eq!(
142            (entries[0].major, entries[0].minor, entries[0].patch),
143            (1, 2, 3)
144        );
145        assert!(entries[0].content.contains("fixed a thing"));
146        assert_eq!(
147            (entries[2].major, entries[2].minor, entries[2].patch),
148            (2, 0, 0)
149        );
150    }
151
152    #[test]
153    fn ignores_non_version_headings() {
154        let text = "# Changelog\n\n## Unreleased\n- wip\n\n## 1.0.0\n- out\n";
155        let entries = parse_changelog(text);
156        assert_eq!(entries.len(), 1);
157        assert_eq!(entries[0].content, "- out");
158    }
159
160    #[test]
161    fn offline_is_empty() {
162        let rt = tokio::runtime::Builder::new_current_thread()
163            .enable_all()
164            .build()
165            .unwrap();
166        let entries = rt.block_on(load_entries(false));
167        assert!(entries.is_empty());
168    }
169}