This commit is contained in:
Ahmed Ibrahim
2025-12-13 18:38:48 -08:00
parent 1ad261d681
commit 309c2f5f94
8 changed files with 369 additions and 110 deletions

View File

@@ -82,6 +82,8 @@ use crate::context_manager::ContextManager;
use crate::environment_context::EnvironmentContext;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::error::error_event_with_update_nudge;
use crate::error::stream_error_event_with_update_nudge;
#[cfg(test)]
use crate::exec::StreamOutput;
use crate::exec_policy::ExecPolicyUpdateError;
@@ -108,7 +110,6 @@ use crate::protocol::SessionConfiguredEvent;
use crate::protocol::SkillErrorInfo;
use crate::protocol::SkillInfo;
use crate::protocol::SkillLoadOutcomeInfo;
use crate::protocol::StreamErrorEvent;
use crate::protocol::Submission;
use crate::protocol::TokenCountEvent;
use crate::protocol::TokenUsage;
@@ -201,6 +202,13 @@ fn maybe_push_chat_wire_api_deprecation(
});
}
fn compute_is_up_to_date(codex_home: &std::path::Path) -> bool {
let version_file = codex_home.join(crate::version::VERSION_FILENAME);
crate::version::read_latest_version(&version_file)
.and_then(|latest| crate::version::is_up_to_date(&latest, env!("CARGO_PKG_VERSION")))
.unwrap_or(true)
}
impl Codex {
/// Spawn a new [`Codex`] and initialize the session.
pub async fn spawn(
@@ -251,6 +259,7 @@ impl Codex {
error!("failed to refresh available models: {err:?}");
}
let model = models_manager.get_model(&config.model, &config).await;
let is_up_to_date = compute_is_up_to_date(&config.codex_home);
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
model: model.clone(),
@@ -266,6 +275,7 @@ impl Codex {
original_config_do_not_use: Arc::clone(&config),
exec_policy,
session_source,
is_up_to_date,
};
// Generate a unique ID for the lifetime of this Codex session.
@@ -430,6 +440,9 @@ pub(crate) struct SessionConfiguration {
original_config_do_not_use: Arc<Config>,
/// Source of the session (cli, vscode, exec, mcp, ...)
session_source: SessionSource,
/// Whether the CLI is up to date with the latest known version at session start.
#[allow(dead_code)]
is_up_to_date: bool,
}
impl SessionConfiguration {
@@ -762,6 +775,11 @@ impl Session {
state.get_total_token_usage()
}
pub(crate) async fn is_up_to_date(&self) -> bool {
let state = self.state.lock().await;
state.session_configuration.is_up_to_date
}
async fn record_initial_history(&self, conversation_history: InitialHistory) {
let turn_context = self.new_turn(SessionSettingsUpdate::default()).await;
match conversation_history {
@@ -791,15 +809,11 @@ impl Session {
warn!(
"resuming session with different model: previous={prev}, current={curr}"
);
self.send_event(
&turn_context,
EventMsg::Warning(WarningEvent {
message: format!(
"This session was recorded with model `{prev}` but is resuming with `{curr}`. \
let message = format!(
"This session was recorded with model `{prev}` but is resuming with `{curr}`. \
Consider switching back to `{prev}` as it may affect Codex performance."
),
}),
)
);
self.send_event(&turn_context, EventMsg::Warning(WarningEvent { message }))
.await;
}
}
@@ -1380,10 +1394,11 @@ impl Session {
let codex_error_info = CodexErrorInfo::ResponseStreamDisconnected {
http_status_code: codex_error.http_status_code_value(),
};
let event = EventMsg::StreamError(StreamErrorEvent {
message: message.into(),
codex_error_info: Some(codex_error_info),
});
let event = EventMsg::StreamError(stream_error_event_with_update_nudge(
message.into(),
Some(codex_error_info),
self.is_up_to_date().await,
));
self.send_event(turn_context, event).await;
}
@@ -1629,6 +1644,7 @@ mod handlers {
use crate::codex::spawn_review_thread;
use crate::config::Config;
use crate::error::error_event_with_update_nudge;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp::collect_mcp_snapshot_from_manager;
use crate::review_prompts::resolve_review_request;
@@ -1638,7 +1654,6 @@ mod handlers {
use crate::tasks::UserShellCommandTask;
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ListCustomPromptsResponseEvent;
@@ -1920,12 +1935,14 @@ mod handlers {
&& let Err(e) = rec.shutdown().await
{
warn!("failed to shutdown rollout recorder: {e}");
let is_up_to_date = sess.is_up_to_date().await;
let event = Event {
id: sub_id.clone(),
msg: EventMsg::Error(ErrorEvent {
message: "Failed to shutdown rollout recorder".to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
}),
msg: EventMsg::Error(error_event_with_update_nudge(
"Failed to shutdown rollout recorder".to_string(),
Some(CodexErrorInfo::Other),
is_up_to_date,
)),
};
sess.send_event_raw(event).await;
}
@@ -1959,12 +1976,14 @@ mod handlers {
.await;
}
Err(err) => {
let is_up_to_date = sess.is_up_to_date().await;
let event = Event {
id: sub_id,
msg: EventMsg::Error(ErrorEvent {
message: err.to_string(),
codex_error_info: Some(CodexErrorInfo::Other),
}),
msg: EventMsg::Error(error_event_with_update_nudge(
err.to_string(),
Some(CodexErrorInfo::Other),
is_up_to_date,
)),
};
sess.send_event(&turn_context, event.msg).await;
}
@@ -2229,7 +2248,13 @@ pub(crate) async fn run_task(
}
Err(e) => {
info!("Turn error: {e:#}");
let event = EventMsg::Error(e.to_error_event(None));
let is_up_to_date = sess.is_up_to_date().await;
let error_event = e.to_error_event(None);
let event = EventMsg::Error(error_event_with_update_nudge(
error_event.message,
error_event.codex_error_info,
is_up_to_date,
));
sess.send_event(&turn_context, event).await;
// let the user continue the conversation
break;
@@ -2723,6 +2748,7 @@ mod tests {
original_config_do_not_use: Arc::clone(&config),
exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())),
session_source: SessionSource::Exec,
is_up_to_date: true,
};
let mut state = SessionState::new(session_configuration);
@@ -2795,6 +2821,7 @@ mod tests {
original_config_do_not_use: Arc::clone(&config),
exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())),
session_source: SessionSource::Exec,
is_up_to_date: true,
};
let mut state = SessionState::new(session_configuration);
@@ -2999,6 +3026,7 @@ mod tests {
original_config_do_not_use: Arc::clone(&config),
exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())),
session_source: SessionSource::Exec,
is_up_to_date: true,
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_family = ModelsManager::construct_model_family_offline(
@@ -3089,6 +3117,7 @@ mod tests {
original_config_do_not_use: Arc::clone(&config),
exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())),
session_source: SessionSource::Exec,
is_up_to_date: true,
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_family = ModelsManager::construct_model_family_offline(

View File

@@ -8,6 +8,7 @@ use crate::codex::TurnContext;
use crate::codex::get_last_assistant_message_from_turn;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::error::error_event_with_update_nudge;
use crate::features::Feature;
use crate::protocol::CompactedItem;
use crate::protocol::ContextCompactedEvent;
@@ -67,6 +68,7 @@ async fn run_compact_task_inner(
input: Vec<UserInput>,
) {
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
let is_up_to_date = sess.is_up_to_date().await;
let mut history = sess.clone_history().await;
history.record_items(
@@ -125,7 +127,12 @@ async fn run_compact_task_inner(
continue;
}
sess.set_total_tokens_full(turn_context.as_ref()).await;
let event = EventMsg::Error(e.to_error_event(None));
let error_event = e.to_error_event(None);
let event = EventMsg::Error(error_event_with_update_nudge(
error_event.message,
error_event.codex_error_info,
is_up_to_date,
));
sess.send_event(&turn_context, event).await;
return;
}
@@ -142,7 +149,12 @@ async fn run_compact_task_inner(
tokio::time::sleep(delay).await;
continue;
} else {
let event = EventMsg::Error(e.to_error_event(None));
let error_event = e.to_error_event(None);
let event = EventMsg::Error(error_event_with_update_nudge(
error_event.message,
error_event.codex_error_info,
is_up_to_date,
));
sess.send_event(&turn_context, event).await;
return;
}

View File

@@ -4,6 +4,7 @@ use crate::Prompt;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::error::Result as CodexResult;
use crate::error::error_event_with_update_nudge;
use crate::protocol::CompactedItem;
use crate::protocol::ContextCompactedEvent;
use crate::protocol::EventMsg;
@@ -29,9 +30,13 @@ pub(crate) async fn run_remote_compact_task(sess: Arc<Session>, turn_context: Ar
async fn run_remote_compact_task_inner(sess: &Arc<Session>, turn_context: &Arc<TurnContext>) {
if let Err(err) = run_remote_compact_task_inner_impl(sess, turn_context).await {
let event = EventMsg::Error(
err.to_error_event(Some("Error running remote compact task".to_string())),
);
let is_up_to_date = sess.is_up_to_date().await;
let error_event = err.to_error_event(Some("Error running remote compact task".to_string()));
let event = EventMsg::Error(error_event_with_update_nudge(
error_event.message,
error_event.codex_error_info,
is_up_to_date,
));
sess.send_event(turn_context, event).await;
}
}

View File

@@ -12,6 +12,7 @@ use codex_protocol::ConversationId;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::StreamErrorEvent;
use reqwest::StatusCode;
use serde_json;
use std::io;
@@ -23,6 +24,41 @@ pub type Result<T> = std::result::Result<T, CodexErr>;
/// Limit UI error messages to a reasonable size while keeping useful context.
const ERROR_MESSAGE_UI_MAX_BYTES: usize = 2 * 1024; // 4 KiB
const UPDATE_AVAILABLE_NUDGE: &str = "Update available. Run `codex update`.";
pub(crate) fn error_event_with_update_nudge(
message: String,
codex_error_info: Option<CodexErrorInfo>,
is_up_to_date: bool,
) -> ErrorEvent {
let message = maybe_append_update_nudge(message, is_up_to_date);
ErrorEvent {
message,
codex_error_info,
}
}
pub(crate) fn stream_error_event_with_update_nudge(
message: String,
codex_error_info: Option<CodexErrorInfo>,
is_up_to_date: bool,
) -> StreamErrorEvent {
StreamErrorEvent {
message: maybe_append_update_nudge(message, is_up_to_date),
codex_error_info,
}
}
fn maybe_append_update_nudge(message: String, is_up_to_date: bool) -> String {
if is_up_to_date {
return message;
}
if message.is_empty() {
UPDATE_AVAILABLE_NUDGE.to_string()
} else {
format!("{message}\n{UPDATE_AVAILABLE_NUDGE}")
}
}
#[derive(Error, Debug)]
pub enum SandboxErr {

View File

@@ -80,6 +80,7 @@ pub mod spawn;
pub mod terminal;
mod tools;
pub mod turn_diff_tracker;
pub mod version;
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
pub use rollout::INTERACTIVE_SESSION_SOURCES;
pub use rollout::RolloutRecorder;

View File

@@ -54,11 +54,12 @@ impl SessionTask for GhostSnapshotTask {
tokio::task::spawn(async move {
tokio::select! {
_ = tokio::time::sleep(SNAPSHOT_WARNING_THRESHOLD) => {
session_for_warning.session
session_for_warning
.session
.send_event(
&ctx_for_warning,
EventMsg::Warning(WarningEvent {
message: "Repository snapshot is taking longer than expected. Large untracked or ignored files can slow snapshots; consider adding large files or directories to .gitignore or disabling `undo` in your config.".to_string()
message: "Repository snapshot is taking longer than expected. Large untracked or ignored files can slow snapshots; consider adding large files or directories to .gitignore or disabling `undo` in your config.".to_string(),
}),
)
.await;

View File

@@ -0,0 +1,247 @@
use std::path::Path;
use chrono::DateTime;
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
pub const VERSION_FILENAME: &str = "version.json";
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct VersionInfo {
pub latest_version: String,
// ISO-8601 timestamp (RFC3339)
pub last_checked_at: DateTime<Utc>,
#[serde(default)]
pub dismissed_version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Version {
major: u64,
minor: u64,
patch: u64,
pre: Option<Vec<PrereleaseIdent>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PrereleaseIdent {
Numeric(u64),
Alpha(String),
}
impl Version {
pub fn parse(input: &str) -> Option<Self> {
let mut input = input.trim();
if let Some(stripped) = input.strip_prefix("rust-v") {
input = stripped;
}
if let Some(stripped) = input.strip_prefix('v') {
input = stripped;
}
let input = input.splitn(2, '+').next().unwrap_or(input);
let mut parts = input.splitn(2, '-');
let core = parts.next()?;
let pre = parts.next();
let mut nums = core.split('.');
let major = nums.next()?.parse::<u64>().ok()?;
let minor = nums.next()?.parse::<u64>().ok()?;
let patch = nums.next()?.parse::<u64>().ok()?;
if nums.next().is_some() {
return None;
}
let pre = match pre {
None => None,
Some(value) if value.is_empty() => None,
Some(value) => {
let mut idents = Vec::new();
for ident in value.split('.') {
if ident.is_empty() {
return None;
}
let parsed = if ident.chars().all(|c| c.is_ascii_digit()) {
ident.parse::<u64>().ok().map(PrereleaseIdent::Numeric)
} else {
Some(PrereleaseIdent::Alpha(ident.to_string()))
};
idents.push(parsed?);
}
Some(idents)
}
};
Some(Self {
major,
minor,
patch,
pre,
})
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match self.major.cmp(&other.major) {
std::cmp::Ordering::Equal => {}
ordering => return ordering,
}
match self.minor.cmp(&other.minor) {
std::cmp::Ordering::Equal => {}
ordering => return ordering,
}
match self.patch.cmp(&other.patch) {
std::cmp::Ordering::Equal => {}
ordering => return ordering,
}
match (&self.pre, &other.pre) {
(None, None) => std::cmp::Ordering::Equal,
(None, Some(_)) => std::cmp::Ordering::Greater,
(Some(_), None) => std::cmp::Ordering::Less,
(Some(left), Some(right)) => compare_prerelease_idents(left, right),
}
}
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
pub fn is_newer(latest: &str, current: &str) -> Option<bool> {
let latest = Version::parse(latest)?;
if latest.pre.is_some() {
return Some(false);
}
let current = Version::parse(current)?;
let current = Version {
pre: None,
..current
};
Some(latest > current)
}
pub fn is_up_to_date(latest: &str, current: &str) -> Option<bool> {
let latest = Version::parse(latest)?;
if latest.pre.is_some() {
return Some(true);
}
let current = Version::parse(current)?;
let current = Version {
pre: None,
..current
};
Some(current >= latest)
}
pub 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)?)
}
pub fn read_latest_version(version_file: &Path) -> Option<String> {
read_version_info(version_file)
.ok()
.map(|info| info.latest_version)
}
pub 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"))
}
pub 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}'"))
}
fn compare_prerelease_idents(
left: &[PrereleaseIdent],
right: &[PrereleaseIdent],
) -> std::cmp::Ordering {
for (l, r) in left.iter().zip(right.iter()) {
let ordering = match (l, r) {
(PrereleaseIdent::Numeric(a), PrereleaseIdent::Numeric(b)) => a.cmp(b),
(PrereleaseIdent::Alpha(a), PrereleaseIdent::Alpha(b)) => a.cmp(b),
(PrereleaseIdent::Numeric(_), PrereleaseIdent::Alpha(_)) => std::cmp::Ordering::Less,
(PrereleaseIdent::Alpha(_), PrereleaseIdent::Numeric(_)) => std::cmp::Ordering::Greater,
};
if ordering != std::cmp::Ordering::Equal {
return ordering;
}
}
left.len().cmp(&right.len())
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn prerelease_current_is_ignored() {
assert_eq!(is_newer("1.2.3", "1.2.3-alpha.1"), Some(false));
assert_eq!(is_up_to_date("1.2.3", "1.2.3-alpha.1"), Some(true));
}
#[test]
fn prerelease_latest_is_ignored() {
assert_eq!(is_newer("1.2.4-alpha.1", "1.2.3"), Some(false));
assert_eq!(is_up_to_date("1.2.4-alpha.1", "1.2.3"), Some(true));
}
#[test]
fn prerelease_latest_is_not_considered_newer() {
assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), Some(false));
assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), Some(false));
}
#[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!(Version::parse(" 1.2.3 \n").is_some(), true);
assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true));
}
#[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"
);
}
#[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"
);
}
#[test]
fn latest_tag_without_prefix_is_invalid() {
assert!(extract_version_from_latest_tag("v1.5.0").is_err());
}
}

View File

@@ -2,13 +2,17 @@
use crate::update_action;
use crate::update_action::UpdateAction;
use chrono::DateTime;
use chrono::Duration;
use chrono::Utc;
use codex_core::config::Config;
use codex_core::default_client::create_client;
use codex_core::version::VERSION_FILENAME;
use codex_core::version::VersionInfo;
use codex_core::version::extract_version_from_cask;
use codex_core::version::extract_version_from_latest_tag;
use codex_core::version::is_newer;
use codex_core::version::read_version_info;
use serde::Deserialize;
use serde::Serialize;
use std::path::Path;
use std::path::PathBuf;
@@ -45,16 +49,6 @@ pub fn get_upgrade_version(config: &Config) -> Option<String> {
})
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct VersionInfo {
latest_version: String,
// ISO-8601 timestamp (RFC3339)
last_checked_at: DateTime<Utc>,
#[serde(default)]
dismissed_version: Option<String>,
}
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";
@@ -69,11 +63,6 @@ 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)?)
}
async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
let latest_version = match update_action::get_update_action() {
Some(UpdateAction::BrewUpgrade) => {
@@ -116,32 +105,6 @@ async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
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,
}
}
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}'"))
}
/// 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> {
@@ -177,48 +140,14 @@ pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<(
Ok(())
}
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))
}
#[cfg(test)]
mod tests {
use super::*;
#[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"
);
}
#[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"
);
}
#[test]
fn latest_tag_without_prefix_is_invalid() {
assert!(extract_version_from_latest_tag("v1.5.0").is_err());
}
#[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);
assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), Some(false));
assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), Some(false));
}
#[test]
@@ -231,7 +160,6 @@ mod tests {
#[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));
}
}