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//! # JSONL schema: what is verified and what is assumed
5//!
6//! This parser was rewritten in #73 after the previous one was found to match
7//! an event shape the CLI never emits. It matched a fixture that invented the
8//! schema, so every test passed while real output produced empty results. The
9//! split below exists so the next person can check the assumptions instead of
10//! rediscovering the bug.
11//!
12//! **Verified** against `codex-cli` 0.145.0, from the compiled serde tag list
13//! and live runs of both `codex exec --json` and `codex exec review --json`:
14//!
15//! - The event vocabulary is `thread.started`, `turn.started`, `turn.completed`,
16//!   `turn.failed`, `item.started`, `item.updated`, `item.completed`. There is
17//!   no bare `completed`. Review emits the same vocabulary as exec.
18//! - `thread.started` carries `thread_id`.
19//! - A completed turn reports token counts, never a monetary cost. The `usage`
20//!   object carries `input_tokens`, `cached_input_tokens`,
21//!   `cache_write_input_tokens`, `output_tokens`, and
22//!   `reasoning_output_tokens`. It does **not** carry `total_tokens`, so
23//!   [`TokenUsage::total`] reaches a total through its input plus output
24//!   fallback on every real run.
25//! - Assistant text arrives as an `item.completed` event whose `item` has a
26//!   `type` of `agent_message` and its text in a `text` field. A review's
27//!   diff-reading steps arrive as `command_execution` items on the same event.
28//! - A review's `turn.completed` reports a usage object of all zeros.
29//!
30//! - The stream carries **no incremental text**. Three captured runs, a
31//!   one-word exec, a four-sentence exec, and a review, each delivered the
32//!   whole assistant message in a single `item.completed`. No `item.updated`,
33//!   no partial or delta fields, and `codex exec --help` has no flag that
34//!   changes output granularity. There is nothing to assemble, which is why
35//!   this module has no equivalent of `claude-wrapper`'s `PartialMessageEvent`
36//!   (#84).
37//!
38//! **Assumed**, still: nothing load-bearing.
39//! [`JsonLineEvent::agent_message_text`] also accepts an `item_type`
40//! discriminator and a `content` block array, neither of which has been seen
41//! in real output. That tolerance stays because the failure mode when this
42//! parser guesses wrong is an empty result rather than an error, which is how
43//! #73 went unnoticed.
44//!
45//! To re-confirm after a CLI upgrade, capture a run and check `result` is
46//! non-empty:
47//!
48//! ```sh
49//! codex exec --json --ephemeral --skip-git-repo-check "reply with: ok" > turn.jsonl
50//! ```
51
52#[cfg(feature = "json")]
53use std::collections::HashMap;
54use std::fmt;
55use std::str::FromStr;
56
57use serde::{Deserialize, Serialize};
58
59/// Sandbox policy for model-generated shell commands.
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "kebab-case")]
62pub enum SandboxMode {
63    /// Read-only filesystem access.
64    ReadOnly,
65    /// Write access limited to the workspace directory (default).
66    #[default]
67    WorkspaceWrite,
68    /// Full filesystem access -- use with extreme caution.
69    DangerFullAccess,
70}
71
72impl SandboxMode {
73    pub(crate) fn as_arg(self) -> &'static str {
74        match self {
75            Self::ReadOnly => "read-only",
76            Self::WorkspaceWrite => "workspace-write",
77            Self::DangerFullAccess => "danger-full-access",
78        }
79    }
80}
81
82/// When the model should ask for human approval before executing commands.
83///
84/// These are the values accepted by the `--ask-for-approval` flag on
85/// [`ForkCommand`](crate::ForkCommand) and [`ResumeCommand`](crate::ResumeCommand).
86/// The exec family sets the same setting through the `approval_policy` config
87/// key, which accepts a larger value set -- see [`ApprovalPolicyConfig`].
88#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "kebab-case")]
90pub enum ApprovalPolicy {
91    /// Only run trusted commands without asking.
92    Untrusted,
93    /// The model decides when to ask (default).
94    #[default]
95    OnRequest,
96    /// Never ask for approval.
97    Never,
98}
99
100impl ApprovalPolicy {
101    pub(crate) fn as_arg(self) -> &'static str {
102        match self {
103            Self::Untrusted => "untrusted",
104            Self::OnRequest => "on-request",
105            Self::Never => "never",
106        }
107    }
108}
109
110/// Approval policy values accepted by the `approval_policy` config key.
111///
112/// `codex-cli` 0.145.0 removed `--ask-for-approval` from the exec family; the
113/// config key is the supported equivalent. It accepts two values the flag does
114/// not ([`OnFailure`](Self::OnFailure) and [`Granular`](Self::Granular)), which
115/// is why this is a separate type from [`ApprovalPolicy`] rather than an alias.
116/// The three shared values convert implicitly:
117///
118/// ```
119/// use codex_wrapper::{ApprovalPolicy, ApprovalPolicyConfig};
120///
121/// let config: ApprovalPolicyConfig = ApprovalPolicy::Never.into();
122/// assert_eq!(config, ApprovalPolicyConfig::Never);
123/// ```
124#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "kebab-case")]
126pub enum ApprovalPolicyConfig {
127    /// Only run trusted commands without asking.
128    Untrusted,
129    /// Ask after a command fails.
130    ///
131    /// Not accepted by `--ask-for-approval`, so it has no [`ApprovalPolicy`]
132    /// counterpart.
133    OnFailure,
134    /// The model decides when to ask (default).
135    #[default]
136    OnRequest,
137    /// Ask per-operation rather than per-command.
138    ///
139    /// Not accepted by `--ask-for-approval`, so it has no [`ApprovalPolicy`]
140    /// counterpart.
141    Granular,
142    /// Never ask for approval.
143    Never,
144}
145
146impl ApprovalPolicyConfig {
147    pub(crate) fn as_config_value(self) -> &'static str {
148        match self {
149            Self::Untrusted => "untrusted",
150            Self::OnFailure => "on-failure",
151            Self::OnRequest => "on-request",
152            Self::Granular => "granular",
153            Self::Never => "never",
154        }
155    }
156}
157
158impl From<ApprovalPolicy> for ApprovalPolicyConfig {
159    fn from(policy: ApprovalPolicy) -> Self {
160        match policy {
161            ApprovalPolicy::Untrusted => Self::Untrusted,
162            ApprovalPolicy::OnRequest => Self::OnRequest,
163            ApprovalPolicy::Never => Self::Never,
164        }
165    }
166}
167
168impl TryFrom<ApprovalPolicyConfig> for ApprovalPolicy {
169    type Error = ApprovalPolicyConfig;
170
171    /// Narrow to the flag-accepted subset.
172    ///
173    /// Returns the original value as the error for
174    /// [`OnFailure`](ApprovalPolicyConfig::OnFailure) and
175    /// [`Granular`](ApprovalPolicyConfig::Granular), which `--ask-for-approval`
176    /// rejects.
177    fn try_from(config: ApprovalPolicyConfig) -> std::result::Result<Self, Self::Error> {
178        match config {
179            ApprovalPolicyConfig::Untrusted => Ok(Self::Untrusted),
180            ApprovalPolicyConfig::OnRequest => Ok(Self::OnRequest),
181            ApprovalPolicyConfig::Never => Ok(Self::Never),
182            other => Err(other),
183        }
184    }
185}
186
187/// Web search mode, set through the `web_search` config key.
188///
189/// `codex-cli` 0.145.0 removed `--search` from the exec family. The config key
190/// replacing it is an enum rather than the flag's boolean;
191/// [`Live`](Self::Live) is what `--search` meant.
192///
193/// `--search` is still a valid flag on [`ForkCommand`](crate::ForkCommand) and
194/// [`ResumeCommand`](crate::ResumeCommand), which keep their boolean setters.
195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "kebab-case")]
197pub enum WebSearchMode {
198    /// No web search.
199    #[default]
200    Disabled,
201    /// Serve results from cache only.
202    Cached,
203    /// Search a prebuilt index.
204    Indexed,
205    /// Live web search. The `--search` flag's former behavior.
206    Live,
207}
208
209impl WebSearchMode {
210    pub(crate) fn as_config_value(self) -> &'static str {
211        match self {
212            Self::Disabled => "disabled",
213            Self::Cached => "cached",
214            Self::Indexed => "indexed",
215            Self::Live => "live",
216        }
217    }
218}
219
220/// Color output mode for exec commands.
221#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(rename_all = "lowercase")]
223pub enum Color {
224    /// Always emit color codes.
225    Always,
226    /// Never emit color codes.
227    Never,
228    /// Auto-detect terminal support (default).
229    #[default]
230    Auto,
231}
232
233impl Color {
234    pub(crate) fn as_arg(self) -> &'static str {
235        match self {
236            Self::Always => "always",
237            Self::Never => "never",
238            Self::Auto => "auto",
239        }
240    }
241}
242
243/// A single parsed JSONL event from `--json` output.
244///
245/// The `event_type` field corresponds to the `"type"` key in the JSON.
246/// All other fields are captured in `extra`.
247#[cfg(feature = "json")]
248#[derive(Debug, Clone, Deserialize, Serialize)]
249pub struct JsonLineEvent {
250    #[serde(rename = "type", default)]
251    pub event_type: String,
252    #[serde(flatten)]
253    pub extra: HashMap<String, serde_json::Value>,
254}
255
256#[cfg(feature = "json")]
257impl JsonLineEvent {
258    /// Returns the `session_id` field, if present and a string.
259    #[must_use]
260    pub fn session_id(&self) -> Option<&str> {
261        self.extra.get("session_id").and_then(|v| v.as_str())
262    }
263
264    /// Returns the `thread_id` field, if present and a string.
265    #[must_use]
266    pub fn thread_id(&self) -> Option<&str> {
267        self.extra.get("thread_id").and_then(|v| v.as_str())
268    }
269
270    /// Returns `true` when this is the terminal event of a successful turn.
271    #[must_use]
272    pub fn is_turn_completed(&self) -> bool {
273        self.event_type == "turn.completed"
274    }
275
276    /// Returns `true` when this is the terminal event of a failed turn.
277    #[must_use]
278    pub fn is_turn_failed(&self) -> bool {
279        self.event_type == "turn.failed"
280    }
281
282    /// Token counts reported by a `turn.completed` event.
283    ///
284    /// Returns `None` on any other event, or when no `usage` object is
285    /// present. The CLI reports tokens, not money; see [`TokenUsage`].
286    #[must_use]
287    pub fn usage(&self) -> Option<TokenUsage> {
288        self.extra.get("usage").map(TokenUsage::from_json)
289    }
290
291    /// Assistant text carried by an `item.completed` agent-message item.
292    ///
293    /// Returns `None` for any other event or item type.
294    ///
295    /// Real output uses a `type` discriminator and a `text` field; see the
296    /// schema block at the top of this module. An `item_type` discriminator
297    /// and a `content` block array are also accepted, neither of them
298    /// observed. Tolerating the unobserved shapes is deliberate: the previous
299    /// parser committed to one exact layout and silently yielded empty results
300    /// when it was wrong, which is the bug this replaces.
301    #[must_use]
302    pub fn agent_message_text(&self) -> Option<String> {
303        if self.event_type != "item.completed" {
304            return None;
305        }
306        let item = self.extra.get("item")?;
307        let kind = item
308            .get("item_type")
309            .or_else(|| item.get("type"))
310            .and_then(|v| v.as_str())?;
311        if kind != "agent_message" {
312            return None;
313        }
314
315        if let Some(text) = item.get("text").and_then(|v| v.as_str())
316            && !text.is_empty()
317        {
318            return Some(text.to_string());
319        }
320
321        let blocks = item.get("content").and_then(|v| v.as_array())?;
322        let text: String = blocks
323            .iter()
324            .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
325            .collect::<Vec<_>>()
326            .join("");
327        if text.is_empty() { None } else { Some(text) }
328    }
329
330    /// Returns the `role` field, if present and a string.
331    #[must_use]
332    pub fn role(&self) -> Option<&str> {
333        self.extra.get("role").and_then(|v| v.as_str())
334    }
335
336    /// Extracts concatenated text from a `content` blocks array.
337    ///
338    /// Each block with `"type": "text"` contributes its `"text"` value.
339    /// Returns `None` if there is no `content` array or no text blocks.
340    #[must_use]
341    pub fn content_text(&self) -> Option<String> {
342        let blocks = self.extra.get("content").and_then(|v| v.as_array())?;
343        let text: String = blocks
344            .iter()
345            .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
346            .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
347            .collect::<Vec<_>>()
348            .join("");
349        if text.is_empty() { None } else { Some(text) }
350    }
351
352    /// The `item` discriminator on an `item.*` event.
353    ///
354    /// `agent_message` and `command_execution` are the observed values. This
355    /// is the field to match on when narrowing an item event, and it is
356    /// present on `item.started` as well as `item.completed`.
357    ///
358    /// Accepts `item_type` as well as `type` for the same reason
359    /// [`agent_message_text`](Self::agent_message_text) does.
360    #[must_use]
361    pub fn item_type(&self) -> Option<&str> {
362        let item = self.extra.get("item")?;
363        item.get("type").or_else(|| item.get("item_type"))?.as_str()
364    }
365
366    /// A shell command the model ran, from a `command_execution` item.
367    ///
368    /// `None` for any other item. `exit_code` and `status` are only populated
369    /// on `item.completed`; an `item.started` for the same command carries the
370    /// command alone.
371    #[must_use]
372    pub fn command_execution(&self) -> Option<CommandExecution> {
373        if self.item_type()? != "command_execution" {
374            return None;
375        }
376        let item = self.extra.get("item")?;
377        let string = |key: &str| item.get(key).and_then(|v| v.as_str()).map(str::to_string);
378        Some(CommandExecution {
379            command: string("command"),
380            status: string("status"),
381            exit_code: item
382                .get("exit_code")
383                .and_then(serde_json::Value::as_i64)
384                .and_then(|code| i32::try_from(code).ok()),
385            aggregated_output: string("aggregated_output"),
386        })
387    }
388}
389
390/// A typed summary of a completed `codex exec` run, assembled from the JSONL
391/// event stream.
392///
393/// This mirrors the shape of `claude-wrapper`'s `QueryResult` so a downstream
394/// abstraction can treat both wrappers uniformly. The full parsed event stream
395/// is retained in [`events`](QueryResult::events) as an escape hatch for fields
396/// not surfaced here.
397#[cfg(feature = "json")]
398#[derive(Debug, Clone)]
399pub struct QueryResult {
400    /// Assistant text, concatenated from the turn's agent-message items.
401    ///
402    /// Empty when the turn produced no agent message, which includes every
403    /// failed turn.
404    pub result: String,
405    /// The `session_id` captured from the event stream, if any.
406    ///
407    /// Observed live runs carry only `thread_id`, so this is usually `None`.
408    /// Retained because it costs nothing and the field may exist under auth
409    /// modes not yet observed.
410    pub session_id: Option<String>,
411    /// The `thread_id` captured from the event stream, if any.
412    ///
413    /// Codex's native identifier for resuming a conversation, emitted on
414    /// `thread.started`.
415    pub thread_id: Option<String>,
416    /// Token counts from the terminal `turn.completed` event, if present.
417    ///
418    /// The CLI reports tokens, not money. There is no cost field to read; see
419    /// [`TokenUsage`].
420    pub usage: Option<TokenUsage>,
421    /// The full parsed event stream this result was assembled from.
422    ///
423    /// The escape hatch for anything not surfaced above, including whatever
424    /// this parser gets wrong.
425    pub events: Vec<JsonLineEvent>,
426}
427
428#[cfg(feature = "json")]
429impl QueryResult {
430    /// Assemble a [`QueryResult`] from a parsed JSONL event stream.
431    ///
432    /// `usage` comes from the last `turn.completed` event; `result` is every
433    /// agent-message item concatenated in order; `session_id` and `thread_id`
434    /// are the first occurrences in the stream.
435    #[must_use]
436    pub fn from_events(events: Vec<JsonLineEvent>) -> Self {
437        let usage = events
438            .iter()
439            .rev()
440            .find(|e| e.is_turn_completed())
441            .and_then(JsonLineEvent::usage);
442        let result = events
443            .iter()
444            .filter_map(JsonLineEvent::agent_message_text)
445            .collect::<Vec<_>>()
446            .join("");
447        let session_id = events
448            .iter()
449            .find_map(JsonLineEvent::session_id)
450            .map(str::to_string);
451        let thread_id = events
452            .iter()
453            .find_map(JsonLineEvent::thread_id)
454            .map(str::to_string);
455        Self {
456            result,
457            session_id,
458            thread_id,
459            usage,
460            events,
461        }
462    }
463}
464
465/// A shell command the model ran, from a `command_execution` item.
466///
467/// Every field is optional: an `item.started` carries the command with no
468/// outcome yet, and the CLI has added fields to this item before.
469#[cfg(feature = "json")]
470#[derive(Debug, Clone, Default, PartialEq, Eq)]
471#[non_exhaustive]
472pub struct CommandExecution {
473    /// The command line, as the CLI recorded it.
474    pub command: Option<String>,
475    /// Reported status, `completed` being the observed value.
476    pub status: Option<String>,
477    /// Exit code, once the command has finished.
478    pub exit_code: Option<i32>,
479    /// Combined stdout and stderr, when the CLI included it.
480    pub aggregated_output: Option<String>,
481}
482
483/// Token counts reported on a completed turn.
484///
485/// `codex-cli` 0.145.0 reports token usage and no monetary cost. Converting
486/// tokens to dollars needs a per-model price table the CLI does not provide,
487/// so this crate does not attempt it: a hardcoded table would go stale
488/// silently, which is the same class of bug as #73.
489///
490/// Every field is optional, and absence is normal rather than exceptional:
491/// observed runs carry five of the six and never `total_tokens`, which is why
492/// [`TokenUsage::total`] falls back to input plus output.
493#[cfg(feature = "json")]
494#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
495pub struct TokenUsage {
496    /// Input tokens for the turn.
497    pub input_tokens: Option<u64>,
498    /// Input tokens served from cache.
499    pub cached_input_tokens: Option<u64>,
500    /// Input tokens written to cache.
501    pub cache_write_input_tokens: Option<u64>,
502    /// Output tokens for the turn.
503    pub output_tokens: Option<u64>,
504    /// Output tokens spent on reasoning.
505    pub reasoning_output_tokens: Option<u64>,
506    /// Total tokens, if the CLI ever reports one. Observed runs do not.
507    pub total_tokens: Option<u64>,
508}
509
510#[cfg(feature = "json")]
511impl TokenUsage {
512    fn from_json(value: &serde_json::Value) -> Self {
513        let field = |name: &str| value.get(name).and_then(serde_json::Value::as_u64);
514        Self {
515            input_tokens: field("input_tokens"),
516            cached_input_tokens: field("cached_input_tokens"),
517            cache_write_input_tokens: field("cache_write_input_tokens"),
518            output_tokens: field("output_tokens"),
519            reasoning_output_tokens: field("reasoning_output_tokens"),
520            total_tokens: field("total_tokens"),
521        }
522    }
523
524    /// Best available total for the turn.
525    ///
526    /// Prefers the CLI's own `total_tokens`, falls back to input plus output
527    /// when it is absent, and returns `None` when neither is reported, so a
528    /// missing total is never silently counted as zero.
529    #[must_use]
530    pub fn total(&self) -> Option<u64> {
531        if let Some(total) = self.total_tokens {
532            return Some(total);
533        }
534        match (self.input_tokens, self.output_tokens) {
535            (None, None) => None,
536            (input, output) => Some(input.unwrap_or(0) + output.unwrap_or(0)),
537        }
538    }
539}
540
541/// Parsed semantic version of the Codex CLI (`major.minor.patch`).
542///
543/// Supports comparison and ordering for version-gating logic.
544#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
545pub struct CliVersion {
546    pub major: u32,
547    pub minor: u32,
548    pub patch: u32,
549}
550
551impl CliVersion {
552    #[must_use]
553    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
554        Self {
555            major,
556            minor,
557            patch,
558        }
559    }
560
561    pub fn parse_version_output(output: &str) -> Result<Self, VersionParseError> {
562        output
563            .split_whitespace()
564            .find_map(|token| token.parse().ok())
565            .ok_or_else(|| VersionParseError(output.trim().to_string()))
566    }
567
568    #[must_use]
569    pub fn satisfies_minimum(&self, minimum: &CliVersion) -> bool {
570        self >= minimum
571    }
572
573    /// Classify this version against a tested-against range.
574    ///
575    /// ```
576    /// use codex_wrapper::{CliVersion, CliVersionStatus};
577    ///
578    /// let min = CliVersion::new(0, 145, 0);
579    /// let max = CliVersion::new(0, 146, 0);
580    ///
581    /// assert!(CliVersion::new(0, 145, 3).status_within(&min, &max).is_tested());
582    /// assert!(!CliVersion::new(0, 200, 0).status_within(&min, &max).is_tested());
583    /// ```
584    #[must_use]
585    pub fn status_within(&self, min: &CliVersion, max: &CliVersion) -> CliVersionStatus {
586        if self < min {
587            CliVersionStatus::OlderThanMinimum {
588                found: *self,
589                minimum: *min,
590            }
591        } else if self > max {
592            CliVersionStatus::NewerUntested {
593                found: *self,
594                tested_max: *max,
595            }
596        } else {
597            CliVersionStatus::Tested
598        }
599    }
600}
601
602/// Classification of an installed CLI version against a tested range.
603///
604/// Returned by [`CliVersion::status_within`] and
605/// [`Codex::cli_version_status`](crate::Codex::cli_version_status). Mirrors
606/// `claude-wrapper`'s enum of the same name so a downstream abstraction can
607/// treat both wrappers uniformly.
608///
609/// There is deliberately no `Unparseable` variant: unparseable output is an
610/// error from [`Codex::cli_version`](crate::Codex::cli_version), not a status,
611/// and modeling it twice would fork this shape away from the sibling crate.
612#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
613#[serde(tag = "status", rename_all = "snake_case")]
614pub enum CliVersionStatus {
615    /// Within the tested-against range.
616    Tested,
617    /// Newer than the highest tested version.
618    ///
619    /// The wrapper should still generally work; semantics may have drifted.
620    NewerUntested {
621        /// The installed CLI version.
622        found: CliVersion,
623        /// Highest version this wrapper is tested against.
624        tested_max: CliVersion,
625    },
626    /// Older than the lowest tested version.
627    ///
628    /// Incorrect behavior is likely rather than merely possible: the arguments
629    /// this wrapper emits target the newer CLI, and older releases reject some
630    /// of them outright.
631    OlderThanMinimum {
632        /// The installed CLI version.
633        found: CliVersion,
634        /// Lowest version this wrapper is tested against.
635        minimum: CliVersion,
636    },
637}
638
639impl CliVersionStatus {
640    /// True only for [`Tested`](Self::Tested).
641    ///
642    /// For callers branching on "should I run?" without matching every
643    /// variant.
644    #[must_use]
645    pub fn is_tested(self) -> bool {
646        matches!(self, Self::Tested)
647    }
648}
649
650impl PartialOrd for CliVersion {
651    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
652        Some(self.cmp(other))
653    }
654}
655
656impl Ord for CliVersion {
657    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
658        self.major
659            .cmp(&other.major)
660            .then(self.minor.cmp(&other.minor))
661            .then(self.patch.cmp(&other.patch))
662    }
663}
664
665impl fmt::Display for CliVersion {
666    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
667        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
668    }
669}
670
671impl FromStr for CliVersion {
672    type Err = VersionParseError;
673
674    fn from_str(s: &str) -> Result<Self, Self::Err> {
675        let parts: Vec<&str> = s.split('.').collect();
676        if parts.len() != 3 {
677            return Err(VersionParseError(s.to_string()));
678        }
679
680        Ok(Self {
681            major: parts[0]
682                .parse()
683                .map_err(|_| VersionParseError(s.to_string()))?,
684            minor: parts[1]
685                .parse()
686                .map_err(|_| VersionParseError(s.to_string()))?,
687            patch: parts[2]
688                .parse()
689                .map_err(|_| VersionParseError(s.to_string()))?,
690        })
691    }
692}
693
694#[derive(Debug, Clone, thiserror::Error)]
695#[error("invalid version string: {0:?}")]
696pub struct VersionParseError(pub String);
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    #[test]
703    fn parses_codex_version_output() {
704        let version = CliVersion::parse_version_output("codex-cli 0.145.0").unwrap();
705        assert_eq!(version, CliVersion::new(0, 145, 0));
706    }
707
708    #[test]
709    fn parses_plain_version_output() {
710        let version = CliVersion::parse_version_output("0.145.0").unwrap();
711        assert_eq!(version, CliVersion::new(0, 145, 0));
712    }
713
714    #[cfg(feature = "json")]
715    #[test]
716    fn json_line_event_session_and_thread_id() {
717        let event: JsonLineEvent = serde_json::from_str(
718            r#"{"type":"message.created","session_id":"sess_abc","thread_id":"thread_123"}"#,
719        )
720        .unwrap();
721        assert_eq!(event.session_id(), Some("sess_abc"));
722        assert_eq!(event.thread_id(), Some("thread_123"));
723    }
724
725    #[cfg(feature = "json")]
726    #[test]
727    fn json_line_event_turn_terminal_types() {
728        let completed: JsonLineEvent =
729            serde_json::from_str(r#"{"type":"turn.completed"}"#).unwrap();
730        assert!(completed.is_turn_completed());
731        assert!(!completed.is_turn_failed());
732
733        let failed: JsonLineEvent = serde_json::from_str(r#"{"type":"turn.failed"}"#).unwrap();
734        assert!(failed.is_turn_failed());
735        assert!(!failed.is_turn_completed());
736
737        // The pre-#73 parser matched this. The CLI has never emitted it.
738        let bogus: JsonLineEvent = serde_json::from_str(r#"{"type":"completed"}"#).unwrap();
739        assert!(!bogus.is_turn_completed());
740    }
741
742    #[cfg(feature = "json")]
743    #[test]
744    fn json_line_event_usage() {
745        let event: JsonLineEvent = serde_json::from_str(
746            r#"{"type":"turn.completed","usage":{"input_tokens":120,"output_tokens":45,"total_tokens":165}}"#,
747        )
748        .unwrap();
749        let usage = event.usage().unwrap();
750        assert_eq!(usage.input_tokens, Some(120));
751        assert_eq!(usage.output_tokens, Some(45));
752        assert_eq!(usage.total_tokens, Some(165));
753        // Absent from the observed payload, so absent here rather than zero.
754        assert_eq!(usage.cache_write_input_tokens, None);
755        assert_eq!(usage.total(), Some(165));
756    }
757
758    #[cfg(feature = "json")]
759    #[test]
760    fn token_usage_total_falls_back_to_input_plus_output() {
761        let usage = TokenUsage {
762            input_tokens: Some(10),
763            output_tokens: Some(5),
764            ..TokenUsage::default()
765        };
766        assert_eq!(usage.total(), Some(15));
767    }
768
769    /// A missing total must not read as zero, which would silently understate
770    /// a session's usage.
771    #[cfg(feature = "json")]
772    #[test]
773    fn token_usage_total_is_none_when_nothing_reported() {
774        assert_eq!(TokenUsage::default().total(), None);
775    }
776
777    /// The shape a real run emits: `type` on the item, text in `text`.
778    #[cfg(feature = "json")]
779    #[test]
780    fn agent_message_text_from_item_completed() {
781        let event: JsonLineEvent = serde_json::from_str(
782            r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"hello"}}"#,
783        )
784        .unwrap();
785        assert_eq!(event.agent_message_text().as_deref(), Some("hello"));
786    }
787
788    /// `item_type` and content blocks are not shapes the CLI has been seen to
789    /// emit. The accessor still tolerates them, because the cost of being
790    /// wrong here is a silently empty result rather than a loud failure, which
791    /// is how #73 stayed hidden. These cases keep that tolerance covered.
792    #[cfg(feature = "json")]
793    #[test]
794    fn agent_message_text_tolerates_layout_variants() {
795        let item_type_key: JsonLineEvent = serde_json::from_str(
796            r#"{"type":"item.completed","item":{"item_type":"agent_message","text":"a"}}"#,
797        )
798        .unwrap();
799        assert_eq!(item_type_key.agent_message_text().as_deref(), Some("a"));
800
801        let content_blocks: JsonLineEvent = serde_json::from_str(
802            r#"{"type":"item.completed","item":{"item_type":"agent_message","content":[{"text":"b"},{"text":"c"}]}}"#,
803        )
804        .unwrap();
805        assert_eq!(content_blocks.agent_message_text().as_deref(), Some("bc"));
806    }
807
808    #[cfg(feature = "json")]
809    #[test]
810    fn agent_message_text_ignores_other_items_and_events() {
811        // The item type a review's diff-reading steps arrive as.
812        let other_item: JsonLineEvent = serde_json::from_str(
813            r#"{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"git diff","exit_code":0}}"#,
814        )
815        .unwrap();
816        assert_eq!(other_item.agent_message_text(), None);
817
818        let other_event: JsonLineEvent = serde_json::from_str(
819            r#"{"type":"item.started","item":{"id":"item_0","type":"agent_message","text":"x"}}"#,
820        )
821        .unwrap();
822        assert_eq!(other_event.agent_message_text(), None);
823    }
824
825    #[cfg(feature = "json")]
826    #[test]
827    fn json_line_event_role() {
828        let event: JsonLineEvent =
829            serde_json::from_str(r#"{"type":"message.created","role":"assistant"}"#).unwrap();
830        assert_eq!(event.role(), Some("assistant"));
831    }
832
833    #[cfg(feature = "json")]
834    #[test]
835    fn json_line_event_content_text() {
836        let event: JsonLineEvent = serde_json::from_str(
837            r#"{"type":"message.delta","content":[{"type":"text","text":"Hello "},{"type":"text","text":"world"}]}"#,
838        )
839        .unwrap();
840        assert_eq!(event.content_text(), Some("Hello world".to_string()));
841    }
842
843    #[cfg(feature = "json")]
844    #[test]
845    fn json_line_event_content_text_skips_non_text_blocks() {
846        let event: JsonLineEvent = serde_json::from_str(
847            r#"{"type":"message.delta","content":[{"type":"image","url":"x"},{"type":"text","text":"only this"}]}"#,
848        )
849        .unwrap();
850        assert_eq!(event.content_text(), Some("only this".to_string()));
851    }
852
853    #[cfg(feature = "json")]
854    #[test]
855    fn json_line_event_content_text_none_when_empty() {
856        let event: JsonLineEvent =
857            serde_json::from_str(r#"{"type":"message.delta","content":[]}"#).unwrap();
858        assert_eq!(event.content_text(), None);
859    }
860
861    #[cfg(feature = "json")]
862    #[test]
863    fn json_line_event_content_text_none_when_missing() {
864        let event: JsonLineEvent = serde_json::from_str(r#"{"type":"message.delta"}"#).unwrap();
865        assert_eq!(event.content_text(), None);
866    }
867
868    #[cfg(feature = "json")]
869    #[test]
870    fn query_result_from_events() {
871        let events: Vec<JsonLineEvent> = [
872            r#"{"type":"thread.started","thread_id":"thread_1"}"#,
873            r#"{"type":"item.completed","item":{"item_type":"agent_message","text":"the answer"}}"#,
874            r#"{"type":"turn.completed","usage":{"input_tokens":7,"output_tokens":3,"total_tokens":10}}"#,
875        ]
876        .iter()
877        .map(|l| serde_json::from_str(l).unwrap())
878        .collect();
879
880        let result = QueryResult::from_events(events);
881        assert_eq!(result.result, "the answer");
882        assert_eq!(result.thread_id.as_deref(), Some("thread_1"));
883        assert_eq!(result.usage.unwrap().total(), Some(10));
884        assert_eq!(result.events.len(), 3);
885    }
886
887    #[cfg(feature = "json")]
888    #[test]
889    fn query_result_concatenates_multiple_agent_messages() {
890        let events: Vec<JsonLineEvent> = [
891            r#"{"type":"item.completed","item":{"item_type":"agent_message","text":"one "}}"#,
892            r#"{"type":"item.completed","item":{"item_type":"agent_message","text":"two"}}"#,
893            r#"{"type":"turn.completed","usage":{"total_tokens":4}}"#,
894        ]
895        .iter()
896        .map(|l| serde_json::from_str(l).unwrap())
897        .collect();
898
899        assert_eq!(QueryResult::from_events(events).result, "one two");
900    }
901
902    /// A failed turn has no agent message and no usage. Both must come back
903    /// empty rather than fabricated.
904    #[cfg(feature = "json")]
905    #[test]
906    fn query_result_from_a_failed_turn() {
907        let events: Vec<JsonLineEvent> = [
908            r#"{"type":"thread.started","thread_id":"thread_2"}"#,
909            r#"{"type":"turn.failed","error":{"message":"usage limit"}}"#,
910        ]
911        .iter()
912        .map(|l| serde_json::from_str(l).unwrap())
913        .collect();
914
915        let result = QueryResult::from_events(events);
916        assert_eq!(result.result, "");
917        assert_eq!(result.usage, None);
918        assert_eq!(result.thread_id.as_deref(), Some("thread_2"));
919    }
920
921    /// Both values transcribed from captured runs.
922    #[cfg(feature = "json")]
923    #[test]
924    fn item_type_reads_the_discriminator() {
925        let message: JsonLineEvent = serde_json::from_str(
926            r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"hi"}}"#,
927        )
928        .unwrap();
929        assert_eq!(message.item_type(), Some("agent_message"));
930
931        let command: JsonLineEvent = serde_json::from_str(
932            r#"{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"git diff"}}"#,
933        )
934        .unwrap();
935        assert_eq!(command.item_type(), Some("command_execution"));
936
937        let turn: JsonLineEvent = serde_json::from_str(r#"{"type":"turn.completed"}"#).unwrap();
938        assert_eq!(turn.item_type(), None);
939    }
940
941    /// Transcribed from a captured `codex exec review` run, where the reviewer
942    /// reads the diff before commenting.
943    #[cfg(feature = "json")]
944    #[test]
945    fn command_execution_reads_a_finished_command() {
946        let event: JsonLineEvent = serde_json::from_str(
947            r#"{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"git diff","aggregated_output":"","exit_code":0,"status":"completed"}}"#,
948        )
949        .unwrap();
950
951        let command = event.command_execution().unwrap();
952        assert_eq!(command.command.as_deref(), Some("git diff"));
953        assert_eq!(command.exit_code, Some(0));
954        assert_eq!(command.status.as_deref(), Some("completed"));
955    }
956
957    /// An `item.started` has no outcome yet, and must not invent one.
958    #[cfg(feature = "json")]
959    #[test]
960    fn command_execution_tolerates_a_command_still_running() {
961        let event: JsonLineEvent = serde_json::from_str(
962            r#"{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"git diff"}}"#,
963        )
964        .unwrap();
965
966        let command = event.command_execution().unwrap();
967        assert_eq!(command.command.as_deref(), Some("git diff"));
968        assert_eq!(command.exit_code, None);
969        assert_eq!(command.status, None);
970    }
971
972    #[cfg(feature = "json")]
973    #[test]
974    fn command_execution_is_none_for_other_items() {
975        let event: JsonLineEvent = serde_json::from_str(
976            r#"{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"hi"}}"#,
977        )
978        .unwrap();
979        assert!(event.command_execution().is_none());
980    }
981}