Skip to main content

gitee_cli_rs/
update_notice.rs

1//! Background GitHub Releases check and stderr Update notice tip.
2
3use std::io::{IsTerminal, Write};
4use std::path::{Path, PathBuf};
5use std::thread::JoinHandle;
6use std::time::{Duration, SystemTime};
7
8use serde::{Deserialize, Serialize};
9
10use crate::config::{Config, Settings};
11
12/// Production GitHub API base URL.
13pub const GITHUB_API_BASE: &str = "https://api.github.com";
14
15/// Session opt-out: any non-empty value skips the Update notice check.
16const ENV_NO_UPDATE_NOTIFIER: &str = "GITEE_NO_UPDATE_NOTIFIER";
17
18const FETCH_TIMEOUT: Duration = Duration::from_secs(2);
19const CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
20const STATE_FILE: &str = "state.json";
21
22/// Injectable process gates for [`should_run_update_check`] (TTY + `--json`).
23#[derive(Debug, Clone, Copy)]
24pub struct UpdateCheckGates {
25    pub json: bool,
26    pub stdout_is_tty: bool,
27    pub stderr_is_tty: bool,
28}
29
30/// True when `key` is present in the environment with a non-empty value.
31fn env_nonempty(key: &str) -> bool {
32    std::env::var_os(key).is_some_and(|v| !v.is_empty())
33}
34
35/// Whether CI heuristics apply (`CI`, `BUILD_NUMBER`, or `RUN_ID` non-empty).
36fn env_is_ci() -> bool {
37    env_nonempty("CI") || env_nonempty("BUILD_NUMBER") || env_nonempty("RUN_ID")
38}
39
40/// Decide whether this invocation may start an Update notice check.
41///
42/// Gate order: env opt-out → `--json` → non-TTY stdout/stderr → CI →
43/// `CODESPACES` → config `disabled` → run.
44pub fn should_run_update_check(gates: &UpdateCheckGates, settings: &Settings) -> bool {
45    if env_nonempty(ENV_NO_UPDATE_NOTIFIER) {
46        return false;
47    }
48    if gates.json {
49        return false;
50    }
51    if !gates.stdout_is_tty || !gates.stderr_is_tty {
52        return false;
53    }
54    if env_is_ci() {
55        return false;
56    }
57    if env_nonempty("CODESPACES") {
58        return false;
59    }
60    if settings.update_notifier.as_deref() == Some("disabled") {
61        return false;
62    }
63    true
64}
65
66/// Start a background check when skip gates allow; otherwise `None` (no
67/// network, no cache read, no tip).
68pub fn maybe_spawn(
69    current_version: &str,
70    api_base: &str,
71    gates: &UpdateCheckGates,
72    settings: &Settings,
73) -> Option<UpdateNotice> {
74    if !should_run_update_check(gates, settings) {
75        return None;
76    }
77    Some(UpdateNotice::spawn(current_version, api_base))
78}
79
80/// Cached release entry persisted in `state.json` (`version` keeps leading `v`).
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct CachedRelease {
83    pub version: String,
84    pub url: String,
85    pub published_at: String,
86}
87
88/// On-disk Update notice cache (`{Config::dir()}/state.json`).
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct UpdateState {
91    pub checked_for_update_at: String,
92    pub latest_release: CachedRelease,
93}
94
95/// True when `checked_for_update_at` parses as RFC3339 and falls within the
96/// last 24 hours before `now`. Invalid or future timestamps are not fresh.
97pub fn cache_is_fresh(checked_for_update_at: &str, now: SystemTime) -> bool {
98    let Ok(checked) = chrono::DateTime::parse_from_rfc3339(checked_for_update_at) else {
99        return false;
100    };
101    let checked: SystemTime = checked.with_timezone(&chrono::Utc).into();
102    match now.duration_since(checked) {
103        Ok(age) => age < CACHE_TTL,
104        // Future timestamp (clock skew): not within the past 24h.
105        Err(_) => false,
106    }
107}
108
109fn state_path() -> Option<PathBuf> {
110    Config::dir().ok().map(|d| d.join(STATE_FILE))
111}
112
113/// Load `state.json`. Missing or invalid content ⇒ `None` (check due).
114pub fn load_state() -> Option<UpdateState> {
115    let path = state_path()?;
116    let raw = std::fs::read_to_string(path).ok()?;
117    serde_json::from_str(&raw).ok()
118}
119
120/// Write `state.json` with the same restricted mode as other config files.
121pub fn save_state(state: &UpdateState) -> Result<(), String> {
122    let path = state_path().ok_or_else(|| "no config directory".to_string())?;
123    if let Some(parent) = path.parent() {
124        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
125    }
126    let body = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?;
127    std::fs::write(&path, body + "\n").map_err(|e| e.to_string())?;
128    crate::config::restrict_perms(&path).map_err(|e| e.to_string())?;
129    Ok(())
130}
131
132/// Latest release fields used for the Update notice tip.
133/// `version` is the GitHub `tag_name` (keeps a leading `v` when present).
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct ReleaseInfo {
136    pub version: String,
137    pub url: String,
138    pub published_at: String,
139}
140
141/// Background Update notice check started before command work.
142pub struct UpdateNotice {
143    current: String,
144    /// Present when a fresh cache skipped the network fetch.
145    cached: Option<ReleaseInfo>,
146    handle: Option<JoinHandle<Option<ReleaseInfo>>>,
147    /// Wall clock used when writing `checked_for_update_at`.
148    now: SystemTime,
149}
150
151impl UpdateNotice {
152    /// Spawn a background fetch against `api_base` (e.g. [`GITHUB_API_BASE`]).
153    pub fn spawn(current_version: &str, api_base: &str) -> Self {
154        Self::spawn_at(current_version, api_base, SystemTime::now())
155    }
156
157    /// Like [`Self::spawn`], with an injectable `now` for cache TTL tests.
158    pub fn spawn_at(current_version: &str, api_base: &str, now: SystemTime) -> Self {
159        if let Some(state) = load_state() {
160            if cache_is_fresh(&state.checked_for_update_at, now) {
161                return Self {
162                    current: current_version.to_string(),
163                    cached: Some(ReleaseInfo {
164                        version: state.latest_release.version,
165                        url: state.latest_release.url,
166                        published_at: state.latest_release.published_at,
167                    }),
168                    handle: None,
169                    now,
170                };
171            }
172        }
173        let api_base = api_base.to_string();
174        let handle = std::thread::spawn(move || fetch_latest(&api_base, FETCH_TIMEOUT));
175        Self {
176            current: current_version.to_string(),
177            cached: None,
178            handle: Some(handle),
179            now,
180        }
181    }
182
183    /// On command success: join the check and maybe write the tip.
184    /// Network/decode failures are silent; write errors are ignored.
185    /// A successful fetch rewrites `state.json`; failed/None leaves it alone.
186    pub fn finish_on_success(mut self, w: &mut impl Write) {
187        let info = if let Some(handle) = self.handle.take() {
188            match handle.join() {
189                Ok(Some(info)) => {
190                    let state = UpdateState {
191                        checked_for_update_at: format_rfc3339(self.now),
192                        latest_release: CachedRelease {
193                            version: info.version.clone(),
194                            url: info.url.clone(),
195                            published_at: info.published_at.clone(),
196                        },
197                    };
198                    let _ = save_state(&state);
199                    Some(info)
200                }
201                _ => None,
202            }
203        } else {
204            self.cached.take()
205        };
206
207        let Some(info) = info else {
208            return;
209        };
210        if !is_strictly_newer(&info.version, &self.current) {
211            return;
212        }
213        let Some(brew_upgrade) =
214            homebrew_tip_mode(detect_homebrew_install(), &info.published_at, self.now)
215        else {
216            return;
217        };
218        let tip = format_tip(
219            strip_leading_v(&self.current),
220            strip_leading_v(&info.version),
221            &info.url,
222            tip_color_enabled(),
223            brew_upgrade,
224        );
225        let _ = write!(w, "{tip}");
226    }
227}
228
229/// True when `exe` lives under `{brew_prefix}/bin/` (path-component prefix).
230pub fn is_homebrew_install(exe: impl AsRef<Path>, brew_prefix: impl AsRef<Path>) -> bool {
231    let bin_dir = brew_prefix.as_ref().join("bin");
232    exe.as_ref().starts_with(bin_dir)
233}
234
235/// True when `published_at` parses as RFC3339 and falls within the last 24 hours
236/// before `now`. Invalid or future timestamps are not within the grace window.
237pub fn release_within_homebrew_grace(published_at: &str, now: SystemTime) -> bool {
238    // Same strict `< 24h` window as [`cache_is_fresh`].
239    cache_is_fresh(published_at, now)
240}
241
242/// Tip mode after a newer release is already known.
243///
244/// - `None` — Homebrew within grace: suppress the entire notice
245/// - `Some(true)` — Homebrew outside grace: include the brew upgrade line
246/// - `Some(false)` — non-Homebrew: headline + URL only
247pub fn homebrew_tip_mode(is_homebrew: bool, published_at: &str, now: SystemTime) -> Option<bool> {
248    if !is_homebrew {
249        return Some(false);
250    }
251    if release_within_homebrew_grace(published_at, now) {
252        None
253    } else {
254        Some(true)
255    }
256}
257
258#[cfg(test)]
259static TEST_HOMEBREW_PROBE: std::sync::Mutex<Option<(Option<PathBuf>, Option<PathBuf>)>> =
260    std::sync::Mutex::new(None);
261
262/// Inject `(current_exe, brew --prefix)` for tests. `None` either side ⇒ probe failure
263/// (non-Homebrew). Pass `None` for the whole override to restore production probing.
264#[cfg(test)]
265pub fn set_test_homebrew_probe(probe: Option<(Option<PathBuf>, Option<PathBuf>)>) {
266    *TEST_HOMEBREW_PROBE
267        .lock()
268        .unwrap_or_else(|e| e.into_inner()) = probe;
269}
270
271fn probe_brew_prefix() -> Option<PathBuf> {
272    let output = std::process::Command::new("brew")
273        .arg("--prefix")
274        .output()
275        .ok()?;
276    if !output.status.success() {
277        return None;
278    }
279    let prefix = String::from_utf8(output.stdout).ok()?;
280    let prefix = prefix.trim();
281    if prefix.is_empty() {
282        return None;
283    }
284    Some(PathBuf::from(prefix))
285}
286
287/// Runtime Homebrew detect: `current_exe` under `{brew --prefix}/bin/`.
288/// Any failure (no brew, prefix fail, exe path fail, mismatch) ⇒ non-Homebrew.
289fn detect_homebrew_install() -> bool {
290    #[cfg(test)]
291    {
292        if let Some((exe, prefix)) = TEST_HOMEBREW_PROBE
293            .lock()
294            .unwrap_or_else(|e| e.into_inner())
295            .clone()
296        {
297            return match (exe, prefix) {
298                (Some(exe), Some(prefix)) => is_homebrew_install(exe, prefix),
299                _ => false,
300            };
301        }
302    }
303    let Ok(exe) = std::env::current_exe() else {
304        return false;
305    };
306    let Some(prefix) = probe_brew_prefix() else {
307        return false;
308    };
309    is_homebrew_install(exe, prefix)
310}
311
312fn format_rfc3339(t: SystemTime) -> String {
313    chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
314}
315
316/// Strip one leading `v` or `V` from a release tag (e.g. `v1.2.3` → `1.2.3`).
317pub fn strip_leading_v(tag: &str) -> &str {
318    tag.strip_prefix(['v', 'V']).unwrap_or(tag)
319}
320
321/// Fetch `/repos/kipyin/gitee-cli/releases/latest` from `api_base`.
322/// Returns `None` on any network, status, or decode failure (silent).
323pub fn fetch_latest(api_base: &str, timeout: Duration) -> Option<ReleaseInfo> {
324    #[derive(serde::Deserialize)]
325    struct LatestRelease {
326        tag_name: String,
327        html_url: String,
328        published_at: String,
329    }
330
331    let base = api_base.trim_end_matches('/');
332    let url = format!("{base}/repos/kipyin/gitee-cli/releases/latest");
333    let http = reqwest::blocking::Client::builder()
334        .timeout(timeout)
335        .user_agent(format!("gitee-cli/{}", env!("CARGO_PKG_VERSION")))
336        .build()
337        .ok()?;
338    let resp = http
339        .get(&url)
340        .header("Accept", "application/vnd.github+json")
341        .header("X-GitHub-Api-Version", "2022-11-28")
342        .send()
343        .ok()?;
344    if !resp.status().is_success() {
345        return None;
346    }
347    let body: LatestRelease = resp.json().ok()?;
348    Some(ReleaseInfo {
349        // Persist tag_name as-is (leading `v`); tip/compare strip later.
350        version: body.tag_name,
351        url: body.html_url,
352        published_at: body.published_at,
353    })
354}
355
356/// True when `remote_tag` (after stripping a leading `v`) is a valid semver
357/// strictly greater than `current` (also stripped).
358pub fn is_strictly_newer(remote_tag: &str, current: &str) -> bool {
359    let Ok(remote) = semver::Version::parse(strip_leading_v(remote_tag)) else {
360        return false;
361    };
362    let Ok(local) = semver::Version::parse(strip_leading_v(current)) else {
363        return false;
364    };
365    remote > local
366}
367
368/// Whether ANSI color is allowed for the Update notice tip (stderr stream).
369fn tip_color_enabled() -> bool {
370    std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
371}
372
373fn paint_if(enabled: bool, code: &str, s: &str) -> String {
374    if enabled {
375        format!("\x1b[{code}m{s}\x1b[0m")
376    } else {
377        s.to_string()
378    }
379}
380
381/// Format the Update notice tip (leading/trailing blank lines).
382/// When `color` is true, label + URL are yellow and version numbers cyan.
383/// When `brew_upgrade` is true, inserts a plain (never colored) brew line
384/// between the headline and the URL.
385pub fn format_tip(
386    current: &str,
387    latest: &str,
388    url: &str,
389    color: bool,
390    brew_upgrade: bool,
391) -> String {
392    let headline = format!(
393        "{}{}{}{}",
394        paint_if(color, "33", "A new release of gitee is available: "),
395        paint_if(color, "36", current),
396        paint_if(color, "33", " → "),
397        paint_if(color, "36", latest),
398    );
399    let url_line = paint_if(color, "33", url);
400    if brew_upgrade {
401        format!("\n{headline}\nTo upgrade, run: brew upgrade gitee\n{url_line}\n\n")
402    } else {
403        format!("\n{headline}\n{url_line}\n\n")
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use std::time::{Duration, SystemTime, UNIX_EPOCH};
411
412    fn ts(secs: u64) -> SystemTime {
413        UNIX_EPOCH + Duration::from_secs(secs)
414    }
415
416    fn rfc3339(secs: u64) -> String {
417        chrono::DateTime::<chrono::Utc>::from(ts(secs)).to_rfc3339_opts(
418            chrono::SecondsFormat::Secs,
419            true,
420        )
421    }
422
423    #[test]
424    fn cache_is_fresh_within_24h_boundaries() {
425        let checked = rfc3339(1_000_000);
426        // exactly 24h later → not fresh (age must be strictly < 24h)
427        assert!(!cache_is_fresh(
428            &checked,
429            ts(1_000_000 + 24 * 60 * 60)
430        ));
431        // one second under 24h → fresh
432        assert!(cache_is_fresh(
433            &checked,
434            ts(1_000_000 + 24 * 60 * 60 - 1)
435        ));
436        // well inside window → fresh
437        assert!(cache_is_fresh(&checked, ts(1_000_000 + 60)));
438        // past 24h → due
439        assert!(!cache_is_fresh(
440            &checked,
441            ts(1_000_000 + 24 * 60 * 60 + 1)
442        ));
443        // invalid timestamp → due
444        assert!(!cache_is_fresh("not-a-timestamp", ts(1_000_000)));
445        // future timestamp → due (not within the past 24h)
446        assert!(!cache_is_fresh(&rfc3339(1_000_000 + 60), ts(1_000_000)));
447    }
448
449    #[test]
450    fn load_state_missing_or_invalid_means_due() {
451        let _env = crate::config::test_config_env_lock();
452        let dir = tempfile::tempdir().unwrap();
453        crate::config::set_test_dir(Some(dir.path().to_path_buf()));
454
455        assert!(load_state().is_none(), "missing state.json ⇒ due");
456
457        std::fs::write(dir.path().join("state.json"), "{not-json").unwrap();
458        assert!(load_state().is_none(), "invalid state.json ⇒ due");
459
460        std::fs::write(dir.path().join("state.json"), "{}").unwrap();
461        assert!(load_state().is_none(), "incomplete state.json ⇒ due");
462
463        crate::config::set_test_dir(None);
464    }
465
466    #[test]
467    fn save_state_round_trips_locked_json_shape() {
468        let _env = crate::config::test_config_env_lock();
469        let dir = tempfile::tempdir().unwrap();
470        crate::config::set_test_dir(Some(dir.path().to_path_buf()));
471
472        let state = UpdateState {
473            checked_for_update_at: "2026-01-15T12:00:00Z".into(),
474            latest_release: CachedRelease {
475                version: "v0.2.0".into(),
476                url: "https://github.com/kipyin/gitee-cli/releases/tag/v0.2.0".into(),
477                published_at: "2026-01-15T11:00:00Z".into(),
478            },
479        };
480        save_state(&state).expect("save");
481
482        let path = dir.path().join("state.json");
483        let raw = std::fs::read_to_string(&path).unwrap();
484        let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
485        assert_eq!(v["checked_for_update_at"], "2026-01-15T12:00:00Z");
486        assert_eq!(v["latest_release"]["version"], "v0.2.0");
487        assert_eq!(
488            v["latest_release"]["url"],
489            "https://github.com/kipyin/gitee-cli/releases/tag/v0.2.0"
490        );
491        assert_eq!(v["latest_release"]["published_at"], "2026-01-15T11:00:00Z");
492
493        assert_eq!(load_state().as_ref(), Some(&state));
494
495        #[cfg(unix)]
496        {
497            use std::os::unix::fs::PermissionsExt;
498            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
499            assert_eq!(mode, 0o600, "state.json mode should match other config files");
500        }
501
502        crate::config::set_test_dir(None);
503    }
504
505    fn seed_state(dir: &std::path::Path, checked_at: &str, version: &str) {
506        seed_state_with_published(dir, checked_at, version, "2026-01-15T11:00:00Z");
507    }
508
509    fn seed_state_with_published(
510        dir: &std::path::Path,
511        checked_at: &str,
512        version: &str,
513        published_at: &str,
514    ) {
515        let state = UpdateState {
516            checked_for_update_at: checked_at.into(),
517            latest_release: CachedRelease {
518                version: version.into(),
519                url: format!("https://github.com/kipyin/gitee-cli/releases/tag/{version}"),
520                published_at: published_at.into(),
521            },
522        };
523        let body = serde_json::to_string_pretty(&state).unwrap();
524        std::fs::write(dir.join("state.json"), body + "\n").unwrap();
525    }
526
527    #[test]
528    fn fresh_cache_skips_network_and_tips_from_cache() {
529        let _env = crate::config::test_config_env_lock();
530        let dir = tempfile::tempdir().unwrap();
531        crate::config::set_test_dir(Some(dir.path().to_path_buf()));
532
533        let now = ts(1_000_000);
534        seed_state(dir.path(), &rfc3339(1_000_000 - 60), "v0.2.0");
535
536        // Any HTTP here would panic: no mockito server is listening on this base.
537        let notice = UpdateNotice::spawn_at("0.1.5", "http://127.0.0.1:1", now);
538        let mut buf = Vec::new();
539        notice.finish_on_success(&mut buf);
540
541        let out = String::from_utf8(buf).unwrap();
542        assert!(
543            out.contains("A new release of gitee is available: 0.1.5 → 0.2.0"),
544            "expected tip from cache, got {out:?}"
545        );
546        // Cache file left unchanged (no rewrite on cache hit).
547        let loaded = load_state().unwrap();
548        assert_eq!(loaded.checked_for_update_at, rfc3339(1_000_000 - 60));
549
550        crate::config::set_test_dir(None);
551    }
552
553    #[test]
554    fn stale_cache_hits_network_and_rewrites_state() {
555        let _env = crate::config::test_config_env_lock();
556        let dir = tempfile::tempdir().unwrap();
557        crate::config::set_test_dir(Some(dir.path().to_path_buf()));
558
559        let now = ts(1_000_000);
560        // Stale: checked 25h ago.
561        seed_state(
562            dir.path(),
563            &rfc3339(1_000_000 - 25 * 60 * 60),
564            "v0.1.9",
565        );
566
567        let mut server = mockito::Server::new();
568        let mock = server
569            .mock("GET", "/repos/kipyin/gitee-cli/releases/latest")
570            .with_status(200)
571            .with_header("content-type", "application/json")
572            .with_body(
573                r#"{
574  "tag_name": "v0.2.0",
575  "html_url": "https://github.com/kipyin/gitee-cli/releases/tag/v0.2.0",
576  "published_at": "2026-01-15T12:00:00Z"
577}"#,
578            )
579            .create();
580
581        let notice = UpdateNotice::spawn_at("0.1.5", &server.url(), now);
582        std::thread::sleep(Duration::from_millis(50));
583        let mut buf = Vec::new();
584        notice.finish_on_success(&mut buf);
585
586        mock.assert();
587        let out = String::from_utf8(buf).unwrap();
588        assert!(out.contains("0.1.5 → 0.2.0"), "got {out:?}");
589
590        let loaded = load_state().unwrap();
591        assert_eq!(loaded.checked_for_update_at, rfc3339(1_000_000));
592        assert_eq!(loaded.latest_release.version, "v0.2.0");
593        assert_eq!(
594            loaded.latest_release.published_at,
595            "2026-01-15T12:00:00Z"
596        );
597
598        crate::config::set_test_dir(None);
599    }
600
601    #[test]
602    fn failed_fetch_leaves_prior_state_unchanged() {
603        let _env = crate::config::test_config_env_lock();
604        let dir = tempfile::tempdir().unwrap();
605        crate::config::set_test_dir(Some(dir.path().to_path_buf()));
606
607        let now = ts(1_000_000);
608        let prior_checked = rfc3339(1_000_000 - 25 * 60 * 60);
609        seed_state(dir.path(), &prior_checked, "v0.2.0");
610        let prior_raw = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
611
612        let mut server = mockito::Server::new();
613        let mock = server
614            .mock("GET", "/repos/kipyin/gitee-cli/releases/latest")
615            .with_status(500)
616            .with_body("oops")
617            .create();
618
619        let notice = UpdateNotice::spawn_at("0.1.5", &server.url(), now);
620        std::thread::sleep(Duration::from_millis(50));
621        let mut buf = Vec::new();
622        notice.finish_on_success(&mut buf);
623
624        mock.assert();
625        assert!(buf.is_empty(), "failed fetch must not tip");
626        let after = std::fs::read_to_string(dir.path().join("state.json")).unwrap();
627        assert_eq!(after, prior_raw, "prior good state must not be overwritten");
628
629        crate::config::set_test_dir(None);
630    }
631
632    #[test]
633    fn strip_leading_v_removes_one_v_or_v() {
634        assert_eq!(strip_leading_v("v1.2.3"), "1.2.3");
635        assert_eq!(strip_leading_v("V0.1.5"), "0.1.5");
636        assert_eq!(strip_leading_v("1.2.3"), "1.2.3");
637        assert_eq!(strip_leading_v("vv1.0.0"), "v1.0.0");
638    }
639
640    #[test]
641    fn is_strictly_newer_compares_semver_after_v_strip() {
642        assert!(is_strictly_newer("v0.2.0", "0.1.5"));
643        assert!(is_strictly_newer("0.1.6", "v0.1.5"));
644        assert!(!is_strictly_newer("v0.1.5", "0.1.5"));
645        assert!(!is_strictly_newer("v0.1.4", "0.1.5"));
646        assert!(!is_strictly_newer("not-a-version", "0.1.5"));
647        assert!(!is_strictly_newer("v0.2.0", "also-bad"));
648    }
649
650    #[test]
651    fn format_tip_plain_has_padding_copy_and_url() {
652        let tip = format_tip(
653            "0.1.5",
654            "0.2.0",
655            "https://github.com/kipyin/gitee-cli/releases/tag/v0.2.0",
656            false,
657            false,
658        );
659        assert_eq!(
660            tip,
661            "\nA new release of gitee is available: 0.1.5 → 0.2.0\nhttps://github.com/kipyin/gitee-cli/releases/tag/v0.2.0\n\n"
662        );
663    }
664
665    #[test]
666    fn format_tip_includes_plain_brew_line_when_requested() {
667        let tip = format_tip(
668            "0.1.5",
669            "0.2.0",
670            "https://github.com/kipyin/gitee-cli/releases/tag/v0.2.0",
671            true,
672            true,
673        );
674        let yellow = |s: &str| format!("\x1b[33m{s}\x1b[0m");
675        let cyan = |s: &str| format!("\x1b[36m{s}\x1b[0m");
676        let expected = format!(
677            "\n{}{}{}{}\nTo upgrade, run: brew upgrade gitee\n{}\n\n",
678            yellow("A new release of gitee is available: "),
679            cyan("0.1.5"),
680            yellow(" → "),
681            cyan("0.2.0"),
682            yellow("https://github.com/kipyin/gitee-cli/releases/tag/v0.2.0"),
683        );
684        assert_eq!(tip, expected);
685        assert!(
686            !tip.contains("\x1b[33mTo upgrade"),
687            "brew line must stay uncolored"
688        );
689    }
690
691    #[test]
692    fn format_tip_color_marks_label_url_yellow_and_versions_cyan() {
693        let tip = format_tip(
694            "0.1.5",
695            "0.2.0",
696            "https://github.com/kipyin/gitee-cli/releases/tag/v0.2.0",
697            true,
698            false,
699        );
700        let yellow = |s: &str| format!("\x1b[33m{s}\x1b[0m");
701        let cyan = |s: &str| format!("\x1b[36m{s}\x1b[0m");
702        let expected = format!(
703            "\n{}{}{}{}\n{}\n\n",
704            yellow("A new release of gitee is available: "),
705            cyan("0.1.5"),
706            yellow(" → "),
707            cyan("0.2.0"),
708            yellow("https://github.com/kipyin/gitee-cli/releases/tag/v0.2.0"),
709        );
710        assert_eq!(tip, expected);
711    }
712
713    #[test]
714    fn is_homebrew_install_requires_prefix_bin_path() {
715        assert!(is_homebrew_install(
716            "/opt/homebrew/bin/gitee",
717            "/opt/homebrew"
718        ));
719        assert!(is_homebrew_install(
720            "/usr/local/bin/gitee",
721            "/usr/local"
722        ));
723        assert!(
724            !is_homebrew_install("/opt/homebrew/Cellar/gitee/0.2.0/bin/gitee", "/opt/homebrew"),
725            "Cellar path alone is not Homebrew for tip purposes"
726        );
727        assert!(!is_homebrew_install(
728            "/home/user/.cargo/bin/gitee",
729            "/opt/homebrew"
730        ));
731        assert!(!is_homebrew_install(
732            "/opt/homebrew/binfoo/gitee",
733            "/opt/homebrew"
734        ));
735    }
736
737    #[test]
738    fn release_within_homebrew_grace_matches_24h_strict_window() {
739        let published = rfc3339(1_000_000);
740        assert!(!release_within_homebrew_grace(
741            &published,
742            ts(1_000_000 + 24 * 60 * 60)
743        ));
744        assert!(release_within_homebrew_grace(
745            &published,
746            ts(1_000_000 + 24 * 60 * 60 - 1)
747        ));
748        assert!(release_within_homebrew_grace(
749            &published,
750            ts(1_000_000 + 60)
751        ));
752        assert!(!release_within_homebrew_grace(
753            &published,
754            ts(1_000_000 + 24 * 60 * 60 + 1)
755        ));
756        assert!(!release_within_homebrew_grace(
757            "not-a-timestamp",
758            ts(1_000_000)
759        ));
760        assert!(!release_within_homebrew_grace(
761            &rfc3339(1_000_000 + 60),
762            ts(1_000_000)
763        ));
764    }
765
766    #[test]
767    fn homebrew_tip_mode_suppresses_brew_or_plain() {
768        let now = ts(1_000_000);
769        let recent = rfc3339(1_000_000 - 60);
770        let older = rfc3339(1_000_000 - 25 * 60 * 60);
771
772        assert_eq!(homebrew_tip_mode(false, &recent, now), Some(false));
773        assert_eq!(homebrew_tip_mode(false, &older, now), Some(false));
774        assert_eq!(homebrew_tip_mode(true, &recent, now), None);
775        assert_eq!(homebrew_tip_mode(true, &older, now), Some(true));
776        assert_eq!(
777            homebrew_tip_mode(true, "not-a-timestamp", now),
778            Some(true),
779            "invalid published_at is outside grace"
780        );
781    }
782
783    #[test]
784    fn finish_on_success_homebrew_brew_line_and_grace_via_inject() {
785        let _env = crate::config::test_config_env_lock();
786        let dir = tempfile::tempdir().unwrap();
787        crate::config::set_test_dir(Some(dir.path().to_path_buf()));
788
789        let now = ts(1_000_000);
790        let brew_prefix = PathBuf::from("/opt/homebrew");
791        let brew_exe = PathBuf::from("/opt/homebrew/bin/gitee");
792
793        // Outside grace + Homebrew → tip includes brew line.
794        seed_state_with_published(
795            dir.path(),
796            &rfc3339(1_000_000 - 60),
797            "v0.2.0",
798            &rfc3339(1_000_000 - 25 * 60 * 60),
799        );
800        set_test_homebrew_probe(Some((Some(brew_exe.clone()), Some(brew_prefix.clone()))));
801        let notice = UpdateNotice::spawn_at("0.1.5", "http://127.0.0.1:1", now);
802        let mut buf = Vec::new();
803        notice.finish_on_success(&mut buf);
804        let out = String::from_utf8(buf).unwrap();
805        assert!(
806            out.contains("To upgrade, run: brew upgrade gitee"),
807            "expected brew line, got {out:?}"
808        );
809
810        // Within grace + Homebrew → suppress entire notice.
811        seed_state_with_published(
812            dir.path(),
813            &rfc3339(1_000_000 - 60),
814            "v0.2.0",
815            &rfc3339(1_000_000 - 60),
816        );
817        let notice = UpdateNotice::spawn_at("0.1.5", "http://127.0.0.1:1", now);
818        let mut buf = Vec::new();
819        notice.finish_on_success(&mut buf);
820        assert!(
821            buf.is_empty(),
822            "Homebrew grace must suppress tip; got {:?}",
823            String::from_utf8_lossy(&buf)
824        );
825
826        // Probe failure → non-Homebrew tip (no brew line, still shown).
827        set_test_homebrew_probe(Some((Some(brew_exe), None)));
828        let notice = UpdateNotice::spawn_at("0.1.5", "http://127.0.0.1:1", now);
829        let mut buf = Vec::new();
830        notice.finish_on_success(&mut buf);
831        let out = String::from_utf8(buf).unwrap();
832        assert!(out.contains("0.1.5 → 0.2.0"), "got {out:?}");
833        assert!(
834            !out.contains("brew upgrade"),
835            "probe failure must omit brew line"
836        );
837
838        set_test_homebrew_probe(None);
839        crate::config::set_test_dir(None);
840    }
841
842    const SKIP_ENV_KEYS: &[&str] = &[
843        "GITEE_NO_UPDATE_NOTIFIER",
844        "CI",
845        "BUILD_NUMBER",
846        "RUN_ID",
847        "CODESPACES",
848    ];
849
850    /// Clear skip-related env vars for the duration of `f`, then restore.
851    fn with_cleared_skip_env<T>(f: impl FnOnce() -> T) -> T {
852        let prev: Vec<_> = SKIP_ENV_KEYS
853            .iter()
854            .map(|k| (*k, std::env::var_os(k)))
855            .collect();
856        for k in SKIP_ENV_KEYS {
857            std::env::remove_var(k);
858        }
859        let out = f();
860        for (k, v) in prev {
861            match v {
862                Some(v) => std::env::set_var(k, v),
863                None => std::env::remove_var(k),
864            }
865        }
866        out
867    }
868
869    fn set_env(key: &str, value: Option<&str>) {
870        match value {
871            Some(v) => std::env::set_var(key, v),
872            None => std::env::remove_var(key),
873        }
874    }
875
876    fn interactive_gates() -> UpdateCheckGates {
877        UpdateCheckGates {
878            json: false,
879            stdout_is_tty: true,
880            stderr_is_tty: true,
881        }
882    }
883
884    #[test]
885    fn should_run_update_check_matrix() {
886        let _env = crate::config::test_config_env_lock();
887
888        #[derive(Clone, Copy)]
889        struct Case {
890            name: &'static str,
891            env_no_update: Option<&'static str>,
892            json: bool,
893            stdout_tty: bool,
894            stderr_tty: bool,
895            ci: Option<&'static str>,
896            build_number: Option<&'static str>,
897            run_id: Option<&'static str>,
898            codespaces: Option<&'static str>,
899            update_notifier: Option<&'static str>,
900            expect: bool,
901        }
902
903        let cases = [
904            Case {
905                name: "default interactive enabled",
906                env_no_update: None,
907                json: false,
908                stdout_tty: true,
909                stderr_tty: true,
910                ci: None,
911                build_number: None,
912                run_id: None,
913                codespaces: None,
914                update_notifier: None,
915                expect: true,
916            },
917            Case {
918                name: "config enabled explicit",
919                env_no_update: None,
920                json: false,
921                stdout_tty: true,
922                stderr_tty: true,
923                ci: None,
924                build_number: None,
925                run_id: None,
926                codespaces: None,
927                update_notifier: Some("enabled"),
928                expect: true,
929            },
930            Case {
931                name: "env non-empty skips",
932                env_no_update: Some("1"),
933                json: false,
934                stdout_tty: true,
935                stderr_tty: true,
936                ci: None,
937                build_number: None,
938                run_id: None,
939                codespaces: None,
940                update_notifier: Some("enabled"),
941                expect: false,
942            },
943            Case {
944                name: "env empty string does not skip",
945                env_no_update: Some(""),
946                json: false,
947                stdout_tty: true,
948                stderr_tty: true,
949                ci: None,
950                build_number: None,
951                run_id: None,
952                codespaces: None,
953                update_notifier: None,
954                expect: true,
955            },
956            Case {
957                name: "json skips",
958                env_no_update: None,
959                json: true,
960                stdout_tty: true,
961                stderr_tty: true,
962                ci: None,
963                build_number: None,
964                run_id: None,
965                codespaces: None,
966                update_notifier: None,
967                expect: false,
968            },
969            Case {
970                name: "stdout non-tty skips",
971                env_no_update: None,
972                json: false,
973                stdout_tty: false,
974                stderr_tty: true,
975                ci: None,
976                build_number: None,
977                run_id: None,
978                codespaces: None,
979                update_notifier: None,
980                expect: false,
981            },
982            Case {
983                name: "stderr non-tty skips",
984                env_no_update: None,
985                json: false,
986                stdout_tty: true,
987                stderr_tty: false,
988                ci: None,
989                build_number: None,
990                run_id: None,
991                codespaces: None,
992                update_notifier: None,
993                expect: false,
994            },
995            Case {
996                name: "CI set skips",
997                env_no_update: None,
998                json: false,
999                stdout_tty: true,
1000                stderr_tty: true,
1001                ci: Some("true"),
1002                build_number: None,
1003                run_id: None,
1004                codespaces: None,
1005                update_notifier: None,
1006                expect: false,
1007            },
1008            Case {
1009                name: "CI empty string does not skip",
1010                env_no_update: None,
1011                json: false,
1012                stdout_tty: true,
1013                stderr_tty: true,
1014                ci: Some(""),
1015                build_number: None,
1016                run_id: None,
1017                codespaces: None,
1018                update_notifier: None,
1019                expect: true,
1020            },
1021            Case {
1022                name: "BUILD_NUMBER set skips",
1023                env_no_update: None,
1024                json: false,
1025                stdout_tty: true,
1026                stderr_tty: true,
1027                ci: None,
1028                build_number: Some("42"),
1029                run_id: None,
1030                codespaces: None,
1031                update_notifier: None,
1032                expect: false,
1033            },
1034            Case {
1035                name: "BUILD_NUMBER empty string does not skip",
1036                env_no_update: None,
1037                json: false,
1038                stdout_tty: true,
1039                stderr_tty: true,
1040                ci: None,
1041                build_number: Some(""),
1042                run_id: None,
1043                codespaces: None,
1044                update_notifier: None,
1045                expect: true,
1046            },
1047            Case {
1048                name: "RUN_ID set skips",
1049                env_no_update: None,
1050                json: false,
1051                stdout_tty: true,
1052                stderr_tty: true,
1053                ci: None,
1054                build_number: None,
1055                run_id: Some("run-1"),
1056                codespaces: None,
1057                update_notifier: None,
1058                expect: false,
1059            },
1060            Case {
1061                name: "RUN_ID empty string does not skip",
1062                env_no_update: None,
1063                json: false,
1064                stdout_tty: true,
1065                stderr_tty: true,
1066                ci: None,
1067                build_number: None,
1068                run_id: Some(""),
1069                codespaces: None,
1070                update_notifier: None,
1071                expect: true,
1072            },
1073            Case {
1074                name: "CODESPACES non-empty skips",
1075                env_no_update: None,
1076                json: false,
1077                stdout_tty: true,
1078                stderr_tty: true,
1079                ci: None,
1080                build_number: None,
1081                run_id: None,
1082                codespaces: Some("true"),
1083                update_notifier: None,
1084                expect: false,
1085            },
1086            Case {
1087                name: "CODESPACES empty string does not skip",
1088                env_no_update: None,
1089                json: false,
1090                stdout_tty: true,
1091                stderr_tty: true,
1092                ci: None,
1093                build_number: None,
1094                run_id: None,
1095                codespaces: Some(""),
1096                update_notifier: None,
1097                expect: true,
1098            },
1099            Case {
1100                name: "config disabled skips",
1101                env_no_update: None,
1102                json: false,
1103                stdout_tty: true,
1104                stderr_tty: true,
1105                ci: None,
1106                build_number: None,
1107                run_id: None,
1108                codespaces: None,
1109                update_notifier: Some("disabled"),
1110                expect: false,
1111            },
1112            Case {
1113                name: "env wins over config enabled",
1114                env_no_update: Some("yes"),
1115                json: false,
1116                stdout_tty: true,
1117                stderr_tty: true,
1118                ci: None,
1119                build_number: None,
1120                run_id: None,
1121                codespaces: None,
1122                update_notifier: Some("enabled"),
1123                expect: false,
1124            },
1125        ];
1126
1127        for case in cases {
1128            with_cleared_skip_env(|| {
1129                set_env("GITEE_NO_UPDATE_NOTIFIER", case.env_no_update);
1130                set_env("CI", case.ci);
1131                set_env("BUILD_NUMBER", case.build_number);
1132                set_env("RUN_ID", case.run_id);
1133                set_env("CODESPACES", case.codespaces);
1134
1135                let settings = Settings {
1136                    update_notifier: case.update_notifier.map(str::to_string),
1137                    ..Settings::default()
1138                };
1139                let gates = UpdateCheckGates {
1140                    json: case.json,
1141                    stdout_is_tty: case.stdout_tty,
1142                    stderr_is_tty: case.stderr_tty,
1143                };
1144                let got = should_run_update_check(&gates, &settings);
1145                assert_eq!(
1146                    got, case.expect,
1147                    "case {:?}: expected {}, got {}",
1148                    case.name, case.expect, got
1149                );
1150            });
1151        }
1152
1153        // Clearing env leaves config in charge: disabled still skips; enabled runs.
1154        with_cleared_skip_env(|| {
1155            set_env("GITEE_NO_UPDATE_NOTIFIER", Some("1"));
1156            let settings = Settings {
1157                update_notifier: Some("disabled".into()),
1158                ..Settings::default()
1159            };
1160            assert!(!should_run_update_check(&interactive_gates(), &settings));
1161            set_env("GITEE_NO_UPDATE_NOTIFIER", None);
1162            assert!(!should_run_update_check(&interactive_gates(), &settings));
1163
1164            let enabled = Settings {
1165                update_notifier: Some("enabled".into()),
1166                ..Settings::default()
1167            };
1168            assert!(should_run_update_check(&interactive_gates(), &enabled));
1169        });
1170    }
1171
1172    #[test]
1173    fn maybe_spawn_skip_does_not_hit_http() {
1174        let _env = crate::config::test_config_env_lock();
1175        let dir = tempfile::tempdir().unwrap();
1176        crate::config::set_test_dir(Some(dir.path().to_path_buf()));
1177
1178        let mut server = mockito::Server::new();
1179        let mock = server
1180            .mock("GET", "/repos/kipyin/gitee-cli/releases/latest")
1181            .with_status(200)
1182            .with_body(
1183                r#"{"tag_name":"v0.2.0","html_url":"https://example.com","published_at":"2026-01-15T12:00:00Z"}"#,
1184            )
1185            .expect(0)
1186            .create();
1187
1188        with_cleared_skip_env(|| {
1189            set_env("GITEE_NO_UPDATE_NOTIFIER", Some("1"));
1190            let settings = Settings::default();
1191            let gates = interactive_gates();
1192            let notice = maybe_spawn("0.1.5", &server.url(), &gates, &settings);
1193            assert!(notice.is_none(), "skip must not spawn UpdateNotice");
1194        });
1195
1196        mock.assert();
1197        crate::config::set_test_dir(None);
1198    }
1199}