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