1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct UpdateInfo {
14 pub current: String,
15 pub latest: String,
16 pub is_newer: bool,
17 pub download_url: Option<String>,
18 pub crate_url: String,
19 pub release_url: String,
20 pub install_cmd: String,
21}
22
23impl std::fmt::Display for UpdateInfo {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 write!(f, "v{} → v{}", self.current, self.latest)
27 }
28}
29
30impl UpdateInfo {
31 pub fn up_to_date(current: &str) -> Self {
32 UpdateInfo {
33 current: current.to_string(),
34 latest: current.to_string(),
35 is_newer: false,
36 download_url: None,
37 crate_url: format!("https://crates.io/crates/sparrow-cli"),
38 release_url: format!("https://github.com/ucav/Sparrow/releases"),
39 install_cmd: "cargo install sparrow-cli".to_string(),
40 }
41 }
42}
43
44const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
45const GITHUB_API: &str = "https://api.github.com/repos/ucav/Sparrow/releases/latest";
46const CRATES_API: &str = "https://crates.io/api/v1/crates/sparrow-cli";
47
48pub fn check_update() -> Option<UpdateInfo> {
53 if let Some(info) = check_github() {
55 if info.is_newer {
56 return Some(info);
57 }
58 }
59
60 if let Some(info) = check_cratesio() {
62 if info.is_newer {
63 return Some(info);
64 }
65 }
66
67 None
68}
69
70pub fn self_update() -> anyhow::Result<String> {
73 let current = CURRENT_VERSION;
74
75 let latest = match check_github() {
77 Some(info) if info.is_newer => info.latest,
78 _ => match check_cratesio() {
79 Some(info) if info.is_newer => info.latest,
80 _ => return Ok(format!("Already up to date (v{}). 🐦", current)),
81 },
82 };
83
84 let platform = if cfg!(target_os = "linux") {
85 "linux-x86_64"
86 } else if cfg!(target_os = "macos") {
87 "macos-arm64"
88 } else if cfg!(target_os = "windows") {
89 "windows-x86_64.exe"
90 } else {
91 anyhow::bail!("Unsupported platform for auto-update. Use: cargo install sparrow-cli");
92 };
93
94 let download_url = format!(
95 "https://github.com/ucav/Sparrow/releases/download/v{}/sparrow-{}",
96 latest, platform
97 );
98
99 let bin_path = std::env::current_exe()?;
100 let new_bin = bin_path.with_extension("new");
101
102 let client = reqwest::blocking::Client::builder()
104 .user_agent("sparrow-updater")
105 .timeout(std::time::Duration::from_secs(120))
106 .build()?;
107
108 let response = client.get(&download_url).send()?;
109 if !response.status().is_success() {
110 anyhow::bail!(
111 "Download failed ({}). Try: {}",
112 response.status(),
113 "cargo install sparrow-cli"
114 );
115 }
116
117 let bytes = response.bytes()?;
118 std::fs::write(&new_bin, &bytes)?;
119
120 #[cfg(windows)]
122 {
123 let old_bin = bin_path.with_extension("old");
124 std::fs::rename(&bin_path, &old_bin)?;
125 std::fs::rename(&new_bin, &bin_path)?;
126 let _ = std::fs::remove_file(&old_bin);
127 }
128 #[cfg(not(windows))]
129 {
130 std::fs::rename(&new_bin, &bin_path)?;
131 use std::os::unix::fs::PermissionsExt;
132 let mut perms = std::fs::metadata(&bin_path)?.permissions();
133 perms.set_mode(0o755);
134 std::fs::set_permissions(&bin_path, perms)?;
135 }
136
137 Ok(format!(
138 "Updated from v{} → v{}. Restart Sparrow to apply. 🐦",
139 current, latest
140 ))
141}
142
143fn check_github() -> Option<UpdateInfo> {
146 let client = reqwest::blocking::Client::builder()
147 .user_agent("sparrow-update-check")
148 .timeout(std::time::Duration::from_secs(5))
149 .build()
150 .ok()?;
151
152 let resp: serde_json::Value = client.get(GITHUB_API).send().ok()?.json().ok()?;
153
154 let latest = resp["tag_name"]
155 .as_str()
156 .unwrap_or("v0.0.0")
157 .trim_start_matches('v');
158
159 let is_newer = is_newer_version(latest, CURRENT_VERSION);
160
161 let platform = if cfg!(target_os = "linux") {
163 "linux-x86_64"
164 } else if cfg!(target_os = "macos") {
165 "macos-arm64"
166 } else if cfg!(target_os = "windows") {
167 "windows-x86_64.exe"
168 } else {
169 return Some(UpdateInfo {
170 current: CURRENT_VERSION.to_string(),
171 latest: latest.to_string(),
172 is_newer,
173 download_url: None,
174 crate_url: "https://crates.io/crates/sparrow-cli".to_string(),
175 release_url: format!("https://github.com/ucav/Sparrow/releases/tag/v{}", latest),
176 install_cmd: "cargo install sparrow-cli".to_string(),
177 });
178 };
179
180 Some(UpdateInfo {
181 current: CURRENT_VERSION.to_string(),
182 latest: latest.to_string(),
183 is_newer,
184 download_url: Some(format!(
185 "https://github.com/ucav/Sparrow/releases/download/v{}/sparrow-{}",
186 latest, platform
187 )),
188 crate_url: "https://crates.io/crates/sparrow-cli".to_string(),
189 release_url: format!("https://github.com/ucav/Sparrow/releases/tag/v{}", latest),
190 install_cmd: "cargo install sparrow-cli".to_string(),
191 })
192}
193
194fn check_cratesio() -> Option<UpdateInfo> {
195 let client = reqwest::blocking::Client::builder()
196 .user_agent("sparrow-update-check (crates.io)")
197 .timeout(std::time::Duration::from_secs(5))
198 .build()
199 .ok()?;
200
201 let resp: serde_json::Value = client.get(CRATES_API).send().ok()?.json().ok()?;
202
203 let latest = resp["crate"]["max_stable_version"]
204 .as_str()
205 .or_else(|| resp["crate"]["max_version"].as_str())
206 .unwrap_or("0.0.0");
207
208 let is_newer = is_newer_version(latest, CURRENT_VERSION);
209
210 Some(UpdateInfo {
211 current: CURRENT_VERSION.to_string(),
212 latest: latest.to_string(),
213 is_newer,
214 download_url: None,
215 crate_url: "https://crates.io/crates/sparrow-cli".to_string(),
216 release_url: "https://github.com/ucav/Sparrow/releases".to_string(),
217 install_cmd: "cargo install sparrow-cli".to_string(),
218 })
219}
220
221fn is_newer_version(latest: &str, current: &str) -> bool {
224 let parse = |v: &str| -> Vec<u32> {
225 v.split(|c: char| !c.is_ascii_digit())
226 .filter(|s| !s.is_empty())
227 .filter_map(|s| s.parse::<u32>().ok())
228 .collect()
229 };
230
231 let latest_parts = parse(latest);
232 let current_parts = parse(current);
233
234 for (l, c) in latest_parts.iter().zip(current_parts.iter()) {
235 if l > c {
236 return true;
237 }
238 if l < c {
239 return false;
240 }
241 }
242
243 if latest_parts.len() > current_parts.len() {
246 return true;
247 }
248
249 false
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 #[test]
257 fn test_version_comparison() {
258 assert!(is_newer_version("0.7.1", "0.7.0"));
259 assert!(is_newer_version("1.0.0", "0.9.9"));
260 assert!(is_newer_version("0.8.0", "0.7.9"));
261 assert!(!is_newer_version("0.7.0", "0.7.0"));
262 assert!(!is_newer_version("0.6.9", "0.7.0"));
263 assert!(!is_newer_version("0.7.0", "0.7.1"));
264 }
265
266 #[test]
267 fn test_version_with_prefix() {
268 assert!(is_newer_version("v0.7.1", "0.7.0"));
269 assert!(is_newer_version("v1.0.0", "v0.9.9"));
270 }
271
272 #[test]
273 fn test_update_info_up_to_date() {
274 let info = UpdateInfo::up_to_date("0.7.0");
275 assert!(!info.is_newer);
276 assert_eq!(info.current, info.latest);
277 }
278}