doiget_cli/commands/
version.rs1use std::io::Write;
25use std::time::Duration;
26
27use anyhow::Result;
28use serde::Serialize;
29
30use super::output::OutputMode;
31
32const CURRENT: &str = env!("CARGO_PKG_VERSION");
33
34const RELEASES_API: &str = "https://api.github.com/repos/sotashimozono/doiget/releases";
35
36const CHECK_TIMEOUT: Duration = Duration::from_secs(10);
38
39fn releases_url() -> anyhow::Result<String> {
44 match std::env::var("DOIGET_GITHUB_BASE").ok() {
45 Some(base) => {
46 url::Url::parse(&base)
47 .map_err(|e| anyhow::anyhow!("invalid DOIGET_GITHUB_BASE: {e}"))?;
48 Ok(format!(
49 "{}/repos/sotashimozono/doiget/releases",
50 base.trim_end_matches('/')
51 ))
52 }
53 None => Ok(RELEASES_API.to_string()),
54 }
55}
56
57#[derive(Debug, Serialize)]
63pub struct VersionCheckResult {
64 pub current: &'static str,
66 pub latest: Option<String>,
69 pub newer_available: Option<bool>,
72 pub html_url: Option<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub error: Option<String>,
77}
78
79pub async fn run(check: bool, mode: OutputMode) -> Result<()> {
81 if mode == OutputMode::Quiet || mode == OutputMode::Mcp {
82 return Ok(());
83 }
84
85 if !check {
86 let stdout = std::io::stdout();
87 let mut out = stdout.lock();
88 if mode == OutputMode::Json {
89 writeln!(
90 out,
91 "{}",
92 serde_json::to_string_pretty(&serde_json::json!({ "version": CURRENT }))?
93 )?;
94 } else {
95 writeln!(out, "doiget {CURRENT}")?;
96 }
97 return Ok(());
98 }
99
100 let result = fetch_latest().await;
101
102 let stdout = std::io::stdout();
103 let mut out = stdout.lock();
104
105 if mode == OutputMode::Json {
106 writeln!(out, "{}", serde_json::to_string_pretty(&result)?)?;
107 } else {
108 match (&result.latest, &result.newer_available) {
109 (Some(latest), Some(true)) => {
110 writeln!(out, "doiget {CURRENT} — update available: {latest}")?;
111 if let Some(url) = &result.html_url {
112 writeln!(out, " {url}")?;
113 }
114 }
115 (Some(latest), _) => {
116 writeln!(
117 out,
118 "doiget {CURRENT} — up to date (latest stable: {latest})"
119 )?;
120 }
121 (None, _) => {
122 let reason = result.error.as_deref().unwrap_or("unknown");
123 writeln!(out, "doiget {CURRENT} — version check failed: {reason}")?;
124 }
125 }
126 }
127
128 Ok(())
129}
130
131async fn fetch_latest() -> VersionCheckResult {
135 match try_fetch_latest().await {
136 Ok(r) => r,
137 Err(e) => VersionCheckResult {
138 current: CURRENT,
139 latest: None,
140 newer_available: None,
141 html_url: None,
142 error: Some(e.to_string()),
143 },
144 }
145}
146
147async fn try_fetch_latest() -> anyhow::Result<VersionCheckResult> {
148 doiget_core::http::init_tls();
149 let client = reqwest::Client::builder()
150 .user_agent(format!("doiget/{CURRENT}"))
151 .timeout(CHECK_TIMEOUT)
152 .build()?;
153
154 let url = releases_url()?;
155
156 let resp = client.get(url).send().await.map_err(|e| {
157 if e.is_connect() || e.is_timeout() {
158 anyhow::anyhow!("unreachable")
159 } else {
160 anyhow::anyhow!("{e}")
161 }
162 })?;
163
164 if resp.status().as_u16() == 403 {
165 return Err(anyhow::anyhow!("rate_limited"));
166 }
167
168 let releases: serde_json::Value = resp.error_for_status()?.json().await?;
169
170 let arr = releases
171 .as_array()
172 .ok_or_else(|| anyhow::anyhow!("unexpected API shape"))?;
173
174 let stable = arr.iter().find(|r| {
176 let draft = r.get("draft").and_then(|v| v.as_bool()).unwrap_or(true);
177 let prerelease = r
178 .get("prerelease")
179 .and_then(|v| v.as_bool())
180 .unwrap_or(true);
181 if draft || prerelease {
182 return false;
183 }
184 let tag = r.get("tag_name").and_then(|v| v.as_str()).unwrap_or("");
185 let bare = tag.strip_prefix('v').unwrap_or(tag);
186 !has_prerelease_suffix(bare)
187 });
188
189 let Some(release) = stable else {
190 return Err(anyhow::anyhow!("no stable release found"));
191 };
192
193 let tag = release
194 .get("tag_name")
195 .and_then(|v| v.as_str())
196 .ok_or_else(|| anyhow::anyhow!("missing tag_name"))?;
197 let html_url = release
198 .get("html_url")
199 .and_then(|v| v.as_str())
200 .map(String::from);
201 let latest = tag.strip_prefix('v').unwrap_or(tag).to_string();
202
203 Ok(VersionCheckResult {
204 current: CURRENT,
205 newer_available: Some(is_newer(&latest, CURRENT)),
206 latest: Some(latest),
207 html_url,
208 error: None,
209 })
210}
211
212fn is_newer(candidate: &str, current: &str) -> bool {
218 let parse = |s: &str| -> Vec<u64> {
219 let core = s.split('-').next().unwrap_or(s);
220 core.split('.').map(|p| p.parse().unwrap_or(0)).collect()
221 };
222 let mut a = parse(candidate);
223 let mut b = parse(current);
224 let len = a.len().max(b.len());
225 a.resize(len, 0);
226 b.resize(len, 0);
227 a > b
228}
229
230fn has_prerelease_suffix(version: &str) -> bool {
232 let lower = version.to_ascii_lowercase();
233 lower.contains("-beta") || lower.contains("-alpha") || lower.contains("-rc")
234}
235
236#[cfg(test)]
237#[allow(clippy::expect_used, clippy::unwrap_used)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn is_newer_detects_upgrade() {
243 assert!(is_newer("0.5.0", "0.4.0"));
244 assert!(is_newer("0.4.1", "0.4.0"));
245 assert!(!is_newer("0.4.0", "0.4.0"));
246 assert!(!is_newer("0.3.9", "0.4.0"));
247 }
248
249 #[test]
250 fn is_newer_ignores_prerelease_suffix_in_current() {
251 assert!(!is_newer("0.4.0", "0.4.1-beta.0"));
252 assert!(is_newer("0.5.0", "0.4.1-beta.0"));
253 }
254
255 #[test]
256 fn is_newer_pads_mismatched_component_counts() {
257 assert!(!is_newer("1.0", "1.0.0"));
259 assert!(!is_newer("1.0.0", "1.0"));
260 assert!(is_newer("1.0.1", "1.0"));
261 }
262
263 #[test]
264 fn has_prerelease_suffix_cases() {
265 assert!(has_prerelease_suffix("0.4.1-beta.0"));
266 assert!(has_prerelease_suffix("1.0.0-alpha"));
267 assert!(has_prerelease_suffix("1.0.0-rc.1"));
268 assert!(!has_prerelease_suffix("0.4.0"));
269 }
270
271 #[test]
272 fn releases_url_rejects_invalid_base() {
273 std::env::set_var("DOIGET_GITHUB_BASE", "not a url !!!");
274 let result = releases_url();
275 std::env::remove_var("DOIGET_GITHUB_BASE");
276 assert!(result.is_err(), "invalid base must return Err");
277 }
278
279 #[test]
280 fn releases_url_falls_back_to_production_when_unset() {
281 std::env::remove_var("DOIGET_GITHUB_BASE");
282 let url = releases_url().expect("fallback must succeed");
283 assert_eq!(url, RELEASES_API);
284 }
285}