1#[cfg(feature = "json")]
5use std::collections::HashMap;
6use std::fmt;
7use std::str::FromStr;
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "kebab-case")]
14pub enum SandboxMode {
15 ReadOnly,
17 #[default]
19 WorkspaceWrite,
20 DangerFullAccess,
22}
23
24impl SandboxMode {
25 pub(crate) fn as_arg(self) -> &'static str {
26 match self {
27 Self::ReadOnly => "read-only",
28 Self::WorkspaceWrite => "workspace-write",
29 Self::DangerFullAccess => "danger-full-access",
30 }
31 }
32}
33
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "kebab-case")]
37pub enum ApprovalPolicy {
38 Untrusted,
40 OnFailure,
42 #[default]
44 OnRequest,
45 Never,
47}
48
49impl ApprovalPolicy {
50 pub(crate) fn as_arg(self) -> &'static str {
51 match self {
52 Self::Untrusted => "untrusted",
53 Self::OnFailure => "on-failure",
54 Self::OnRequest => "on-request",
55 Self::Never => "never",
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum Color {
64 Always,
66 Never,
68 #[default]
70 Auto,
71}
72
73impl Color {
74 pub(crate) fn as_arg(self) -> &'static str {
75 match self {
76 Self::Always => "always",
77 Self::Never => "never",
78 Self::Auto => "auto",
79 }
80 }
81}
82
83#[cfg(feature = "json")]
88#[derive(Debug, Clone, Deserialize, Serialize)]
89pub struct JsonLineEvent {
90 #[serde(rename = "type", default)]
91 pub event_type: String,
92 #[serde(flatten)]
93 pub extra: HashMap<String, serde_json::Value>,
94}
95
96#[cfg(feature = "json")]
97impl JsonLineEvent {
98 #[must_use]
100 pub fn session_id(&self) -> Option<&str> {
101 self.extra.get("session_id").and_then(|v| v.as_str())
102 }
103
104 #[must_use]
106 pub fn thread_id(&self) -> Option<&str> {
107 self.extra.get("thread_id").and_then(|v| v.as_str())
108 }
109
110 #[must_use]
112 pub fn is_completed(&self) -> bool {
113 self.event_type == "completed"
114 }
115
116 #[must_use]
118 pub fn result_text(&self) -> Option<&str> {
119 self.extra
120 .get("result")
121 .and_then(|v| v.get("text"))
122 .and_then(|v| v.as_str())
123 }
124
125 #[must_use]
127 pub fn cost_usd(&self) -> Option<f64> {
128 self.extra
129 .get("result")
130 .and_then(|v| v.get("cost"))
131 .and_then(|v| v.as_f64())
132 }
133
134 #[must_use]
136 pub fn role(&self) -> Option<&str> {
137 self.extra.get("role").and_then(|v| v.as_str())
138 }
139
140 #[must_use]
145 pub fn content_text(&self) -> Option<String> {
146 let blocks = self.extra.get("content").and_then(|v| v.as_array())?;
147 let text: String = blocks
148 .iter()
149 .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
150 .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
151 .collect::<Vec<_>>()
152 .join("");
153 if text.is_empty() { None } else { Some(text) }
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub struct CliVersion {
162 pub major: u32,
163 pub minor: u32,
164 pub patch: u32,
165}
166
167impl CliVersion {
168 #[must_use]
169 pub fn new(major: u32, minor: u32, patch: u32) -> Self {
170 Self {
171 major,
172 minor,
173 patch,
174 }
175 }
176
177 pub fn parse_version_output(output: &str) -> Result<Self, VersionParseError> {
178 output
179 .split_whitespace()
180 .find_map(|token| token.parse().ok())
181 .ok_or_else(|| VersionParseError(output.trim().to_string()))
182 }
183
184 #[must_use]
185 pub fn satisfies_minimum(&self, minimum: &CliVersion) -> bool {
186 self >= minimum
187 }
188}
189
190impl PartialOrd for CliVersion {
191 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
192 Some(self.cmp(other))
193 }
194}
195
196impl Ord for CliVersion {
197 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
198 self.major
199 .cmp(&other.major)
200 .then(self.minor.cmp(&other.minor))
201 .then(self.patch.cmp(&other.patch))
202 }
203}
204
205impl fmt::Display for CliVersion {
206 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
208 }
209}
210
211impl FromStr for CliVersion {
212 type Err = VersionParseError;
213
214 fn from_str(s: &str) -> Result<Self, Self::Err> {
215 let parts: Vec<&str> = s.split('.').collect();
216 if parts.len() != 3 {
217 return Err(VersionParseError(s.to_string()));
218 }
219
220 Ok(Self {
221 major: parts[0]
222 .parse()
223 .map_err(|_| VersionParseError(s.to_string()))?,
224 minor: parts[1]
225 .parse()
226 .map_err(|_| VersionParseError(s.to_string()))?,
227 patch: parts[2]
228 .parse()
229 .map_err(|_| VersionParseError(s.to_string()))?,
230 })
231 }
232}
233
234#[derive(Debug, Clone, thiserror::Error)]
235#[error("invalid version string: {0:?}")]
236pub struct VersionParseError(pub String);
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn parses_codex_version_output() {
244 let version = CliVersion::parse_version_output("codex-cli 0.116.0").unwrap();
245 assert_eq!(version, CliVersion::new(0, 116, 0));
246 }
247
248 #[test]
249 fn parses_plain_version_output() {
250 let version = CliVersion::parse_version_output("0.116.0").unwrap();
251 assert_eq!(version, CliVersion::new(0, 116, 0));
252 }
253
254 #[cfg(feature = "json")]
255 #[test]
256 fn json_line_event_session_and_thread_id() {
257 let event: JsonLineEvent = serde_json::from_str(
258 r#"{"type":"message.created","session_id":"sess_abc","thread_id":"thread_123"}"#,
259 )
260 .unwrap();
261 assert_eq!(event.session_id(), Some("sess_abc"));
262 assert_eq!(event.thread_id(), Some("thread_123"));
263 }
264
265 #[cfg(feature = "json")]
266 #[test]
267 fn json_line_event_is_completed() {
268 let completed: JsonLineEvent = serde_json::from_str(r#"{"type":"completed"}"#).unwrap();
269 assert!(completed.is_completed());
270
271 let other: JsonLineEvent = serde_json::from_str(r#"{"type":"message.created"}"#).unwrap();
272 assert!(!other.is_completed());
273 }
274
275 #[cfg(feature = "json")]
276 #[test]
277 fn json_line_event_result_text_and_cost() {
278 let event: JsonLineEvent = serde_json::from_str(
279 r#"{"type":"completed","result":{"text":"hello world","cost":0.0042}}"#,
280 )
281 .unwrap();
282 assert_eq!(event.result_text(), Some("hello world"));
283 assert!((event.cost_usd().unwrap() - 0.0042).abs() < f64::EPSILON);
284 }
285
286 #[cfg(feature = "json")]
287 #[test]
288 fn json_line_event_result_text_missing() {
289 let event: JsonLineEvent = serde_json::from_str(r#"{"type":"completed"}"#).unwrap();
290 assert_eq!(event.result_text(), None);
291 assert_eq!(event.cost_usd(), None);
292 }
293
294 #[cfg(feature = "json")]
295 #[test]
296 fn json_line_event_role() {
297 let event: JsonLineEvent =
298 serde_json::from_str(r#"{"type":"message.created","role":"assistant"}"#).unwrap();
299 assert_eq!(event.role(), Some("assistant"));
300 }
301
302 #[cfg(feature = "json")]
303 #[test]
304 fn json_line_event_content_text() {
305 let event: JsonLineEvent = serde_json::from_str(
306 r#"{"type":"message.delta","content":[{"type":"text","text":"Hello "},{"type":"text","text":"world"}]}"#,
307 )
308 .unwrap();
309 assert_eq!(event.content_text(), Some("Hello world".to_string()));
310 }
311
312 #[cfg(feature = "json")]
313 #[test]
314 fn json_line_event_content_text_skips_non_text_blocks() {
315 let event: JsonLineEvent = serde_json::from_str(
316 r#"{"type":"message.delta","content":[{"type":"image","url":"x"},{"type":"text","text":"only this"}]}"#,
317 )
318 .unwrap();
319 assert_eq!(event.content_text(), Some("only this".to_string()));
320 }
321
322 #[cfg(feature = "json")]
323 #[test]
324 fn json_line_event_content_text_none_when_empty() {
325 let event: JsonLineEvent =
326 serde_json::from_str(r#"{"type":"message.delta","content":[]}"#).unwrap();
327 assert_eq!(event.content_text(), None);
328 }
329
330 #[cfg(feature = "json")]
331 #[test]
332 fn json_line_event_content_text_none_when_missing() {
333 let event: JsonLineEvent = serde_json::from_str(r#"{"type":"message.delta"}"#).unwrap();
334 assert_eq!(event.content_text(), None);
335 }
336}