Skip to main content

claude_code/
types.rs

1use serde::Deserialize;
2use serde::de::DeserializeOwned;
3
4use crate::error::ClaudeError;
5
6/// JSON response from the Claude CLI.
7#[derive(Debug, Clone, Deserialize)]
8#[non_exhaustive]
9pub struct ClaudeResponse {
10    /// Model response text.
11    pub result: String,
12    /// Whether this is an error response.
13    pub is_error: bool,
14    /// Execution duration in milliseconds.
15    pub duration_ms: u64,
16    /// Number of turns.
17    pub num_turns: u32,
18    /// Session ID.
19    pub session_id: String,
20    /// Total cost in USD.
21    pub total_cost_usd: f64,
22    /// Stop reason.
23    pub stop_reason: String,
24    /// Token usage.
25    pub usage: Usage,
26}
27
28/// Token usage.
29#[derive(Debug, Clone, Deserialize)]
30#[non_exhaustive]
31pub struct Usage {
32    /// Input token count.
33    pub input_tokens: u64,
34    /// Output token count.
35    pub output_tokens: u64,
36    /// Input tokens read from cache.
37    pub cache_read_input_tokens: u64,
38    /// Input tokens used for cache creation.
39    pub cache_creation_input_tokens: u64,
40}
41
42impl ClaudeResponse {
43    /// Deserializes the `result` field into a strongly-typed value.
44    ///
45    /// Works with both streaming and non-streaming responses.
46    /// The config must have `json_schema` set for the CLI to return
47    /// structured JSON in the `result` field.
48    pub fn parse_result<T: DeserializeOwned>(&self) -> Result<T, ClaudeError> {
49        serde_json::from_str(&self.result).map_err(|e| ClaudeError::StructuredOutputError {
50            raw_result: self.result.clone(),
51            source: e,
52        })
53    }
54}
55
56/// Strips ANSI escape sequences from stdout and extracts the JSON portion.
57pub(crate) fn strip_ansi(input: &str) -> &str {
58    // CLI output may be wrapped with escape sequences like `\x1b[?1004l{...}\x1b[?1004l`.
59    // Extract from the first '{' to the last '}'.
60    let start = input.find('{');
61    let end = input.rfind('}');
62    match (start, end) {
63        (Some(s), Some(e)) if s <= e => &input[s..=e],
64        _ => input,
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn deserialize_success_fixture() {
74        let json = include_str!("../tests/fixtures/success.json");
75        let resp: ClaudeResponse = serde_json::from_str(json).unwrap();
76        assert_eq!(resp.result, "Hello!");
77        assert!(!resp.is_error);
78        assert_eq!(resp.num_turns, 1);
79        assert_eq!(resp.usage.input_tokens, 10);
80        assert_eq!(resp.usage.output_tokens, 421);
81    }
82
83    #[test]
84    fn deserialize_error_fixture() {
85        let json = include_str!("../tests/fixtures/error_response.json");
86        let resp: ClaudeResponse = serde_json::from_str(json).unwrap();
87        assert!(resp.is_error);
88        assert_eq!(resp.result, "Error: invalid request");
89        assert_eq!(resp.total_cost_usd, 0.0);
90    }
91
92    #[test]
93    fn strip_ansi_with_escape_sequences() {
94        let input = "\x1b[?1004l{\"result\":\"hello\"}\x1b[?1004l";
95        assert_eq!(strip_ansi(input), "{\"result\":\"hello\"}");
96    }
97
98    #[test]
99    fn strip_ansi_without_escape_sequences() {
100        let input = "{\"result\":\"hello\"}";
101        assert_eq!(strip_ansi(input), "{\"result\":\"hello\"}");
102    }
103
104    #[test]
105    fn strip_ansi_no_json() {
106        let input = "no json here";
107        assert_eq!(strip_ansi(input), "no json here");
108    }
109
110    #[derive(Debug, Deserialize, PartialEq)]
111    struct Answer {
112        value: i32,
113    }
114
115    #[test]
116    fn parse_result_success() {
117        let json = include_str!("../tests/fixtures/structured_success.json");
118        let resp: ClaudeResponse = serde_json::from_str(json).unwrap();
119        let answer: Answer = resp.parse_result().unwrap();
120        assert_eq!(answer, Answer { value: 42 });
121    }
122
123    #[test]
124    fn parse_result_invalid_json() {
125        let resp = ClaudeResponse {
126            result: "not valid json".into(),
127            is_error: false,
128            duration_ms: 0,
129            num_turns: 0,
130            session_id: String::new(),
131            total_cost_usd: 0.0,
132            stop_reason: String::new(),
133            usage: Usage {
134                input_tokens: 0,
135                output_tokens: 0,
136                cache_read_input_tokens: 0,
137                cache_creation_input_tokens: 0,
138            },
139        };
140        let err = resp.parse_result::<Answer>().unwrap_err();
141        match err {
142            crate::error::ClaudeError::StructuredOutputError { raw_result, .. } => {
143                assert_eq!(raw_result, "not valid json");
144            }
145            _ => panic!("expected StructuredOutputError, got {err:?}"),
146        }
147    }
148
149    #[test]
150    fn parse_result_type_mismatch() {
151        let resp = ClaudeResponse {
152            result: r#"{"wrong_field": "hello"}"#.into(),
153            is_error: false,
154            duration_ms: 0,
155            num_turns: 0,
156            session_id: String::new(),
157            total_cost_usd: 0.0,
158            stop_reason: String::new(),
159            usage: Usage {
160                input_tokens: 0,
161                output_tokens: 0,
162                cache_read_input_tokens: 0,
163                cache_creation_input_tokens: 0,
164            },
165        };
166        let err = resp.parse_result::<Answer>().unwrap_err();
167        assert!(matches!(
168            err,
169            crate::error::ClaudeError::StructuredOutputError { .. }
170        ));
171    }
172}