Skip to main content

doiget_cli/commands/
version.rs

1// allow: outbound-network
2//! `doiget version [--check]` — print the current version and optionally
3//! query GitHub Releases for the latest stable tag.
4//!
5//! Without `--check` the command prints the compiled-in version string and
6//! exits immediately (no network, no side-effects).
7//!
8//! With `--check` it queries the GitHub Releases API, compares with the
9//! compiled-in version, and emits a machine-readable JSON object:
10//!
11//! ```json
12//! {
13//!   "current":          "0.4.1-beta.0",
14//!   "latest":           "0.4.0",
15//!   "newer_available":  false,
16//!   "html_url":         "https://github.com/…/releases/tag/v0.4.0"
17//! }
18//! ```
19//!
20//! When the check fails (rate-limited, network down) the `latest` and
21//! `html_url` fields are `null` and an `error` key is populated instead,
22//! so callers never receive a hard error just because GitHub is slow.
23
24use 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
36/// Connect + read timeout for the version check HTTP request.
37const CHECK_TIMEOUT: Duration = Duration::from_secs(10);
38
39/// Return the releases endpoint URL, honouring `DOIGET_GITHUB_BASE` for
40/// integration tests (same override pattern as `DOIGET_CROSSREF_BASE` etc.).
41///
42/// Returns an error when `DOIGET_GITHUB_BASE` is set but not a valid URL.
43fn 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/// Output shape for `--check` in JSON mode.
58///
59/// On failure `latest`, `newer_available`, and `html_url` are `null`;
60/// `error` carries a human-readable reason. These `null` fields are stable
61/// wire format — they are present, not absent, in the error path.
62#[derive(Debug, Serialize)]
63pub struct VersionCheckResult {
64    /// Current compiled-in version.
65    pub current: &'static str,
66    /// Latest stable tag without the `v` prefix (e.g. `"0.4.0"`).
67    /// `null` when the check could not complete.
68    pub latest: Option<String>,
69    /// `true` when `latest` is set and strictly newer than `current`.
70    /// `null` when `latest` is `null`.
71    pub newer_available: Option<bool>,
72    /// GitHub release page URL. `null` when the check could not complete.
73    pub html_url: Option<String>,
74    /// Human-readable error reason when the check failed.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub error: Option<String>,
77}
78
79/// Entry point for `doiget version [--check]`.
80pub 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
131/// Query the GitHub Releases API and return the latest stable (non-prerelease,
132/// non-draft) release. Returns a populated `error` field on any failure so
133/// callers are never hard-blocked by a network hiccup.
134async 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    // First non-draft, non-prerelease release without a pre-release suffix.
175    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
212/// Return `true` when `candidate` is strictly newer than `current` by
213/// dot-separated numeric comparison on the version core (before any `-`
214/// pre-release suffix). Both vectors are padded to equal length so that
215/// `"1.0"` and `"1.0.0"` compare equal rather than the shorter one
216/// appearing lesser.
217fn 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
230/// Return `true` when a version string has a known pre-release suffix.
231fn 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        // "1.0" and "1.0.0" must compare equal, not report "1.0" as older.
258        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}