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/// Parsed semantic version of the Codex CLI (`major.minor.patch`).
158///
159/// Supports comparison and ordering for version-gating logic.
160#[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}