2025-11-10 09:05:00 -08:00
#![ cfg(not(debug_assertions)) ]
use crate ::update_action ;
use crate ::update_action ::UpdateAction ;
2025-08-01 17:31:38 -07:00
use chrono ::DateTime ;
use chrono ::Duration ;
use chrono ::Utc ;
2025-11-10 09:05:00 -08:00
use codex_core ::config ::Config ;
use codex_core ::default_client ::create_client ;
2025-08-01 17:31:38 -07:00
use serde ::Deserialize ;
use serde ::Serialize ;
use std ::path ::Path ;
use std ::path ::PathBuf ;
2025-09-05 16:27:31 -07:00
use crate ::version ::CODEX_CLI_VERSION ;
2025-08-01 17:31:38 -07:00
pub fn get_upgrade_version ( config : & Config ) -> Option < String > {
let version_file = version_filepath ( config ) ;
let info = read_version_info ( & version_file ) . ok ( ) ;
if match & info {
None = > true ,
Some ( info ) = > info . last_checked_at < Utc ::now ( ) - Duration ::hours ( 20 ) ,
} {
// Refresh the cached latest version in the background so TUI startup
// isn’ t blocked by a network call. The UI reads the previously cached
// value (if any) for this run; the next run shows the banner if needed.
tokio ::spawn ( async move {
2025-09-09 14:23:23 -07:00
check_for_update ( & version_file )
2025-08-01 17:31:38 -07:00
. await
. inspect_err ( | e | tracing ::error! ( " Failed to update version: {e} " ) )
} ) ;
}
info . and_then ( | info | {
2025-09-05 16:27:31 -07:00
if is_newer ( & info . latest_version , CODEX_CLI_VERSION ) . unwrap_or ( false ) {
2025-08-01 17:31:38 -07:00
Some ( info . latest_version )
} else {
None
}
} )
}
#[ derive(Serialize, Deserialize, Debug, Clone) ]
struct VersionInfo {
latest_version : String ,
// ISO-8601 timestamp (RFC3339)
last_checked_at : DateTime < Utc > ,
2025-10-15 16:11:20 -07:00
#[ serde(default) ]
dismissed_version : Option < String > ,
2025-08-01 17:31:38 -07:00
}
2025-11-10 09:05:00 -08:00
const VERSION_FILENAME : & str = " version.json " ;
// 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.
const HOMEBREW_CASK_URL : & str =
" https://raw.githubusercontent.com/Homebrew/homebrew-cask/HEAD/Casks/c/codex.rb " ;
const LATEST_RELEASE_URL : & str = " https://api.github.com/repos/openai/codex/releases/latest " ;
2025-08-01 17:31:38 -07:00
#[ derive(Deserialize, Debug, Clone) ]
struct ReleaseInfo {
tag_name : String ,
}
fn version_filepath ( config : & Config ) -> PathBuf {
config . codex_home . join ( VERSION_FILENAME )
}
fn read_version_info ( version_file : & Path ) -> anyhow ::Result < VersionInfo > {
let contents = std ::fs ::read_to_string ( version_file ) ? ;
Ok ( serde_json ::from_str ( & contents ) ? )
}
2025-09-09 14:23:23 -07:00
async fn check_for_update ( version_file : & Path ) -> anyhow ::Result < ( ) > {
2025-11-10 09:05:00 -08:00
let latest_version = match update_action ::get_update_action ( ) {
Some ( UpdateAction ::BrewUpgrade ) = > {
let cask_contents = create_client ( )
. get ( HOMEBREW_CASK_URL )
. send ( )
. await ?
. error_for_status ( ) ?
. text ( )
. await ? ;
extract_version_from_cask ( & cask_contents ) ?
}
_ = > {
let ReleaseInfo {
tag_name : latest_tag_name ,
} = create_client ( )
. get ( LATEST_RELEASE_URL )
. send ( )
. await ?
. error_for_status ( ) ?
. json ::< ReleaseInfo > ( )
. await ? ;
extract_version_from_latest_tag ( & latest_tag_name ) ?
}
} ;
2025-08-01 17:31:38 -07:00
2025-10-15 16:11:20 -07:00
// Preserve any previously dismissed version if present.
let prev_info = read_version_info ( version_file ) . ok ( ) ;
2025-08-01 17:31:38 -07:00
let info = VersionInfo {
2025-11-10 09:05:00 -08:00
latest_version ,
2025-08-01 17:31:38 -07:00
last_checked_at : Utc ::now ( ) ,
2025-10-15 16:11:20 -07:00
dismissed_version : prev_info . and_then ( | p | p . dismissed_version ) ,
2025-08-01 17:31:38 -07:00
} ;
let json_line = format! ( " {} \n " , serde_json ::to_string ( & info ) ? ) ;
if let Some ( parent ) = version_file . parent ( ) {
tokio ::fs ::create_dir_all ( parent ) . await ? ;
}
tokio ::fs ::write ( version_file , json_line ) . await ? ;
Ok ( ( ) )
}
fn is_newer ( latest : & str , current : & str ) -> Option < bool > {
match ( parse_version ( latest ) , parse_version ( current ) ) {
( Some ( l ) , Some ( c ) ) = > Some ( l > c ) ,
_ = > None ,
}
}
2025-11-10 09:05:00 -08:00
fn extract_version_from_cask ( cask_contents : & str ) -> anyhow ::Result < String > {
cask_contents
. lines ( )
. find_map ( | line | {
let line = line . trim ( ) ;
line . strip_prefix ( " version \" " )
. and_then ( | rest | rest . strip_suffix ( '"' ) )
. map ( ToString ::to_string )
} )
. ok_or_else ( | | anyhow ::anyhow! ( " Failed to find version in Homebrew cask file " ) )
}
fn extract_version_from_latest_tag ( latest_tag_name : & str ) -> anyhow ::Result < String > {
latest_tag_name
. strip_prefix ( " rust-v " )
. map ( str ::to_owned )
. ok_or_else ( | | anyhow ::anyhow! ( " Failed to parse latest tag name '{latest_tag_name}' " ) )
}
2025-10-15 16:11:20 -07:00
/// Returns the latest version to show in a popup, if it should be shown.
/// This respects the user's dismissal choice for the current latest version.
pub fn get_upgrade_version_for_popup ( config : & Config ) -> Option < String > {
let version_file = version_filepath ( config ) ;
let latest = get_upgrade_version ( config ) ? ;
// If the user dismissed this exact version previously, do not show the popup.
if let Ok ( info ) = read_version_info ( & version_file )
& & info . dismissed_version . as_deref ( ) = = Some ( latest . as_str ( ) )
{
return None ;
}
Some ( latest )
}
/// Persist a dismissal for the current latest version so we don't show
/// the update popup again for this version.
pub async fn dismiss_version ( config : & Config , version : & str ) -> anyhow ::Result < ( ) > {
let version_file = version_filepath ( config ) ;
let mut info = match read_version_info ( & version_file ) {
Ok ( info ) = > info ,
Err ( _ ) = > return Ok ( ( ) ) ,
} ;
info . dismissed_version = Some ( version . to_string ( ) ) ;
let json_line = format! ( " {} \n " , serde_json ::to_string ( & info ) ? ) ;
if let Some ( parent ) = version_file . parent ( ) {
tokio ::fs ::create_dir_all ( parent ) . await ? ;
}
tokio ::fs ::write ( version_file , json_line ) . await ? ;
Ok ( ( ) )
}
2025-08-01 17:31:38 -07:00
fn parse_version ( v : & str ) -> Option < ( u64 , u64 , u64 ) > {
let mut iter = v . trim ( ) . split ( '.' ) ;
let maj = iter . next ( ) ? . parse ::< u64 > ( ) . ok ( ) ? ;
let min = iter . next ( ) ? . parse ::< u64 > ( ) . ok ( ) ? ;
let pat = iter . next ( ) ? . parse ::< u64 > ( ) . ok ( ) ? ;
Some ( ( maj , min , pat ) )
}
2025-11-10 09:05:00 -08:00
#[ cfg(test) ]
mod tests {
use super ::* ;
2025-10-20 14:40:14 -07:00
2025-11-10 09:05:00 -08:00
#[ test ]
fn parses_version_from_cask_contents ( ) {
let cask = r #"
cask " codex " do
version " 0.55.0 "
end
" #;
assert_eq! (
extract_version_from_cask ( cask ) . expect ( " failed to parse version " ) ,
" 0.55.0 "
) ;
2025-10-20 14:40:14 -07:00
}
2025-11-10 09:05:00 -08:00
#[ test ]
fn extracts_version_from_latest_tag ( ) {
assert_eq! (
extract_version_from_latest_tag ( " rust-v1.5.0 " ) . expect ( " failed to parse version " ) ,
" 1.5.0 "
) ;
2025-10-20 14:40:14 -07:00
}
2025-11-10 09:05:00 -08:00
#[ test ]
fn latest_tag_without_prefix_is_invalid ( ) {
assert! ( extract_version_from_latest_tag ( " v1.5.0 " ) . is_err ( ) ) ;
2025-10-20 14:40:14 -07:00
}
2025-08-01 17:31:38 -07:00
#[ test ]
fn prerelease_version_is_not_considered_newer ( ) {
assert_eq! ( is_newer ( " 0.11.0-beta.1 " , " 0.11.0 " ) , None ) ;
assert_eq! ( is_newer ( " 1.0.0-rc.1 " , " 1.0.0 " ) , None ) ;
}
#[ test ]
fn plain_semver_comparisons_work ( ) {
assert_eq! ( is_newer ( " 0.11.1 " , " 0.11.0 " ) , Some ( true ) ) ;
assert_eq! ( is_newer ( " 0.11.0 " , " 0.11.1 " ) , Some ( false ) ) ;
assert_eq! ( is_newer ( " 1.0.0 " , " 0.9.9 " ) , Some ( true ) ) ;
assert_eq! ( is_newer ( " 0.9.9 " , " 1.0.0 " ) , Some ( false ) ) ;
}
#[ test ]
fn whitespace_is_ignored ( ) {
assert_eq! ( parse_version ( " 1.2.3 \n " ) , Some ( ( 1 , 2 , 3 ) ) ) ;
assert_eq! ( is_newer ( " 1.2.3 " , " 1.2.2 " ) , Some ( true ) ) ;
}
}