Skip to main content

codexswitch_cli/
updates.rs

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
19// We use the latest version from the cask if installation is via homebrew - homebrew does not immediately pick up the latest release and can lag behind.
20const 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/// Update action the CLI should perform after the prompt exits.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum UpdateAction {
33    /// Update via `npm install -g @syntaxskills/codexswitch-cli`.
34    NpmGlobalLatest,
35    /// Update via `bun install -g @syntaxskills/codexswitch-cli`.
36    BunGlobalLatest,
37    /// Update via `brew upgrade codexswitch-cli`.
38    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    /// Returns the list of command-line arguments for invoking the update.
51    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    /// Returns string representation of the command-line arguments for invoking the update.
64    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        // Defensive fallback: older wrappers may leak bun hints from ambient env
101        // even when the installed binary is not managed by bun.
102        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    // Preserve any previously dismissed version if present.
359    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 the user dismissed this exact version previously, do not show the popup.
452    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
462/// Persist a dismissal for the current latest version so we don't show
463/// the update popup again for this version.
464pub 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}