#[cfg(feature = "json")]
use std::collections::HashMap;
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SandboxMode {
ReadOnly,
#[default]
WorkspaceWrite,
DangerFullAccess,
}
impl SandboxMode {
pub(crate) fn as_arg(self) -> &'static str {
match self {
Self::ReadOnly => "read-only",
Self::WorkspaceWrite => "workspace-write",
Self::DangerFullAccess => "danger-full-access",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ApprovalPolicy {
Untrusted,
OnFailure,
#[default]
OnRequest,
Never,
}
impl ApprovalPolicy {
pub(crate) fn as_arg(self) -> &'static str {
match self {
Self::Untrusted => "untrusted",
Self::OnFailure => "on-failure",
Self::OnRequest => "on-request",
Self::Never => "never",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Color {
Always,
Never,
#[default]
Auto,
}
impl Color {
pub(crate) fn as_arg(self) -> &'static str {
match self {
Self::Always => "always",
Self::Never => "never",
Self::Auto => "auto",
}
}
}
#[cfg(feature = "json")]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct JsonLineEvent {
#[serde(rename = "type", default)]
pub event_type: String,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
#[cfg(feature = "json")]
impl JsonLineEvent {
#[must_use]
pub fn session_id(&self) -> Option<&str> {
self.extra.get("session_id").and_then(|v| v.as_str())
}
#[must_use]
pub fn thread_id(&self) -> Option<&str> {
self.extra.get("thread_id").and_then(|v| v.as_str())
}
#[must_use]
pub fn is_completed(&self) -> bool {
self.event_type == "completed"
}
#[must_use]
pub fn result_text(&self) -> Option<&str> {
self.extra
.get("result")
.and_then(|v| v.get("text"))
.and_then(|v| v.as_str())
}
#[must_use]
pub fn cost_usd(&self) -> Option<f64> {
self.extra
.get("result")
.and_then(|v| v.get("cost"))
.and_then(|v| v.as_f64())
}
#[must_use]
pub fn role(&self) -> Option<&str> {
self.extra.get("role").and_then(|v| v.as_str())
}
#[must_use]
pub fn content_text(&self) -> Option<String> {
let blocks = self.extra.get("content").and_then(|v| v.as_array())?;
let text: String = blocks
.iter()
.filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("");
if text.is_empty() { None } else { Some(text) }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CliVersion {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl CliVersion {
#[must_use]
pub fn new(major: u32, minor: u32, patch: u32) -> Self {
Self {
major,
minor,
patch,
}
}
pub fn parse_version_output(output: &str) -> Result<Self, VersionParseError> {
output
.split_whitespace()
.find_map(|token| token.parse().ok())
.ok_or_else(|| VersionParseError(output.trim().to_string()))
}
#[must_use]
pub fn satisfies_minimum(&self, minimum: &CliVersion) -> bool {
self >= minimum
}
}
impl PartialOrd for CliVersion {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for CliVersion {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.major
.cmp(&other.major)
.then(self.minor.cmp(&other.minor))
.then(self.patch.cmp(&other.patch))
}
}
impl fmt::Display for CliVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
impl FromStr for CliVersion {
type Err = VersionParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != 3 {
return Err(VersionParseError(s.to_string()));
}
Ok(Self {
major: parts[0]
.parse()
.map_err(|_| VersionParseError(s.to_string()))?,
minor: parts[1]
.parse()
.map_err(|_| VersionParseError(s.to_string()))?,
patch: parts[2]
.parse()
.map_err(|_| VersionParseError(s.to_string()))?,
})
}
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("invalid version string: {0:?}")]
pub struct VersionParseError(pub String);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_codex_version_output() {
let version = CliVersion::parse_version_output("codex-cli 0.116.0").unwrap();
assert_eq!(version, CliVersion::new(0, 116, 0));
}
#[test]
fn parses_plain_version_output() {
let version = CliVersion::parse_version_output("0.116.0").unwrap();
assert_eq!(version, CliVersion::new(0, 116, 0));
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_session_and_thread_id() {
let event: JsonLineEvent = serde_json::from_str(
r#"{"type":"message.created","session_id":"sess_abc","thread_id":"thread_123"}"#,
)
.unwrap();
assert_eq!(event.session_id(), Some("sess_abc"));
assert_eq!(event.thread_id(), Some("thread_123"));
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_is_completed() {
let completed: JsonLineEvent = serde_json::from_str(r#"{"type":"completed"}"#).unwrap();
assert!(completed.is_completed());
let other: JsonLineEvent = serde_json::from_str(r#"{"type":"message.created"}"#).unwrap();
assert!(!other.is_completed());
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_result_text_and_cost() {
let event: JsonLineEvent = serde_json::from_str(
r#"{"type":"completed","result":{"text":"hello world","cost":0.0042}}"#,
)
.unwrap();
assert_eq!(event.result_text(), Some("hello world"));
assert!((event.cost_usd().unwrap() - 0.0042).abs() < f64::EPSILON);
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_result_text_missing() {
let event: JsonLineEvent = serde_json::from_str(r#"{"type":"completed"}"#).unwrap();
assert_eq!(event.result_text(), None);
assert_eq!(event.cost_usd(), None);
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_role() {
let event: JsonLineEvent =
serde_json::from_str(r#"{"type":"message.created","role":"assistant"}"#).unwrap();
assert_eq!(event.role(), Some("assistant"));
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_content_text() {
let event: JsonLineEvent = serde_json::from_str(
r#"{"type":"message.delta","content":[{"type":"text","text":"Hello "},{"type":"text","text":"world"}]}"#,
)
.unwrap();
assert_eq!(event.content_text(), Some("Hello world".to_string()));
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_content_text_skips_non_text_blocks() {
let event: JsonLineEvent = serde_json::from_str(
r#"{"type":"message.delta","content":[{"type":"image","url":"x"},{"type":"text","text":"only this"}]}"#,
)
.unwrap();
assert_eq!(event.content_text(), Some("only this".to_string()));
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_content_text_none_when_empty() {
let event: JsonLineEvent =
serde_json::from_str(r#"{"type":"message.delta","content":[]}"#).unwrap();
assert_eq!(event.content_text(), None);
}
#[cfg(feature = "json")]
#[test]
fn json_line_event_content_text_none_when_missing() {
let event: JsonLineEvent = serde_json::from_str(r#"{"type":"message.delta"}"#).unwrap();
assert_eq!(event.content_text(), None);
}
}