1#[cfg(feature = "json")]
53use std::collections::HashMap;
54use std::fmt;
55use std::str::FromStr;
56
57use serde::{Deserialize, Serialize};
58
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "kebab-case")]
62pub enum SandboxMode {
63 ReadOnly,
65 #[default]
67 WorkspaceWrite,
68 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "kebab-case")]
90pub enum ApprovalPolicy {
91 Untrusted,
93 #[default]
95 OnRequest,
96 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "kebab-case")]
126pub enum ApprovalPolicyConfig {
127 Untrusted,
129 OnFailure,
134 #[default]
136 OnRequest,
137 Granular,
142 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "kebab-case")]
197pub enum WebSearchMode {
198 #[default]
200 Disabled,
201 Cached,
203 Indexed,
205 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(rename_all = "lowercase")]
223pub enum Color {
224 Always,
226 Never,
228 #[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#[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 #[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 #[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 #[must_use]
272 pub fn is_turn_completed(&self) -> bool {
273 self.event_type == "turn.completed"
274 }
275
276 #[must_use]
278 pub fn is_turn_failed(&self) -> bool {
279 self.event_type == "turn.failed"
280 }
281
282 #[must_use]
287 pub fn usage(&self) -> Option<TokenUsage> {
288 self.extra.get("usage").map(TokenUsage::from_json)
289 }
290
291 #[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 #[must_use]
332 pub fn role(&self) -> Option<&str> {
333 self.extra.get("role").and_then(|v| v.as_str())
334 }
335
336 #[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 #[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 #[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#[cfg(feature = "json")]
398#[derive(Debug, Clone)]
399pub struct QueryResult {
400 pub result: String,
405 pub session_id: Option<String>,
411 pub thread_id: Option<String>,
416 pub usage: Option<TokenUsage>,
421 pub events: Vec<JsonLineEvent>,
426}
427
428#[cfg(feature = "json")]
429impl QueryResult {
430 #[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#[cfg(feature = "json")]
470#[derive(Debug, Clone, Default, PartialEq, Eq)]
471#[non_exhaustive]
472pub struct CommandExecution {
473 pub command: Option<String>,
475 pub status: Option<String>,
477 pub exit_code: Option<i32>,
479 pub aggregated_output: Option<String>,
481}
482
483#[cfg(feature = "json")]
494#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
495pub struct TokenUsage {
496 pub input_tokens: Option<u64>,
498 pub cached_input_tokens: Option<u64>,
500 pub cache_write_input_tokens: Option<u64>,
502 pub output_tokens: Option<u64>,
504 pub reasoning_output_tokens: Option<u64>,
506 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 #[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#[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
613#[serde(tag = "status", rename_all = "snake_case")]
614pub enum CliVersionStatus {
615 Tested,
617 NewerUntested {
621 found: CliVersion,
623 tested_max: CliVersion,
625 },
626 OlderThanMinimum {
632 found: CliVersion,
634 minimum: CliVersion,
636 },
637}
638
639impl CliVersionStatus {
640 #[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 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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}