1use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct ChangelogEntry {
16 pub major: u64,
17 pub minor: u64,
18 pub patch: u64,
19 pub content: String,
21}
22
23pub const DEFAULT_CHANGELOG_URL: &str =
25 "https://raw.githubusercontent.com/bigfish1913/pi-rust/main/CHANGELOG.md";
26
27pub fn changelog_url() -> String {
29 std::env::var("RPI_CHANGELOG_URL").unwrap_or_else(|_| DEFAULT_CHANGELOG_URL.to_string())
30}
31
32fn 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
49pub 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
86pub 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
112pub 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}