1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::io::IsTerminal as _;
5use std::io::{self, Write};
6use std::path::PathBuf;
7use std::time::Duration as StdDuration;
8
9use crate::{
10 Paths, default_profiles_dir, lock_usage, read_profiles_index, write_atomic,
11 write_profiles_index,
12};
13use crate::{
14 UPDATE_ERR_PERSIST_DISMISSAL, UPDATE_ERR_READ_CHOICE, UPDATE_ERR_REFRESH_VERSION,
15 UPDATE_ERR_SHOW_PROMPT, UPDATE_NON_TTY_RUN, UPDATE_OPTION_NOW, UPDATE_OPTION_SKIP,
16 UPDATE_OPTION_SKIP_VERSION, UPDATE_PROMPT_SELECT, UPDATE_RELEASE_NOTES, UPDATE_TITLE_AVAILABLE,
17};
18
19const HOMEBREW_CASK_URL: &str =
21 "https://raw.githubusercontent.com/Homebrew/homebrew-cask/HEAD/Casks/c/codexswitch-cli.rb";
22const LATEST_RELEASE_URL: &str =
23 "https://api.github.com/repos/syntaxskills/codexswitch-cli/releases/latest";
24const RELEASE_NOTES_URL: &str = "https://github.com/syntaxskills/codexswitch-cli/releases/latest";
25#[cfg(test)]
26const HOMEBREW_CASK_URL_OVERRIDE_ENV_VAR: &str = "CODEXSWITCH_CLI_HOMEBREW_CASK_URL";
27#[cfg(test)]
28const LATEST_RELEASE_URL_OVERRIDE_ENV_VAR: &str = "CODEXSWITCH_CLI_LATEST_RELEASE_URL";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum UpdateAction {
33 NpmGlobalLatest,
35 BunGlobalLatest,
37 BrewUpgrade,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum InstallSource {
43 Npm,
44 Bun,
45 Brew,
46 Unknown,
47}
48
49impl UpdateAction {
50 pub fn command_args(self) -> (&'static str, &'static [&'static str]) {
52 match self {
53 UpdateAction::NpmGlobalLatest => {
54 ("npm", &["install", "-g", "@syntaxskills/codexswitch-cli"])
55 }
56 UpdateAction::BunGlobalLatest => {
57 ("bun", &["install", "-g", "@syntaxskills/codexswitch-cli"])
58 }
59 UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codexswitch-cli"]),
60 }
61 }
62
63 pub fn command_str(self) -> String {
65 let (command, args) = self.command_args();
66 shlex::try_join(std::iter::once(command).chain(args.iter().copied()))
67 .unwrap_or_else(|_| format!("{command} {}", args.join(" ")))
68 }
69}
70
71pub fn detect_install_source() -> InstallSource {
72 let exe = std::env::current_exe().unwrap_or_default();
73 let managed_by_npm = std::env::var_os("CODEXSWITCH_CLI_MANAGED_BY_NPM").is_some();
74 let managed_by_bun = std::env::var_os("CODEXSWITCH_CLI_MANAGED_BY_BUN").is_some();
75 detect_install_source_inner(
76 cfg!(target_os = "macos"),
77 &exe,
78 managed_by_npm,
79 managed_by_bun,
80 )
81}
82
83#[doc(hidden)]
84pub fn detect_install_source_inner(
85 is_macos: bool,
86 current_exe: &std::path::Path,
87 managed_by_npm: bool,
88 managed_by_bun: bool,
89) -> InstallSource {
90 let bun_install = is_bun_install(current_exe);
91 if managed_by_npm {
92 InstallSource::Npm
93 } else if managed_by_bun && bun_install {
94 InstallSource::Bun
95 } else if is_macos && is_brew_install(current_exe) {
96 InstallSource::Brew
97 } else if bun_install {
98 InstallSource::Bun
99 } else if managed_by_bun {
100 InstallSource::Npm
103 } else {
104 InstallSource::Unknown
105 }
106}
107
108fn is_bun_install(current_exe: &std::path::Path) -> bool {
109 let exe = current_exe.to_string_lossy();
110 exe.contains(".bun/install/global") || exe.contains(".bun\\install\\global")
111}
112
113fn is_brew_install(current_exe: &std::path::Path) -> bool {
114 (current_exe.starts_with("/opt/homebrew") || current_exe.starts_with("/usr/local"))
115 && current_exe.file_name().and_then(|name| name.to_str()) == Some("codexswitch-cli")
116}
117
118pub(crate) fn get_update_action() -> Option<UpdateAction> {
119 get_update_action_with_debug(cfg!(debug_assertions), detect_install_source())
120}
121
122fn get_update_action_with_debug(
123 is_debug: bool,
124 install_source: InstallSource,
125) -> Option<UpdateAction> {
126 if is_debug {
127 return None;
128 }
129 match install_source {
130 InstallSource::Npm => Some(UpdateAction::NpmGlobalLatest),
131 InstallSource::Bun => Some(UpdateAction::BunGlobalLatest),
132 InstallSource::Brew => Some(UpdateAction::BrewUpgrade),
133 InstallSource::Unknown => None,
134 }
135}
136
137#[derive(Clone, Debug)]
138pub struct UpdateConfig {
139 pub codex_home: PathBuf,
140 pub check_for_update_on_startup: bool,
141}
142
143#[derive(Deserialize, Debug, Clone)]
144struct ReleaseInfo {
145 tag_name: String,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149struct UpdateCache {
150 #[serde(default)]
151 latest_version: String,
152 #[serde(default = "update_cache_checked_default")]
153 last_checked_at: DateTime<Utc>,
154 #[serde(default)]
155 dismissed_version: Option<String>,
156 #[serde(default)]
157 last_prompted_at: Option<DateTime<Utc>>,
158}
159
160fn update_cache_checked_default() -> DateTime<Utc> {
161 DateTime::<Utc>::from_timestamp(0, 0).unwrap_or_else(Utc::now)
162}
163
164pub enum UpdatePromptOutcome {
165 Continue,
166 RunUpdate(UpdateAction),
167}
168
169pub fn run_update_prompt_if_needed(config: &UpdateConfig) -> Result<UpdatePromptOutcome, String> {
170 let mut input = io::stdin().lock();
171 let mut output = io::stderr();
172 run_update_prompt_if_needed_with_io(
173 config,
174 cfg!(debug_assertions),
175 io::stdin().is_terminal(),
176 &mut input,
177 &mut output,
178 )
179}
180
181fn run_update_prompt_if_needed_with_io(
182 config: &UpdateConfig,
183 is_debug: bool,
184 is_tty: bool,
185 input: &mut impl io::BufRead,
186 output: &mut impl Write,
187) -> Result<UpdatePromptOutcome, String> {
188 run_update_prompt_if_needed_with_io_and_source(
189 config,
190 is_debug,
191 is_tty,
192 detect_install_source(),
193 input,
194 output,
195 )
196}
197
198fn run_update_prompt_if_needed_with_io_and_source(
199 config: &UpdateConfig,
200 is_debug: bool,
201 is_tty: bool,
202 install_source: InstallSource,
203 input: &mut impl io::BufRead,
204 output: &mut impl Write,
205) -> Result<UpdatePromptOutcome, String> {
206 if is_debug {
207 return Ok(UpdatePromptOutcome::Continue);
208 }
209
210 let Some(latest_version) = get_upgrade_version_for_popup_with_debug(config, is_debug) else {
211 return Ok(UpdatePromptOutcome::Continue);
212 };
213 let Some(update_action) = get_update_action_with_debug(false, install_source) else {
214 return Ok(UpdatePromptOutcome::Continue);
215 };
216
217 let current_version = current_version();
218 if !is_tty {
219 write_prompt(
220 output,
221 format_args!(
222 "{} {current_version} -> {latest_version}\n",
223 UPDATE_TITLE_AVAILABLE
224 ),
225 )?;
226 write_prompt(
227 output,
228 format_args!(
229 "{}",
230 crate::msg1(UPDATE_NON_TTY_RUN, update_action.command_str())
231 ),
232 )?;
233 return Ok(UpdatePromptOutcome::Continue);
234 }
235
236 write_prompt(
237 output,
238 format_args!(
239 "\n✨ {} {current_version} -> {latest_version}\n",
240 UPDATE_TITLE_AVAILABLE
241 ),
242 )?;
243 write_prompt(
244 output,
245 format_args!("{}", crate::msg1(UPDATE_RELEASE_NOTES, RELEASE_NOTES_URL)),
246 )?;
247 write_prompt(output, format_args!("\n"))?;
248 write_prompt(
249 output,
250 format_args!(
251 "{}",
252 crate::msg1(UPDATE_OPTION_NOW, update_action.command_str())
253 ),
254 )?;
255 write_prompt(output, format_args!("{}", UPDATE_OPTION_SKIP))?;
256 write_prompt(output, format_args!("{}", UPDATE_OPTION_SKIP_VERSION))?;
257 write_prompt(output, format_args!("{}", UPDATE_PROMPT_SELECT))?;
258 output.flush().map_err(prompt_io_error)?;
259 let _ = mark_prompted_now(config);
260
261 let mut selection = String::new();
262 input
263 .read_line(&mut selection)
264 .map_err(|err| crate::msg1(UPDATE_ERR_READ_CHOICE, err))?;
265
266 match selection.trim() {
267 "1" => Ok(UpdatePromptOutcome::RunUpdate(update_action)),
268 "3" => {
269 if let Err(err) = dismiss_version(config, &latest_version) {
270 write_prompt(
271 output,
272 format_args!("{}", crate::msg1(UPDATE_ERR_PERSIST_DISMISSAL, err)),
273 )?;
274 }
275 Ok(UpdatePromptOutcome::Continue)
276 }
277 _ => Ok(UpdatePromptOutcome::Continue),
278 }
279}
280
281fn prompt_io_error(err: impl std::fmt::Display) -> String {
282 crate::msg1(UPDATE_ERR_SHOW_PROMPT, err)
283}
284
285fn write_prompt(output: &mut impl Write, args: std::fmt::Arguments) -> Result<(), String> {
286 output.write_fmt(args).map_err(prompt_io_error)
287}
288
289fn current_version() -> &'static str {
290 env!("CARGO_PKG_VERSION")
291}
292
293fn build_update_cache(
294 latest_version: Option<String>,
295 dismissed_version: Option<String>,
296 last_prompted_at: Option<DateTime<Utc>>,
297) -> UpdateCache {
298 UpdateCache {
299 latest_version: latest_version.unwrap_or_else(|| current_version().to_string()),
300 last_checked_at: Utc::now(),
301 dismissed_version,
302 last_prompted_at,
303 }
304}
305
306fn get_upgrade_version_with_debug(config: &UpdateConfig, is_debug: bool) -> Option<String> {
307 if updates_disabled_with_debug(config, is_debug) {
308 return None;
309 }
310 let paths = paths_for_update(config.codex_home.clone());
311 let mut info = read_update_cache(&paths).ok().flatten();
312
313 let should_check = match &info {
314 None => true,
315 Some(info) => info.last_checked_at < Utc::now() - Duration::hours(20),
316 };
317 if should_check {
318 if info.is_none() {
319 if let Err(err) = check_for_update(&paths) {
320 eprintln!("{}", crate::msg1(UPDATE_ERR_REFRESH_VERSION, err));
321 }
322 info = read_update_cache(&paths).ok().flatten();
323 } else {
324 let codex_home = config.codex_home.clone();
325 std::thread::spawn(move || {
326 let paths = paths_for_update(codex_home);
327 if let Err(err) = check_for_update(&paths) {
328 eprintln!("{}", crate::msg1(UPDATE_ERR_REFRESH_VERSION, err));
329 }
330 });
331 }
332 }
333
334 info.and_then(|info| {
335 if is_newer(&info.latest_version, current_version()).unwrap_or(false) {
336 Some(info.latest_version)
337 } else {
338 None
339 }
340 })
341}
342
343fn check_for_update(paths: &Paths) -> Result<(), String> {
344 check_for_update_with_action(paths, get_update_action())
345}
346
347fn check_for_update_with_action(
348 paths: &Paths,
349 update_action: Option<UpdateAction>,
350) -> Result<(), String> {
351 let latest_version = match update_action {
352 Some(UpdateAction::BrewUpgrade) => {
353 fetch_version_from_cask().or_else(fetch_version_from_release)
354 }
355 _ => fetch_version_from_release(),
356 };
357
358 let prev_info = read_update_cache(paths).ok().flatten();
360 let prev_dismissed = prev_info
361 .as_ref()
362 .and_then(|info| info.dismissed_version.clone());
363 let prev_prompted = prev_info.as_ref().and_then(|info| info.last_prompted_at);
364 let info = build_update_cache(latest_version, prev_dismissed, prev_prompted);
365 write_update_cache(paths, &info)
366}
367
368#[doc(hidden)]
369pub fn is_newer(latest: &str, current: &str) -> Option<bool> {
370 match (parse_version(latest), parse_version(current)) {
371 (Some(l), Some(c)) => Some(l > c),
372 _ => None,
373 }
374}
375
376#[doc(hidden)]
377pub fn extract_version_from_cask(cask_contents: &str) -> Result<String, String> {
378 cask_contents
379 .lines()
380 .find_map(|line| {
381 let line = line.trim();
382 line.strip_prefix("version \"")
383 .and_then(|rest| rest.strip_suffix('"'))
384 .map(ToString::to_string)
385 })
386 .ok_or_else(|| "Failed to find version in Homebrew cask file".to_string())
387}
388
389#[doc(hidden)]
390pub fn extract_version_from_latest_tag(latest_tag_name: &str) -> Result<String, String> {
391 for prefix in ["v", "rust-v"] {
392 if let Some(version) = latest_tag_name.strip_prefix(prefix) {
393 return Ok(version.to_string());
394 }
395 }
396 Err(format!(
397 "Failed to parse latest tag name '{latest_tag_name}'"
398 ))
399}
400
401fn fetch_version_from_cask() -> Option<String> {
402 let response = update_agent()
403 .get(&homebrew_cask_url())
404 .header("User-Agent", "codexswitch-cli")
405 .call();
406 match response {
407 Ok(mut resp) => {
408 let contents = resp.body_mut().read_to_string().ok()?;
409 extract_version_from_cask(&contents).ok()
410 }
411 Err(ureq::Error::StatusCode(404)) => None,
412 Err(_) => None,
413 }
414}
415
416fn fetch_version_from_release() -> Option<String> {
417 let response = update_agent()
418 .get(&latest_release_url())
419 .header("User-Agent", "codexswitch-cli")
420 .call();
421 match response {
422 Ok(mut resp) => {
423 let ReleaseInfo {
424 tag_name: latest_tag_name,
425 } = resp.body_mut().read_json().ok()?;
426 extract_version_from_latest_tag(&latest_tag_name).ok()
427 }
428 Err(ureq::Error::StatusCode(404)) => None,
429 Err(_) => None,
430 }
431}
432
433fn get_upgrade_version_for_popup_with_debug(
434 config: &UpdateConfig,
435 is_debug: bool,
436) -> Option<String> {
437 if updates_disabled_with_debug(config, is_debug) {
438 return None;
439 }
440
441 let paths = paths_for_update(config.codex_home.clone());
442 let latest = get_upgrade_version_with_debug(config, is_debug)?;
443 let info = read_update_cache(&paths).ok().flatten();
444 if info
445 .as_ref()
446 .and_then(|info| info.last_prompted_at)
447 .is_some_and(|last| last > Utc::now() - Duration::hours(24))
448 {
449 return None;
450 }
451 if info
453 .as_ref()
454 .and_then(|info| info.dismissed_version.as_deref())
455 == Some(latest.as_str())
456 {
457 return None;
458 }
459 Some(latest)
460}
461
462pub fn dismiss_version(config: &UpdateConfig, version: &str) -> Result<(), String> {
465 if !config.check_for_update_on_startup {
466 return Ok(());
467 }
468 let paths = paths_for_update(config.codex_home.clone());
469 let mut info = match read_update_cache(&paths) {
470 Ok(Some(info)) => info,
471 _ => return Ok(()),
472 };
473 info.dismissed_version = Some(version.to_string());
474 info.last_prompted_at = Some(Utc::now());
475 write_update_cache(&paths, &info)
476}
477
478fn mark_prompted_now(config: &UpdateConfig) -> Result<(), String> {
479 if !config.check_for_update_on_startup {
480 return Ok(());
481 }
482 let paths = paths_for_update(config.codex_home.clone());
483 let mut info = match read_update_cache(&paths) {
484 Ok(Some(info)) => info,
485 _ => return Ok(()),
486 };
487 info.last_prompted_at = Some(Utc::now());
488 write_update_cache(&paths, &info)
489}
490
491fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
492 let mut iter = v.trim().split('.');
493 let maj = iter.next()?.parse::<u64>().ok()?;
494 let min = iter.next()?.parse::<u64>().ok()?;
495 let pat = iter.next()?.parse::<u64>().ok()?;
496 Some((maj, min, pat))
497}
498
499fn updates_disabled_with_debug(config: &UpdateConfig, is_debug: bool) -> bool {
500 is_debug || !config.check_for_update_on_startup
501}
502
503fn paths_for_update(codex_home: PathBuf) -> Paths {
504 let profiles = default_profiles_dir(&codex_home);
505 Paths {
506 auth: codex_home.join("auth.json"),
507 config: codex_home.join("config.toml"),
508 profiles_index: profiles.join("profiles.json"),
509 update_cache: profiles.join("update.json"),
510 profiles_lock: profiles.join("profiles.lock"),
511 codex: codex_home,
512 profiles,
513 }
514}
515
516fn read_update_cache(paths: &Paths) -> Result<Option<UpdateCache>, String> {
517 if !paths.update_cache.is_file() {
518 if let Some(legacy) = read_legacy_update_cache(paths)? {
519 let _ = write_update_cache(paths, &legacy);
520 return Ok(Some(legacy));
521 }
522 return Ok(None);
523 }
524 let contents = fs::read_to_string(&paths.update_cache).map_err(|e| e.to_string())?;
525 if contents.trim().is_empty() {
526 return Ok(None);
527 }
528 let cache = serde_json::from_str::<UpdateCache>(&contents).map_err(|e| e.to_string())?;
529 Ok(Some(cache))
530}
531
532fn write_update_cache(paths: &Paths, cache: &UpdateCache) -> Result<(), String> {
533 let _lock = lock_usage(paths)?;
534 let contents = serde_json::to_string_pretty(cache).map_err(|e| e.to_string())?;
535 write_atomic(&paths.update_cache, format!("{contents}\n").as_bytes())
536}
537
538fn read_legacy_update_cache(paths: &Paths) -> Result<Option<UpdateCache>, String> {
539 if !paths.profiles_index.is_file() {
540 return Ok(None);
541 }
542 let contents = fs::read_to_string(&paths.profiles_index).map_err(|e| e.to_string())?;
543 let json: serde_json::Value = serde_json::from_str(&contents).map_err(|e| e.to_string())?;
544 let Some(value) = json.get("update_cache") else {
545 return Ok(None);
546 };
547 let cache = serde_json::from_value::<UpdateCache>(value.clone()).map_err(|e| e.to_string())?;
548 if let Ok(index) = read_profiles_index(paths) {
549 let _ = write_profiles_index(paths, &index);
550 }
551 Ok(Some(cache))
552}
553
554fn update_agent() -> ureq::Agent {
555 let config = ureq::Agent::config_builder()
556 .timeout_global(Some(StdDuration::from_secs(5)))
557 .build();
558 config.into()
559}
560
561fn latest_release_url() -> String {
562 #[cfg(test)]
563 {
564 env_url(LATEST_RELEASE_URL_OVERRIDE_ENV_VAR, LATEST_RELEASE_URL)
565 }
566 #[cfg(not(test))]
567 {
568 LATEST_RELEASE_URL.to_string()
569 }
570}
571
572fn homebrew_cask_url() -> String {
573 #[cfg(test)]
574 {
575 env_url(HOMEBREW_CASK_URL_OVERRIDE_ENV_VAR, HOMEBREW_CASK_URL)
576 }
577 #[cfg(not(test))]
578 {
579 HOMEBREW_CASK_URL.to_string()
580 }
581}
582
583#[cfg(test)]
584fn env_url(override_var: &str, default: &str) -> String {
585 std::env::var(override_var).unwrap_or_else(|_| default.to_string())
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591 use crate::test_utils::{ENV_MUTEX, http_ok_response, set_env_guard, spawn_server};
592 use std::fs;
593 use std::path::PathBuf;
594
595 fn seed_version_info(config: &UpdateConfig, version: &str) {
596 let paths = paths_for_update(config.codex_home.clone());
597 fs::create_dir_all(&paths.profiles).unwrap();
598 fs::write(&paths.profiles_lock, "").unwrap();
599 let info = UpdateCache {
600 latest_version: version.to_string(),
601 last_checked_at: Utc::now(),
602 dismissed_version: None,
603 last_prompted_at: None,
604 };
605 write_update_cache(&paths, &info).unwrap();
606 }
607
608 #[test]
609 fn update_action_commands() {
610 let (cmd, args) = UpdateAction::NpmGlobalLatest.command_args();
611 assert_eq!(cmd, "npm");
612 assert!(args.contains(&"install"));
613 assert!(UpdateAction::BunGlobalLatest.command_str().contains("bun"));
614 }
615
616 #[test]
617 fn detect_install_source_inner_variants() {
618 let exe = PathBuf::from("/usr/local/bin/codexswitch-cli");
619 assert_eq!(
620 detect_install_source_inner(true, &exe, false, false),
621 InstallSource::Brew
622 );
623 assert_eq!(
624 detect_install_source_inner(false, &exe, true, false),
625 InstallSource::Npm
626 );
627 assert_eq!(
628 detect_install_source_inner(false, &exe, false, true),
629 InstallSource::Npm
630 );
631
632 let bun_exe = PathBuf::from(
633 "/Users/dev/.bun/install/global/node_modules/codexswitch-cli/bin/codexswitch-cli",
634 );
635 assert_eq!(
636 detect_install_source_inner(false, &bun_exe, false, true),
637 InstallSource::Bun
638 );
639 assert_eq!(
640 detect_install_source_inner(false, &bun_exe, false, false),
641 InstallSource::Bun
642 );
643 }
644
645 #[test]
646 fn get_update_action_debug() {
647 assert!(get_update_action_with_debug(true, InstallSource::Npm).is_none());
648 assert!(get_update_action_with_debug(false, InstallSource::Npm).is_some());
649 }
650
651 #[test]
652 fn extract_version_helpers() {
653 assert_eq!(extract_version_from_latest_tag("v1.2.3").unwrap(), "1.2.3");
654 assert_eq!(
655 extract_version_from_latest_tag("rust-v2.0.0").unwrap(),
656 "2.0.0"
657 );
658 assert!(extract_version_from_latest_tag("bad").is_err());
659 let cask = "version \"1.2.3\"";
660 assert_eq!(extract_version_from_cask(cask).unwrap(), "1.2.3");
661 assert!(extract_version_from_cask("nope").is_err());
662 }
663
664 #[test]
665 fn parse_version_and_compare() {
666 assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
667 assert!(is_newer("2.0.0", "1.9.9").unwrap());
668 assert!(is_newer("bad", "1.0.0").is_none());
669 }
670
671 #[test]
672 fn url_overrides_work() {
673 let _guard = ENV_MUTEX.lock().unwrap();
674 let _env = set_env_guard(
675 LATEST_RELEASE_URL_OVERRIDE_ENV_VAR,
676 Some("http://example.com"),
677 );
678 assert_eq!(latest_release_url(), "http://example.com");
679 }
680
681 #[test]
682 fn fetch_versions_from_servers() {
683 let _guard = ENV_MUTEX.lock().unwrap();
684 let release_body = "{\"tag_name\":\"v9.9.9\"}";
685 let release_resp = http_ok_response(release_body, "application/json");
686 let release_url = spawn_server(release_resp);
687 {
688 let _env = set_env_guard(LATEST_RELEASE_URL_OVERRIDE_ENV_VAR, Some(&release_url));
689 assert_eq!(fetch_version_from_release().unwrap(), "9.9.9");
690 }
691
692 let cask_body = "version \"9.9.9\"";
693 let cask_resp = http_ok_response(cask_body, "text/plain");
694 let cask_url = spawn_server(cask_resp);
695 {
696 let _env = set_env_guard(HOMEBREW_CASK_URL_OVERRIDE_ENV_VAR, Some(&cask_url));
697 assert_eq!(fetch_version_from_cask().unwrap(), "9.9.9");
698 }
699 }
700
701 #[test]
702 fn fetch_versions_handle_404() {
703 let _guard = ENV_MUTEX.lock().unwrap();
704 let resp = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n".to_string();
705 let url = spawn_server(resp);
706 let _env = set_env_guard(LATEST_RELEASE_URL_OVERRIDE_ENV_VAR, Some(&url));
707 assert!(fetch_version_from_release().is_none());
708 }
709
710 #[test]
711 fn check_for_update_writes_version() {
712 let _guard = ENV_MUTEX.lock().unwrap();
713 let release_body = "{\"tag_name\":\"v9.9.9\"}";
714 let release_resp = http_ok_response(release_body, "application/json");
715 let release_url = spawn_server(release_resp);
716 let _env = set_env_guard(LATEST_RELEASE_URL_OVERRIDE_ENV_VAR, Some(&release_url));
717
718 let dir = tempfile::tempdir().expect("tempdir");
719 let paths = paths_for_update(dir.path().to_path_buf());
720 fs::create_dir_all(&paths.profiles).unwrap();
721 fs::write(&paths.profiles_lock, "").unwrap();
722 check_for_update_with_action(&paths, None).unwrap();
723 let contents = fs::read_to_string(&paths.update_cache).unwrap();
724 assert!(contents.contains("9.9.9"));
725 }
726
727 #[test]
728 fn read_update_cache_migrates_legacy_profiles_schema() {
729 let dir = tempfile::tempdir().expect("tempdir");
730 let paths = paths_for_update(dir.path().to_path_buf());
731 fs::create_dir_all(&paths.profiles).unwrap();
732 fs::write(&paths.profiles_lock, "").unwrap();
733 let legacy = serde_json::json!({
734 "version": 1,
735 "profiles": {},
736 "update_cache": {
737 "latest_version": "1.2.3",
738 "last_checked_at": "2024-01-01T00:00:00Z"
739 }
740 });
741 fs::write(
742 &paths.profiles_index,
743 serde_json::to_string_pretty(&legacy).unwrap(),
744 )
745 .unwrap();
746
747 let migrated = read_update_cache(&paths).unwrap().unwrap();
748 assert_eq!(migrated.latest_version, "1.2.3");
749 assert!(paths.update_cache.is_file());
750 let index_contents = fs::read_to_string(&paths.profiles_index).unwrap();
751 assert!(!index_contents.contains("update_cache"));
752 }
753
754 #[test]
755 fn updates_disabled_variants() {
756 let config = UpdateConfig {
757 codex_home: PathBuf::new(),
758 check_for_update_on_startup: false,
759 };
760 assert!(updates_disabled_with_debug(&config, false));
761 let config = UpdateConfig {
762 codex_home: PathBuf::new(),
763 check_for_update_on_startup: true,
764 };
765 assert!(updates_disabled_with_debug(&config, true));
766 }
767
768 #[test]
769 fn run_update_prompt_paths() {
770 let _guard = ENV_MUTEX.lock().unwrap();
771 let release_body = format!("{{\"tag_name\":\"v{}\"}}", "99.0.0");
772 let release_resp = http_ok_response(&release_body, "application/json");
773 let release_url = spawn_server(release_resp);
774 let _env = set_env_guard(LATEST_RELEASE_URL_OVERRIDE_ENV_VAR, Some(&release_url));
775
776 let dir = tempfile::tempdir().expect("tempdir");
777 let config = UpdateConfig {
778 codex_home: dir.path().to_path_buf(),
779 check_for_update_on_startup: true,
780 };
781 seed_version_info(&config, "99.0.0");
782 let mut input = std::io::Cursor::new("2\n");
783 let mut output = Vec::new();
784 let result = run_update_prompt_if_needed_with_io_and_source(
785 &config,
786 false,
787 false,
788 InstallSource::Npm,
789 &mut input,
790 &mut output,
791 )
792 .unwrap();
793 assert!(matches!(result, UpdatePromptOutcome::Continue));
794
795 let cache = read_update_cache(&paths_for_update(config.codex_home.clone()))
796 .unwrap()
797 .expect("update cache");
798 assert!(cache.last_prompted_at.is_none());
799 let output = String::from_utf8(output).expect("utf8 output");
800 assert!(output.contains("Run `npm install -g @syntaxskills/codexswitch-cli` to update."));
801
802 let dir = tempfile::tempdir().expect("tempdir");
803 let config = UpdateConfig {
804 codex_home: dir.path().to_path_buf(),
805 check_for_update_on_startup: true,
806 };
807 seed_version_info(&config, "99.0.0");
808 let mut input = std::io::Cursor::new("1\n");
809 let mut output = Vec::new();
810 let result = run_update_prompt_if_needed_with_io_and_source(
811 &config,
812 false,
813 true,
814 InstallSource::Npm,
815 &mut input,
816 &mut output,
817 )
818 .unwrap();
819 assert!(matches!(result, UpdatePromptOutcome::RunUpdate(_)));
820
821 let cache = read_update_cache(&paths_for_update(config.codex_home.clone()))
822 .unwrap()
823 .expect("update cache");
824 assert!(cache.last_prompted_at.is_some());
825
826 let mut input = std::io::Cursor::new("1\n");
827 let mut output = Vec::new();
828 let result = run_update_prompt_if_needed_with_io_and_source(
829 &config,
830 false,
831 true,
832 InstallSource::Npm,
833 &mut input,
834 &mut output,
835 )
836 .unwrap();
837 assert!(matches!(result, UpdatePromptOutcome::Continue));
838 assert!(output.is_empty());
839
840 let dir = tempfile::tempdir().expect("tempdir");
841 let config = UpdateConfig {
842 codex_home: dir.path().to_path_buf(),
843 check_for_update_on_startup: true,
844 };
845 seed_version_info(&config, "99.0.0");
846 let mut input = std::io::Cursor::new("3\n");
847 let mut output = Vec::new();
848 let result = run_update_prompt_if_needed_with_io_and_source(
849 &config,
850 false,
851 true,
852 InstallSource::Npm,
853 &mut input,
854 &mut output,
855 )
856 .unwrap();
857 assert!(matches!(result, UpdatePromptOutcome::Continue));
858 }
859}