use serde::{Deserialize, Serialize};
const BUNDLED_CHANGELOG: &str = include_str!("../embedded-docs/changelog.md");
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChangelogEntry {
pub major: u64,
pub minor: u64,
pub patch: u64,
pub content: String,
}
pub const DEFAULT_CHANGELOG_URL: &str =
"https://raw.githubusercontent.com/bigfish1913/pi-rust/main/CHANGELOG.md";
pub fn changelog_url() -> String {
std::env::var("RPI_CHANGELOG_URL").unwrap_or_else(|_| DEFAULT_CHANGELOG_URL.to_string())
}
fn parse_heading(line: &str) -> Option<(u64, u64, u64)> {
let trimmed = line.trim_start_matches('#').trim();
let version = trimmed.split_whitespace().next()?.trim_matches(['[', ']']);
let version = version.strip_prefix('v').unwrap_or(version);
let mut parts = version.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next().unwrap_or("0").parse().unwrap_or(0);
let patch = parts
.next()
.map(|p| p.split(['-', '+']).next().unwrap_or(p))
.unwrap_or("0")
.parse()
.unwrap_or(0);
Some((major, minor, patch))
}
pub fn parse_changelog(text: &str) -> Vec<ChangelogEntry> {
let mut entries: Vec<ChangelogEntry> = Vec::new();
let mut current: Option<ChangelogEntry> = None;
for line in text.lines() {
let is_heading = line.trim_start().starts_with('#');
if is_heading {
if let Some((major, minor, patch)) = parse_heading(line) {
if let Some(entry) = current.take() {
entries.push(finalize(entry));
}
current = Some(ChangelogEntry {
major,
minor,
patch,
content: String::new(),
});
continue;
}
}
if let Some(entry) = current.as_mut() {
entry.content.push_str(line);
entry.content.push('\n');
}
}
if let Some(entry) = current.take() {
entries.push(finalize(entry));
}
entries
}
fn finalize(mut entry: ChangelogEntry) -> ChangelogEntry {
entry.content = entry.content.trim().to_string();
entry
}
pub fn bundled_entries() -> Vec<ChangelogEntry> {
let mut entries = parse_changelog(BUNDLED_CHANGELOG);
entries.sort_by_key(|entry| std::cmp::Reverse((entry.major, entry.minor, entry.patch)));
entries
}
pub fn format_entries(entries: &[ChangelogEntry]) -> String {
entries
.iter()
.map(|entry| {
format!(
"## {}.{}.{}\n\n{}",
entry.major, entry.minor, entry.patch, entry.content
)
})
.collect::<Vec<_>>()
.join("\n\n")
}
#[derive(Debug, Default)]
pub struct StartupChangelog {
pub markdown: Option<String>,
pub version_to_record: Option<String>,
}
pub fn startup_changelog(
entries: &[ChangelogEntry],
last_version: Option<&str>,
current_version: &str,
has_messages: bool,
) -> StartupChangelog {
if has_messages {
return StartupChangelog::default();
}
let Ok(current) = semver::Version::parse(current_version) else {
return StartupChangelog::default();
};
let Some(last) = last_version.and_then(|version| semver::Version::parse(version).ok()) else {
return StartupChangelog {
markdown: None,
version_to_record: Some(current_version.into()),
};
};
if last >= current {
return StartupChangelog::default();
}
let last = (last.major, last.minor, last.patch);
let current = (current.major, current.minor, current.patch);
let mut unseen: Vec<_> = entries
.iter()
.filter(|entry| {
let version = (entry.major, entry.minor, entry.patch);
version > last && version <= current
})
.cloned()
.collect();
unseen.sort_by_key(|entry| std::cmp::Reverse((entry.major, entry.minor, entry.patch)));
if unseen.is_empty() {
return StartupChangelog::default();
}
StartupChangelog {
markdown: Some(format_entries(&unseen)),
version_to_record: Some(current_version.into()),
}
}
pub async fn fetch_changelog(allow_network: bool) -> Result<Option<String>, String> {
if !allow_network {
return Ok(None);
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.user_agent(format!("rpi/{}", crate::VERSION))
.build()
.map_err(|e| e.to_string())?;
let response = client
.get(changelog_url())
.header("accept", "text/plain")
.send()
.await
.map_err(|e| format!("changelog request failed: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"changelog request failed: {}",
response.status().as_u16()
));
}
let text = response.text().await.map_err(|e| e.to_string())?;
Ok(Some(text))
}
pub async fn load_entries(allow_network: bool) -> Vec<ChangelogEntry> {
match fetch_changelog(allow_network).await {
Ok(Some(text)) => parse_changelog(&text),
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_versions() {
let text = "\
# Changelog
## 1.2.3
- fixed a thing
## 0.9.0
- initial
## v2.0.0-beta.1
- future
";
let entries = parse_changelog(text);
assert_eq!(entries.len(), 3);
assert_eq!(
(entries[0].major, entries[0].minor, entries[0].patch),
(1, 2, 3)
);
assert!(entries[0].content.contains("fixed a thing"));
assert_eq!(
(entries[2].major, entries[2].minor, entries[2].patch),
(2, 0, 0)
);
}
#[test]
fn ignores_non_version_headings() {
let text = "# Changelog\n\n## Unreleased\n- wip\n\n## 1.0.0\n- out\n";
let entries = parse_changelog(text);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].content, "- out");
}
#[test]
fn parses_keep_a_changelog_headings_and_bundled_notes() {
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");
assert_eq!(entries.len(), 2);
assert_eq!(
(entries[0].major, entries[0].minor, entries[0].patch),
(0, 3, 17)
);
assert!(entries[0].content.contains("reply spacing"));
assert!(!entries[0].content.contains("previous"));
assert!(!bundled_entries().is_empty());
}
#[test]
fn startup_notes_show_once_only_for_new_sessions_after_upgrade() {
let entries = parse_changelog(
"## [0.3.18]\n- future\n## [0.3.17]\n- current\n## [0.3.16]\n- previous",
);
let fresh = startup_changelog(&entries, None, "0.3.17", false);
assert!(fresh.markdown.is_none());
assert_eq!(fresh.version_to_record.as_deref(), Some("0.3.17"));
let upgrade = startup_changelog(&entries, Some("0.3.15"), "0.3.17", false);
let markdown = upgrade.markdown.unwrap();
assert!(markdown.contains("current"));
assert!(markdown.contains("previous"));
assert!(!markdown.contains("future"));
let again = startup_changelog(
&entries,
upgrade.version_to_record.as_deref(),
"0.3.17",
false,
);
assert!(again.markdown.is_none());
assert!(again.version_to_record.is_none());
let resumed = startup_changelog(&entries, Some("0.3.16"), "0.3.17", true);
assert!(resumed.markdown.is_none());
assert!(resumed.version_to_record.is_none());
assert!(startup_changelog(&entries, Some("0.3.16"), "0.3.17", false)
.markdown
.is_some());
let downgraded = startup_changelog(&entries, Some("0.3.18"), "0.3.17", false);
assert!(downgraded.markdown.is_none());
assert!(downgraded.version_to_record.is_none());
}
#[test]
fn offline_is_empty() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let entries = rt.block_on(load_entries(false));
assert!(entries.is_empty());
}
}