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#[cfg(feature = "json")]
165#[derive(Debug, Clone)]
166pub struct QueryResult {
167 pub result: String,
171 pub session_id: Option<String>,
173 pub thread_id: Option<String>,
177 pub cost_usd: Option<f64>,
179 pub events: Vec<JsonLineEvent>,
181}
182
183#[cfg(feature = "json")]
184impl QueryResult {
185 #[must_use]
190 pub fn from_events(events: Vec<JsonLineEvent>) -> Self {
191 let completed = events.iter().rev().find(|e| e.is_completed());
192 let result = completed
193 .and_then(JsonLineEvent::result_text)
194 .unwrap_or_default()
195 .to_string();
196 let cost_usd = completed.and_then(JsonLineEvent::cost_usd);
197 let session_id = events
198 .iter()
199 .find_map(JsonLineEvent::session_id)
200 .map(str::to_string);
201 let thread_id = events
202 .iter()
203 .find_map(JsonLineEvent::thread_id)
204 .map(str::to_string);
205 Self {
206 result,
207 session_id,
208 thread_id,
209 cost_usd,
210 events,
211 }
212 }
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub struct CliVersion {
220 pub major: u32,
221 pub minor: u32,
222 pub patch: u32,
223}
224
225impl CliVersion {
226 #[must_use]
227 pub fn new(major: u32, minor: u32, patch: u32) -> Self {
228 Self {
229 major,
230 minor,
231 patch,
232 }
233 }
234
235 pub fn parse_version_output(output: &str) -> Result<Self, VersionParseError> {
236 output
237 .split_whitespace()
238 .find_map(|token| token.parse().ok())
239 .ok_or_else(|| VersionParseError(output.trim().to_string()))
240 }
241
242 #[must_use]
243 pub fn satisfies_minimum(&self, minimum: &CliVersion) -> bool {
244 self >= minimum
245 }
246}
247
248impl PartialOrd for CliVersion {
249 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
250 Some(self.cmp(other))
251 }
252}
253
254impl Ord for CliVersion {
255 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
256 self.major
257 .cmp(&other.major)
258 .then(self.minor.cmp(&other.minor))
259 .then(self.patch.cmp(&other.patch))
260 }
261}
262
263impl fmt::Display for CliVersion {
264 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
266 }
267}
268
269impl FromStr for CliVersion {
270 type Err = VersionParseError;
271
272 fn from_str(s: &str) -> Result<Self, Self::Err> {
273 let parts: Vec<&str> = s.split('.').collect();
274 if parts.len() != 3 {
275 return Err(VersionParseError(s.to_string()));
276 }
277
278 Ok(Self {
279 major: parts[0]
280 .parse()
281 .map_err(|_| VersionParseError(s.to_string()))?,
282 minor: parts[1]
283 .parse()
284 .map_err(|_| VersionParseError(s.to_string()))?,
285 patch: parts[2]
286 .parse()
287 .map_err(|_| VersionParseError(s.to_string()))?,
288 })
289 }
290}
291
292#[derive(Debug, Clone, thiserror::Error)]
293#[error("invalid version string: {0:?}")]
294pub struct VersionParseError(pub String);
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn parses_codex_version_output() {
302 let version = CliVersion::parse_version_output("codex-cli 0.145.0").unwrap();
303 assert_eq!(version, CliVersion::new(0, 145, 0));
304 }
305
306 #[test]
307 fn parses_plain_version_output() {
308 let version = CliVersion::parse_version_output("0.145.0").unwrap();
309 assert_eq!(version, CliVersion::new(0, 145, 0));
310 }
311
312 #[cfg(feature = "json")]
313 #[test]
314 fn json_line_event_session_and_thread_id() {
315 let event: JsonLineEvent = serde_json::from_str(
316 r#"{"type":"message.created","session_id":"sess_abc","thread_id":"thread_123"}"#,
317 )
318 .unwrap();
319 assert_eq!(event.session_id(), Some("sess_abc"));
320 assert_eq!(event.thread_id(), Some("thread_123"));
321 }
322
323 #[cfg(feature = "json")]
324 #[test]
325 fn json_line_event_is_completed() {
326 let completed: JsonLineEvent = serde_json::from_str(r#"{"type":"completed"}"#).unwrap();
327 assert!(completed.is_completed());
328
329 let other: JsonLineEvent = serde_json::from_str(r#"{"type":"message.created"}"#).unwrap();
330 assert!(!other.is_completed());
331 }
332
333 #[cfg(feature = "json")]
334 #[test]
335 fn json_line_event_result_text_and_cost() {
336 let event: JsonLineEvent = serde_json::from_str(
337 r#"{"type":"completed","result":{"text":"hello world","cost":0.0042}}"#,
338 )
339 .unwrap();
340 assert_eq!(event.result_text(), Some("hello world"));
341 assert!((event.cost_usd().unwrap() - 0.0042).abs() < f64::EPSILON);
342 }
343
344 #[cfg(feature = "json")]
345 #[test]
346 fn json_line_event_result_text_missing() {
347 let event: JsonLineEvent = serde_json::from_str(r#"{"type":"completed"}"#).unwrap();
348 assert_eq!(event.result_text(), None);
349 assert_eq!(event.cost_usd(), None);
350 }
351
352 #[cfg(feature = "json")]
353 #[test]
354 fn json_line_event_role() {
355 let event: JsonLineEvent =
356 serde_json::from_str(r#"{"type":"message.created","role":"assistant"}"#).unwrap();
357 assert_eq!(event.role(), Some("assistant"));
358 }
359
360 #[cfg(feature = "json")]
361 #[test]
362 fn json_line_event_content_text() {
363 let event: JsonLineEvent = serde_json::from_str(
364 r#"{"type":"message.delta","content":[{"type":"text","text":"Hello "},{"type":"text","text":"world"}]}"#,
365 )
366 .unwrap();
367 assert_eq!(event.content_text(), Some("Hello world".to_string()));
368 }
369
370 #[cfg(feature = "json")]
371 #[test]
372 fn json_line_event_content_text_skips_non_text_blocks() {
373 let event: JsonLineEvent = serde_json::from_str(
374 r#"{"type":"message.delta","content":[{"type":"image","url":"x"},{"type":"text","text":"only this"}]}"#,
375 )
376 .unwrap();
377 assert_eq!(event.content_text(), Some("only this".to_string()));
378 }
379
380 #[cfg(feature = "json")]
381 #[test]
382 fn json_line_event_content_text_none_when_empty() {
383 let event: JsonLineEvent =
384 serde_json::from_str(r#"{"type":"message.delta","content":[]}"#).unwrap();
385 assert_eq!(event.content_text(), None);
386 }
387
388 #[cfg(feature = "json")]
389 #[test]
390 fn json_line_event_content_text_none_when_missing() {
391 let event: JsonLineEvent = serde_json::from_str(r#"{"type":"message.delta"}"#).unwrap();
392 assert_eq!(event.content_text(), None);
393 }
394
395 #[cfg(feature = "json")]
396 #[test]
397 fn query_result_from_events() {
398 let events: Vec<JsonLineEvent> = vec![
399 serde_json::from_str(
400 r#"{"type":"thread.started","session_id":"sess_1","thread_id":"thread_1"}"#,
401 )
402 .unwrap(),
403 serde_json::from_str(r#"{"type":"message.created","role":"assistant"}"#).unwrap(),
404 serde_json::from_str(r#"{"type":"completed","result":{"text":"done","cost":0.02}}"#)
405 .unwrap(),
406 ];
407 let result = QueryResult::from_events(events);
408 assert_eq!(result.result, "done");
409 assert_eq!(result.session_id.as_deref(), Some("sess_1"));
410 assert_eq!(result.thread_id.as_deref(), Some("thread_1"));
411 assert_eq!(result.cost_usd, Some(0.02));
412 assert_eq!(result.events.len(), 3);
413 }
414
415 #[cfg(feature = "json")]
416 #[test]
417 fn query_result_from_events_no_completed() {
418 let events: Vec<JsonLineEvent> =
419 vec![serde_json::from_str(r#"{"type":"message.created"}"#).unwrap()];
420 let result = QueryResult::from_events(events);
421 assert_eq!(result.result, "");
422 assert_eq!(result.cost_usd, None);
423 assert!(result.session_id.is_none());
424 assert!(result.thread_id.is_none());
425 }
426}