Skip to main content

bb_cli/
update_check.rs

1//! The passive "a newer bb exists" notice.
2//!
3//! `bb update` answers the question only when someone thinks to ask it, and
4//! most people never do — so this asks on their behalf, once a day, ahead of
5//! whatever command they actually ran.
6//!
7//! Four properties, each with a test, because this runs before *every*
8//! command and must never be the reason one of them fails or changes shape:
9//!
10//! - the notice goes to **stderr**, in human and `--json` mode alike, so the
11//!   `--json` stdout contract stays intact and an agent reading stderr still
12//!   learns about the upgrade;
13//! - the network is touched at most once per [`CHECK_TTL`]; every other
14//!   invocation reads one small file;
15//! - every failure — offline, rate limited, unwritable config dir, corrupt
16//!   cache — is swallowed silently, since the user asked for something else;
17//! - `BB_NO_UPDATE_CHECK=1` turns the whole thing off.
18
19use 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
25/// How long a recorded answer is trusted. A day is short enough that an
26/// upgrade is noticed promptly and long enough that the check is invisible in
27/// normal use.
28pub const CHECK_TTL: Duration = Duration::from_secs(24 * 60 * 60);
29
30/// Hard ceiling on how long a user's command may be delayed by the check.
31/// They did not ask for this request, so it gets a fraction of the budget
32/// `bb update` gives its own.
33const CHECK_TIMEOUT: Duration = Duration::from_secs(2);
34
35/// What the last check found. `latest` is stored as the raw tag (`v0.19.4`)
36/// because that is what the release API returns and [`is_newer`] tolerates
37/// the `v`.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct Cache {
40    /// Unix seconds at which the release API was last asked.
41    pub checked_at: u64,
42    /// The newest tag it reported.
43    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
59/// A missing or unreadable cache is indistinguishable from never having
60/// checked, which is exactly the right reading: check again.
61pub 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
66/// Written the same way `skill::save_state` writes: temp file plus `rename`,
67/// so two `bb` processes racing cannot leave a truncated file that then
68/// silently disables the check.
69pub 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
93/// True when the cache is missing, older than `ttl`, or stamped in the
94/// future. A future stamp means a clock moved backwards; treating it as fresh
95/// would freeze the check until the clock caught up.
96pub 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
103/// The line shown when `latest` is ahead of `current`, and `None` otherwise.
104///
105/// It names the version, the running version and the one command that
106/// upgrades *this* install, because a notice that says only "update
107/// available" makes the reader go looking for how.
108pub 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        // Without a path there is nothing to classify. `bb update` works for
122        // a standalone install and tells the other two what to run instead,
123        // so it is the safe answer rather than a guess at brew or cargo.
124        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
137/// The pre-command hook. Prints at most one line, to stderr, and returns
138/// nothing to fail on.
139///
140/// `format` is taken only so a future format can opt out; the notice is
141/// printed in both current formats, since stderr is outside the `--json`
142/// stdout contract and an agent needs the notice as much as a human does.
143pub 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                // A cache that cannot be written costs an extra request a day,
159                // which is not worth a line of noise on someone's command.
160                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}