1use 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
12pub const GITHUB_API_BASE: &str = "https://api.github.com";
14
15const 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#[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
30fn env_nonempty(key: &str) -> bool {
32 std::env::var_os(key).is_some_and(|v| !v.is_empty())
33}
34
35fn env_is_ci() -> bool {
37 env_nonempty("CI") || env_nonempty("BUILD_NUMBER") || env_nonempty("RUN_ID")
38}
39
40pub 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
66pub 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct UpdateState {
91 pub checked_for_update_at: String,
92 pub latest_release: CachedRelease,
93}
94
95pub 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 Err(_) => false,
106 }
107}
108
109fn state_path() -> Option<PathBuf> {
110 Config::dir().ok().map(|d| d.join(STATE_FILE))
111}
112
113pub 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
120pub 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#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct ReleaseInfo {
136 pub version: String,
137 pub url: String,
138 pub published_at: String,
139}
140
141pub struct UpdateNotice {
143 current: String,
144 cached: Option<ReleaseInfo>,
146 handle: Option<JoinHandle<Option<ReleaseInfo>>>,
147 now: SystemTime,
149}
150
151impl UpdateNotice {
152 pub fn spawn(current_version: &str, api_base: &str) -> Self {
154 Self::spawn_at(current_version, api_base, SystemTime::now())
155 }
156
157 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 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
229pub 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
235pub fn release_within_homebrew_grace(published_at: &str, now: SystemTime) -> bool {
238 cache_is_fresh(published_at, now)
240}
241
242pub 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#[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
287fn 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
316pub fn strip_leading_v(tag: &str) -> &str {
318 tag.strip_prefix(['v', 'V']).unwrap_or(tag)
319}
320
321pub 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 version: body.tag_name,
351 url: body.html_url,
352 published_at: body.published_at,
353 })
354}
355
356pub 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
368fn 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
381pub 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 assert!(!cache_is_fresh(
428 &checked,
429 ts(1_000_000 + 24 * 60 * 60)
430 ));
431 assert!(cache_is_fresh(
433 &checked,
434 ts(1_000_000 + 24 * 60 * 60 - 1)
435 ));
436 assert!(cache_is_fresh(&checked, ts(1_000_000 + 60)));
438 assert!(!cache_is_fresh(
440 &checked,
441 ts(1_000_000 + 24 * 60 * 60 + 1)
442 ));
443 assert!(!cache_is_fresh("not-a-timestamp", ts(1_000_000)));
445 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 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 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 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 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 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 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 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 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}