Skip to main content

codex_profiles/
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 = "CODEX_PROFILES_HOMEBREW_CASK_URL";
27#[cfg(test)]
28const LATEST_RELEASE_URL_OVERRIDE_ENV_VAR: &str = "CODEX_PROFILES_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")
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        // Defensive fallback: older wrappers may leak bun hints from ambient env
105        // even when the installed binary is not managed by bun.
106        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    // Preserve any previously dismissed version if present.
363    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 the user dismissed this exact version previously, do not show the popup.
456    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
466/// Persist a dismissal for the current latest version so we don't show
467/// the update popup again for this version.
468pub 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}