1use std::time::Duration;
16
17use serde::Deserialize;
18
19#[derive(Debug, Clone)]
24pub struct VersionStatus {
25 pub running: Option<String>,
28 pub latest_tag: String,
30 pub latest_published_at: String,
32 pub latest_html_url: String,
33 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
64pub async fn check_latest(running_version: Option<String>) -> Result<VersionStatus, String> {
68 let client = reqwest::Client::builder()
69 .timeout(Duration::from_secs(10))
70 .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
105fn 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 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 let patch_digits: String = patch_raw
130 .chars()
131 .take_while(|c| c.is_ascii_digit())
132 .collect();
133 let patch: u32 = patch_digits.parse().ok()?;
134 Some((major, minor, patch))
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn parse_semver_strips_v_prefix() {
143 assert_eq!(parse_semver("v2.7.2"), Some((2, 7, 2)));
144 assert_eq!(parse_semver("2.7.2"), Some((2, 7, 2)));
145 }
146
147 #[test]
148 fn parse_semver_handles_dirty_suffix() {
149 assert_eq!(parse_semver("2.7.2-bcaf69d-dirty"), Some((2, 7, 2)));
150 assert_eq!(parse_semver("v2.7.2-rc1"), Some((2, 7, 2)));
151 }
152
153 #[test]
154 fn parse_semver_returns_none_on_non_numeric() {
155 assert_eq!(parse_semver("alpha"), None);
156 assert_eq!(parse_semver("2"), None);
157 assert_eq!(parse_semver(""), None);
158 }
159
160 #[test]
161 fn drift_detected_when_versions_differ() {
162 assert!(version_drift_detected("2.7.2", "v2.8.0"));
163 assert!(version_drift_detected("v2.7.2", "v2.7.3"));
164 }
165
166 #[test]
167 fn drift_not_detected_when_versions_match() {
168 assert!(!version_drift_detected("2.7.2", "v2.7.2"));
169 assert!(!version_drift_detected("2.7.2-bcaf69d-dirty", "v2.7.2"));
170 }
171
172 #[test]
173 fn drift_not_detected_when_either_unparseable() {
174 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}