Skip to main content

codex_wrapper/
types.rs

1//! Domain types shared across commands: enums for CLI options, version parsing,
2//! and structured JSONL events.
3
4#[cfg(feature = "json")]
5use std::collections::HashMap;
6use std::fmt;
7use std::str::FromStr;
8
9use serde::{Deserialize, Serialize};
10
11/// Sandbox policy for model-generated shell commands.
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "kebab-case")]
14pub enum SandboxMode {
15    /// Read-only filesystem access.
16    ReadOnly,
17    /// Write access limited to the workspace directory (default).
18    #[default]
19    WorkspaceWrite,
20    /// Full filesystem access -- use with extreme caution.
21    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/// When the model should ask for human approval before executing commands.
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "kebab-case")]
37pub enum ApprovalPolicy {
38    /// Only run trusted commands without asking.
39    Untrusted,
40    /// Ask on failure (deprecated -- prefer `OnRequest` or `Never`).
41    OnFailure,
42    /// The model decides when to ask (default).
43    #[default]
44    OnRequest,
45    /// Never ask for approval.
46    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/// Color output mode for exec commands.
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum Color {
64    /// Always emit color codes.
65    Always,
66    /// Never emit color codes.
67    Never,
68    /// Auto-detect terminal support (default).
69    #[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/// A single parsed JSONL event from `--json` output.
84///
85/// The `event_type` field corresponds to the `"type"` key in the JSON.
86/// All other fields are captured in `extra`.
87#[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    /// Returns the `session_id` field, if present and a string.
99    #[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    /// Returns the `thread_id` field, if present and a string.
105    #[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    /// Returns `true` when the event type is `"completed"`.
111    #[must_use]
112    pub fn is_completed(&self) -> bool {
113        self.event_type == "completed"
114    }
115
116    /// Returns the nested `result.text` field, if present and a string.
117    #[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    /// Returns the nested `result.cost` field in USD, if present and numeric.
126    #[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    /// Returns the `role` field, if present and a string.
135    #[must_use]
136    pub fn role(&self) -> Option<&str> {
137        self.extra.get("role").and_then(|v| v.as_str())
138    }
139
140    /// Extracts concatenated text from a `content` blocks array.
141    ///
142    /// Each block with `"type": "text"` contributes its `"text"` value.
143    /// Returns `None` if there is no `content` array or no text blocks.
144    #[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/// A typed summary of a completed `codex exec` run, assembled from the JSONL
158/// event stream.
159///
160/// This mirrors the shape of `claude-wrapper`'s `QueryResult` so a downstream
161/// abstraction can treat both wrappers uniformly. The full parsed event stream
162/// is retained in [`events`](QueryResult::events) as an escape hatch for fields
163/// not surfaced here.
164#[cfg(feature = "json")]
165#[derive(Debug, Clone)]
166pub struct QueryResult {
167    /// Final assistant text from the terminal `completed` event.
168    ///
169    /// Empty if no `completed` event carried a `result.text` value.
170    pub result: String,
171    /// The `session_id` captured from the event stream, if any.
172    pub session_id: Option<String>,
173    /// The `thread_id` captured from the event stream, if any.
174    ///
175    /// This is Codex's native identifier for resuming a conversation.
176    pub thread_id: Option<String>,
177    /// Total cost in USD from the `completed` event, if reported.
178    pub cost_usd: Option<f64>,
179    /// The full parsed event stream this result was assembled from.
180    pub events: Vec<JsonLineEvent>,
181}
182
183#[cfg(feature = "json")]
184impl QueryResult {
185    /// Assemble a [`QueryResult`] from a parsed JSONL event stream.
186    ///
187    /// `result` and `cost_usd` are taken from the last `completed` event;
188    /// `session_id` and `thread_id` are the first occurrences in the stream.
189    #[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/// Parsed semantic version of the Codex CLI (`major.minor.patch`).
216///
217/// Supports comparison and ordering for version-gating logic.
218#[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}