claude-code 0.1.2

A Rust library for executing Claude Code CLI
Documentation
use serde::Deserialize;
use serde::de::DeserializeOwned;

use crate::error::ClaudeError;

/// JSON response from the Claude CLI.
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct ClaudeResponse {
    /// Model response text.
    pub result: String,
    /// Whether this is an error response.
    pub is_error: bool,
    /// Execution duration in milliseconds.
    pub duration_ms: u64,
    /// Number of turns.
    pub num_turns: u32,
    /// Session ID.
    pub session_id: String,
    /// Total cost in USD.
    pub total_cost_usd: f64,
    /// Stop reason.
    pub stop_reason: String,
    /// Token usage.
    pub usage: Usage,
}

/// Token usage.
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Usage {
    /// Input token count.
    pub input_tokens: u64,
    /// Output token count.
    pub output_tokens: u64,
    /// Input tokens read from cache.
    pub cache_read_input_tokens: u64,
    /// Input tokens used for cache creation.
    pub cache_creation_input_tokens: u64,
}

impl ClaudeResponse {
    /// Deserializes the `result` field into a strongly-typed value.
    ///
    /// Works with both streaming and non-streaming responses.
    /// The config must have `json_schema` set for the CLI to return
    /// structured JSON in the `result` field.
    pub fn parse_result<T: DeserializeOwned>(&self) -> Result<T, ClaudeError> {
        serde_json::from_str(&self.result).map_err(|e| ClaudeError::StructuredOutputError {
            raw_result: self.result.clone(),
            source: e,
        })
    }
}

/// Strips ANSI escape sequences from stdout and extracts the JSON portion.
pub(crate) fn strip_ansi(input: &str) -> &str {
    // CLI output may be wrapped with escape sequences like `\x1b[?1004l{...}\x1b[?1004l`.
    // Extract from the first '{' to the last '}'.
    let start = input.find('{');
    let end = input.rfind('}');
    match (start, end) {
        (Some(s), Some(e)) if s <= e => &input[s..=e],
        _ => input,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn deserialize_success_fixture() {
        let json = include_str!("../tests/fixtures/success.json");
        let resp: ClaudeResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.result, "Hello!");
        assert!(!resp.is_error);
        assert_eq!(resp.num_turns, 1);
        assert_eq!(resp.usage.input_tokens, 10);
        assert_eq!(resp.usage.output_tokens, 421);
    }

    #[test]
    fn deserialize_error_fixture() {
        let json = include_str!("../tests/fixtures/error_response.json");
        let resp: ClaudeResponse = serde_json::from_str(json).unwrap();
        assert!(resp.is_error);
        assert_eq!(resp.result, "Error: invalid request");
        assert_eq!(resp.total_cost_usd, 0.0);
    }

    #[test]
    fn strip_ansi_with_escape_sequences() {
        let input = "\x1b[?1004l{\"result\":\"hello\"}\x1b[?1004l";
        assert_eq!(strip_ansi(input), "{\"result\":\"hello\"}");
    }

    #[test]
    fn strip_ansi_without_escape_sequences() {
        let input = "{\"result\":\"hello\"}";
        assert_eq!(strip_ansi(input), "{\"result\":\"hello\"}");
    }

    #[test]
    fn strip_ansi_no_json() {
        let input = "no json here";
        assert_eq!(strip_ansi(input), "no json here");
    }

    #[derive(Debug, Deserialize, PartialEq)]
    struct Answer {
        value: i32,
    }

    #[test]
    fn parse_result_success() {
        let json = include_str!("../tests/fixtures/structured_success.json");
        let resp: ClaudeResponse = serde_json::from_str(json).unwrap();
        let answer: Answer = resp.parse_result().unwrap();
        assert_eq!(answer, Answer { value: 42 });
    }

    #[test]
    fn parse_result_invalid_json() {
        let resp = ClaudeResponse {
            result: "not valid json".into(),
            is_error: false,
            duration_ms: 0,
            num_turns: 0,
            session_id: String::new(),
            total_cost_usd: 0.0,
            stop_reason: String::new(),
            usage: Usage {
                input_tokens: 0,
                output_tokens: 0,
                cache_read_input_tokens: 0,
                cache_creation_input_tokens: 0,
            },
        };
        let err = resp.parse_result::<Answer>().unwrap_err();
        match err {
            crate::error::ClaudeError::StructuredOutputError { raw_result, .. } => {
                assert_eq!(raw_result, "not valid json");
            }
            _ => panic!("expected StructuredOutputError, got {err:?}"),
        }
    }

    #[test]
    fn parse_result_type_mismatch() {
        let resp = ClaudeResponse {
            result: r#"{"wrong_field": "hello"}"#.into(),
            is_error: false,
            duration_ms: 0,
            num_turns: 0,
            session_id: String::new(),
            total_cost_usd: 0.0,
            stop_reason: String::new(),
            usage: Usage {
                input_tokens: 0,
                output_tokens: 0,
                cache_read_input_tokens: 0,
                cache_creation_input_tokens: 0,
            },
        };
        let err = resp.parse_result::<Answer>().unwrap_err();
        assert!(matches!(
            err,
            crate::error::ClaudeError::StructuredOutputError { .. }
        ));
    }
}