Skip to main content

bee_tui/
version_check.rs

1//! `:check-version` — long-running Bee operators silently drift
2//! behind upstream. This module hits GitHub's `releases/latest` for
3//! `ethersphere/bee` and pairs the answer with the running Bee
4//! version (read from `/health`) so operators see "running 2.7.2 /
5//! latest 2.8.0 (released 2026-04-15)" at a glance.
6//!
7//! Read-only — informational only. No auto-update, no chain
8//! interaction. Honours bee-tui's "inform, don't act" stance.
9//!
10//! GitHub's unauthenticated REST API allows ~60 requests/hour per
11//! source IP; an interactive cockpit operator burns ≪ 1/hr so we
12//! don't need a cache. CI users hitting `--once check-version` from
13//! a CD pipeline should rate-limit at the workflow level.
14
15use std::time::Duration;
16
17use serde::Deserialize;
18
19/// Outcome of `:check-version`. Drift is intentionally a string the
20/// operator reads, not a semver subtraction — different version
21/// schemes (release candidates, pre-releases, dirty builds) make a
22/// `latest - running` integer ambiguous.
23#[derive(Debug, Clone)]
24pub struct VersionStatus {
25    /// Version string from Bee's `/health`. Often includes a git-sha
26    /// suffix (`2.7.2-bcaf69d-dirty`).
27    pub running: Option<String>,
28    /// `tag_name` from GitHub's `releases/latest` (e.g. `v2.8.0`).
29    pub latest_tag: String,
30    /// Human title (`v2.8.0`) and `published_at` (RFC 3339).
31    pub latest_published_at: String,
32    pub latest_html_url: String,
33    /// True iff `running` parses as a SemVer Major.Minor.Patch and
34    /// `latest_tag` parses likewise and the two differ. False when
35    /// they appear identical OR when either is unparseable.
36    pub drift_detected: bool,
37}
38
39impl VersionStatus {
40    pub fn summary(&self) -> String {
41        let running = self
42            .running
43            .clone()
44            .unwrap_or_else(|| "(unknown)".to_string());
45        let drift_marker = if self.drift_detected {
46            " · DRIFT — upgrade available"
47        } else {
48            ""
49        };
50        format!(
51            "running {} / latest {} ({} · {}){drift_marker}",
52            running, self.latest_tag, self.latest_published_at, self.latest_html_url,
53        )
54    }
55}
56
57#[derive(Deserialize)]
58struct GitHubRelease {
59    tag_name: String,
60    html_url: String,
61    published_at: String,
62}
63
64/// Fetch GitHub's `releases/latest` for `ethersphere/bee`. Pure async;
65/// the caller passes the running Bee version (or `None`) so this
66/// module stays Bee-API-free.
67pub async fn check_latest(running_version: Option<String>) -> Result<VersionStatus, String> {
68    let client = reqwest::Client::builder()
69        .timeout(Duration::from_secs(10))
70        // GitHub rejects requests without a User-Agent. We send our
71        // crate name + version verbatim — easy to grep for in
72        // GitHub's logs if rate-limited.
73        .user_agent(concat!("bee-tui/", env!("CARGO_PKG_VERSION")))
74        .build()
75        .map_err(|e| format!("client build: {e}"))?;
76    let resp = client
77        .get("https://api.github.com/repos/ethersphere/bee/releases/latest")
78        .header("Accept", "application/vnd.github+json")
79        .send()
80        .await
81        .map_err(|e| format!("GET github releases: {e}"))?;
82    if !resp.status().is_success() {
83        return Err(format!(
84            "GitHub releases API returned HTTP {}",
85            resp.status()
86        ));
87    }
88    let release: GitHubRelease = resp
89        .json()
90        .await
91        .map_err(|e| format!("decode github response: {e}"))?;
92    let drift = match (&running_version, &release.tag_name) {
93        (Some(r), tag) => version_drift_detected(r, tag),
94        _ => false,
95    };
96    Ok(VersionStatus {
97        running: running_version,
98        latest_tag: release.tag_name,
99        latest_published_at: release.published_at,
100        latest_html_url: release.html_url,
101        drift_detected: drift,
102    })
103}
104
105/// Compare `running` (e.g. `2.7.2-bcaf69d-dirty`) with `latest_tag`
106/// (e.g. `v2.8.0`). Returns true when both parse as `Major.Minor.Patch`
107/// and the triples differ. Lenient — anything we can't parse maps to
108/// false (no drift surfaced) so the operator doesn't get a false
109/// alarm on an unusual build label.
110fn version_drift_detected(running: &str, latest_tag: &str) -> bool {
111    let r = parse_semver(running);
112    let l = parse_semver(latest_tag);
113    match (r, l) {
114        (Some(a), Some(b)) => a != b,
115        _ => false,
116    }
117}
118
119fn parse_semver(s: &str) -> Option<(u32, u32, u32)> {
120    let s = s.strip_prefix('v').unwrap_or(s);
121    // First three dot-separated tokens, stopping at any non-digit
122    // suffix (e.g. `-bcaf69d-dirty`, `-rc1`).
123    let mut iter = s.split(['.', '-']);
124    let major: u32 = iter.next()?.parse().ok()?;
125    let minor: u32 = iter.next()?.parse().ok()?;
126    let patch_raw = iter.next()?;
127    // Patch may itself carry a non-digit suffix on weird build
128    // strings; trim to the leading digits.
129    let patch_digits: String = patch_raw.chars().take_while(|c| c.is_ascii_digit()).collect();
130    let patch: u32 = patch_digits.parse().ok()?;
131    Some((major, minor, patch))
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn parse_semver_strips_v_prefix() {
140        assert_eq!(parse_semver("v2.7.2"), Some((2, 7, 2)));
141        assert_eq!(parse_semver("2.7.2"), Some((2, 7, 2)));
142    }
143
144    #[test]
145    fn parse_semver_handles_dirty_suffix() {
146        assert_eq!(parse_semver("2.7.2-bcaf69d-dirty"), Some((2, 7, 2)));
147        assert_eq!(parse_semver("v2.7.2-rc1"), Some((2, 7, 2)));
148    }
149
150    #[test]
151    fn parse_semver_returns_none_on_non_numeric() {
152        assert_eq!(parse_semver("alpha"), None);
153        assert_eq!(parse_semver("2"), None);
154        assert_eq!(parse_semver(""), None);
155    }
156
157    #[test]
158    fn drift_detected_when_versions_differ() {
159        assert!(version_drift_detected("2.7.2", "v2.8.0"));
160        assert!(version_drift_detected("v2.7.2", "v2.7.3"));
161    }
162
163    #[test]
164    fn drift_not_detected_when_versions_match() {
165        assert!(!version_drift_detected("2.7.2", "v2.7.2"));
166        assert!(!version_drift_detected(
167            "2.7.2-bcaf69d-dirty",
168            "v2.7.2"
169        ));
170    }
171
172    #[test]
173    fn drift_not_detected_when_either_unparseable() {
174        // Lenient — don't false-alarm on weird formats.
175        assert!(!version_drift_detected("alpha", "v2.7.2"));
176        assert!(!version_drift_detected("2.7.2", "weird-tag"));
177    }
178
179    #[test]
180    fn summary_renders_drift_marker_when_set() {
181        let s = VersionStatus {
182            running: Some("2.7.2".into()),
183            latest_tag: "v2.8.0".into(),
184            latest_published_at: "2026-04-15T10:00:00Z".into(),
185            latest_html_url: "https://github.com/ethersphere/bee/releases/tag/v2.8.0".into(),
186            drift_detected: true,
187        }
188        .summary();
189        assert!(s.contains("DRIFT"), "{s}");
190        assert!(s.contains("v2.8.0"));
191    }
192
193    #[test]
194    fn summary_omits_drift_marker_when_unset() {
195        let s = VersionStatus {
196            running: Some("2.7.2".into()),
197            latest_tag: "v2.7.2".into(),
198            latest_published_at: "2026-03-01T10:00:00Z".into(),
199            latest_html_url: "https://github.com/ethersphere/bee/releases/tag/v2.7.2".into(),
200            drift_detected: false,
201        }
202        .summary();
203        assert!(!s.contains("DRIFT"), "{s}");
204    }
205}