1use crate::commands::update::{self, is_newer};
20use crate::output::{self, Format};
21use serde::{Deserialize, Serialize};
22use std::path::PathBuf;
23use std::time::{Duration, SystemTime, UNIX_EPOCH};
24
25pub const CHECK_TTL: Duration = Duration::from_secs(24 * 60 * 60);
29
30const CHECK_TIMEOUT: Duration = Duration::from_secs(2);
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct Cache {
40 pub checked_at: u64,
42 pub latest: String,
44}
45
46pub fn cache_path() -> PathBuf {
47 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
48 if !xdg.is_empty() {
49 return PathBuf::from(xdg).join("bb").join("update-check.json");
50 }
51 }
52 let home = std::env::var_os("HOME").unwrap_or_default();
53 PathBuf::from(home)
54 .join(".config")
55 .join("bb")
56 .join("update-check.json")
57}
58
59pub fn load_cache() -> Option<Cache> {
62 let raw = std::fs::read_to_string(cache_path()).ok()?;
63 serde_json::from_str(&raw).ok()
64}
65
66pub fn save_cache(cache: &Cache) -> std::io::Result<()> {
70 let path = cache_path();
71 let Some(parent) = path.parent() else {
72 return Ok(());
73 };
74 std::fs::create_dir_all(parent)?;
75 let json = serde_json::to_string(cache)
76 .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
77 let tmp = parent.join(format!(
78 ".update-check.json.tmp.{}.{}",
79 std::process::id(),
80 now_secs()
81 ));
82 std::fs::write(&tmp, json)?;
83 std::fs::rename(&tmp, &path)
84}
85
86fn now_secs() -> u64 {
87 SystemTime::now()
88 .duration_since(UNIX_EPOCH)
89 .map(|d| d.as_secs())
90 .unwrap_or(0)
91}
92
93pub fn is_stale(cache: Option<&Cache>, now: u64, ttl: Duration) -> bool {
97 match cache {
98 None => true,
99 Some(cache) => cache.checked_at > now || now - cache.checked_at >= ttl.as_secs(),
100 }
101}
102
103pub fn notice(latest: &str, current: &str, hint: &str) -> Option<String> {
109 if !is_newer(latest, current) {
110 return None;
111 }
112 let version = latest.trim().trim_start_matches('v');
113 Some(format!(
114 "bb {version} is available (you have {current}) — upgrade with: {hint}"
115 ))
116}
117
118fn hint_for_this_install() -> &'static str {
119 match std::env::current_exe() {
120 Ok(exe) => update::upgrade_hint(update::classify_install(&exe)),
121 Err(_) => "bb update",
125 }
126}
127
128fn client() -> Option<reqwest::Client> {
129 reqwest::Client::builder()
130 .connect_timeout(CHECK_TIMEOUT)
131 .timeout(CHECK_TIMEOUT)
132 .user_agent(concat!("bbcloud/", env!("CARGO_PKG_VERSION")))
133 .build()
134 .ok()
135}
136
137pub async fn maybe_notify(format: Format, base_url: &str) {
144 let _ = format;
145 if std::env::var_os("BB_NO_UPDATE_CHECK").is_some() {
146 return;
147 }
148 let current = env!("CARGO_PKG_VERSION");
149 let mut cache = load_cache();
150
151 if is_stale(cache.as_ref(), now_secs(), CHECK_TTL) {
152 if let Some(http) = client() {
153 if let Ok(latest) = update::latest_tag(&http, base_url).await {
154 let fresh = Cache {
155 checked_at: now_secs(),
156 latest,
157 };
158 let _ = save_cache(&fresh);
161 cache = Some(fresh);
162 }
163 }
164 }
165
166 if let Some(line) = cache
167 .as_ref()
168 .and_then(|c| notice(&c.latest, current, hint_for_this_install()))
169 {
170 output::warn(&line);
171 }
172}
173
174#[cfg(test)]
175#[allow(clippy::unwrap_used)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn notice_names_version_and_command() {
181 let line = notice("v0.20.0", "0.19.4", "bb update").unwrap();
182 assert!(line.contains("bb 0.20.0 is available"), "{line}");
183 assert!(line.contains("you have 0.19.4"), "{line}");
184 assert!(line.ends_with("bb update"), "{line}");
185 }
186
187 #[test]
188 fn no_notice_when_current_or_ahead() {
189 assert!(notice("v0.19.4", "0.19.4", "bb update").is_none());
190 assert!(notice("v0.19.3", "0.19.4", "bb update").is_none());
191 }
192
193 #[test]
194 fn unparseable_tag_is_never_a_notice() {
195 assert!(notice("nightly", "0.19.4", "bb update").is_none());
196 assert!(notice("", "0.19.4", "bb update").is_none());
197 }
198
199 #[test]
200 fn missing_cache_is_stale() {
201 assert!(is_stale(None, 1_000_000, CHECK_TTL));
202 }
203
204 #[test]
205 fn fresh_cache_is_not_stale() {
206 let cache = Cache {
207 checked_at: 1_000_000,
208 latest: "v0.19.4".to_string(),
209 };
210 assert!(!is_stale(Some(&cache), 1_000_000 + 60, CHECK_TTL));
211 }
212
213 #[test]
214 fn cache_older_than_ttl_is_stale() {
215 let cache = Cache {
216 checked_at: 1_000_000,
217 latest: "v0.19.4".to_string(),
218 };
219 assert!(is_stale(
220 Some(&cache),
221 1_000_000 + CHECK_TTL.as_secs(),
222 CHECK_TTL
223 ));
224 }
225
226 #[test]
227 fn cache_stamped_in_the_future_is_stale() {
228 let cache = Cache {
229 checked_at: 2_000_000,
230 latest: "v0.19.4".to_string(),
231 };
232 assert!(is_stale(Some(&cache), 1_000_000, CHECK_TTL));
233 }
234}