Skip to main content

autom8/claude/
types.rs

1//! Core types for Claude operations.
2//!
3//! Defines error types, result enums, outcome structures, and usage tracking
4//! used throughout the Claude integration.
5
6use serde::{Deserialize, Serialize};
7
8/// Represents token usage data from Claude CLI responses.
9///
10/// This struct captures detailed token consumption metrics from Claude API calls,
11/// including input/output tokens, cache statistics, and model information.
12/// Used for tracking resource consumption across stories and runs.
13#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct ClaudeUsage {
16    /// Number of input tokens consumed
17    #[serde(default)]
18    pub input_tokens: u64,
19    /// Number of output tokens generated
20    #[serde(default)]
21    pub output_tokens: u64,
22    /// Number of tokens read from cache
23    #[serde(default)]
24    pub cache_read_tokens: u64,
25    /// Number of tokens written to cache
26    #[serde(default)]
27    pub cache_creation_tokens: u64,
28    /// Number of tokens used for thinking/reasoning
29    #[serde(default)]
30    pub thinking_tokens: u64,
31    /// The Claude model used (e.g., "claude-sonnet-4-20250514")
32    #[serde(default)]
33    pub model: Option<String>,
34}
35
36impl ClaudeUsage {
37    /// Returns the total number of tokens (input + output).
38    pub fn total_tokens(&self) -> u64 {
39        self.input_tokens + self.output_tokens
40    }
41
42    /// Accumulates token counts from another ClaudeUsage instance.
43    ///
44    /// This is useful for aggregating usage across multiple Claude calls
45    /// within a single phase or story.
46    pub fn add(&mut self, other: &ClaudeUsage) {
47        self.input_tokens += other.input_tokens;
48        self.output_tokens += other.output_tokens;
49        self.cache_read_tokens += other.cache_read_tokens;
50        self.cache_creation_tokens += other.cache_creation_tokens;
51        self.thinking_tokens += other.thinking_tokens;
52        // For model, keep the existing value if set, otherwise take the other's value
53        if self.model.is_none() {
54            self.model = other.model.clone();
55        }
56    }
57}
58
59/// Captures detailed error information from Claude process failures.
60#[derive(Debug, Clone, PartialEq)]
61pub struct ClaudeErrorInfo {
62    /// Human-readable error message
63    pub message: String,
64    /// Exit code from the subprocess, if available
65    pub exit_code: Option<i32>,
66    /// Stderr output from the subprocess, if available
67    pub stderr: Option<String>,
68}
69
70impl ClaudeErrorInfo {
71    /// Create a new error info with just a message
72    pub fn new(message: impl Into<String>) -> Self {
73        Self {
74            message: message.into(),
75            exit_code: None,
76            stderr: None,
77        }
78    }
79
80    /// Create error info from a process exit status and stderr
81    pub fn from_process_failure(status: std::process::ExitStatus, stderr: Option<String>) -> Self {
82        let exit_code = status.code();
83        let stderr_trimmed = stderr.as_ref().map(|s| s.trim().to_string());
84
85        let message = match (&stderr_trimmed, exit_code) {
86            (Some(err), Some(code)) if !err.is_empty() => {
87                format!("Claude exited with status {}: {}", code, err)
88            }
89            (Some(err), None) if !err.is_empty() => {
90                format!("Claude exited with error: {}", err)
91            }
92            (_, Some(code)) => {
93                format!("Claude exited with status: {}", code)
94            }
95            (_, None) => {
96                format!("Claude exited with status: {}", status)
97            }
98        };
99
100        Self {
101            message,
102            exit_code,
103            stderr: stderr_trimmed.filter(|s| !s.is_empty()),
104        }
105    }
106}
107
108impl std::fmt::Display for ClaudeErrorInfo {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        write!(f, "{}", self.message)
111    }
112}
113
114impl From<ClaudeErrorInfo> for String {
115    fn from(info: ClaudeErrorInfo) -> Self {
116        info.message
117    }
118}
119
120/// Result from running Claude on a story task
121#[derive(Debug, Clone, PartialEq)]
122pub struct ClaudeStoryResult {
123    pub outcome: ClaudeOutcome,
124    /// Extracted work summary from Claude's output, if present
125    pub work_summary: Option<String>,
126    /// Full accumulated text output from Claude, used for knowledge extraction
127    pub full_output: String,
128    /// Token usage data from the Claude API response
129    pub usage: Option<ClaudeUsage>,
130}
131
132#[derive(Debug, Clone, PartialEq)]
133pub enum ClaudeOutcome {
134    IterationComplete,
135    AllStoriesComplete,
136    Error(ClaudeErrorInfo),
137}
138
139/// Legacy enum for backwards compatibility - use ClaudeStoryResult for new code
140#[derive(Debug, Clone, PartialEq)]
141pub enum ClaudeResult {
142    IterationComplete,
143    AllStoriesComplete,
144    Error(ClaudeErrorInfo),
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    // ClaudeUsage tests
152
153    #[test]
154    fn test_claude_usage_default() {
155        let usage = ClaudeUsage::default();
156        assert_eq!(usage.input_tokens, 0);
157        assert_eq!(usage.output_tokens, 0);
158        assert_eq!(usage.cache_read_tokens, 0);
159        assert_eq!(usage.cache_creation_tokens, 0);
160        assert_eq!(usage.thinking_tokens, 0);
161        assert_eq!(usage.model, None);
162    }
163
164    #[test]
165    fn test_claude_usage_total_tokens() {
166        let usage = ClaudeUsage {
167            input_tokens: 100,
168            output_tokens: 50,
169            ..Default::default()
170        };
171        assert_eq!(usage.total_tokens(), 150);
172    }
173
174    #[test]
175    fn test_claude_usage_total_tokens_zero() {
176        let usage = ClaudeUsage::default();
177        assert_eq!(usage.total_tokens(), 0);
178    }
179
180    #[test]
181    fn test_claude_usage_add_basic() {
182        let mut usage1 = ClaudeUsage {
183            input_tokens: 100,
184            output_tokens: 50,
185            cache_read_tokens: 25,
186            cache_creation_tokens: 10,
187            thinking_tokens: 5,
188            model: None,
189        };
190        let usage2 = ClaudeUsage {
191            input_tokens: 200,
192            output_tokens: 100,
193            cache_read_tokens: 50,
194            cache_creation_tokens: 20,
195            thinking_tokens: 10,
196            model: Some("claude-sonnet-4-20250514".to_string()),
197        };
198
199        usage1.add(&usage2);
200
201        assert_eq!(usage1.input_tokens, 300);
202        assert_eq!(usage1.output_tokens, 150);
203        assert_eq!(usage1.cache_read_tokens, 75);
204        assert_eq!(usage1.cache_creation_tokens, 30);
205        assert_eq!(usage1.thinking_tokens, 15);
206        assert_eq!(usage1.model, Some("claude-sonnet-4-20250514".to_string()));
207    }
208
209    #[test]
210    fn test_claude_usage_add_preserves_existing_model() {
211        let mut usage1 = ClaudeUsage {
212            model: Some("existing-model".to_string()),
213            ..Default::default()
214        };
215        let usage2 = ClaudeUsage {
216            model: Some("other-model".to_string()),
217            ..Default::default()
218        };
219
220        usage1.add(&usage2);
221
222        // Should preserve the existing model
223        assert_eq!(usage1.model, Some("existing-model".to_string()));
224    }
225
226    #[test]
227    fn test_claude_usage_add_takes_model_when_none() {
228        let mut usage1 = ClaudeUsage::default();
229        let usage2 = ClaudeUsage {
230            model: Some("new-model".to_string()),
231            ..Default::default()
232        };
233
234        usage1.add(&usage2);
235
236        assert_eq!(usage1.model, Some("new-model".to_string()));
237    }
238
239    #[test]
240    fn test_claude_usage_clone() {
241        let usage = ClaudeUsage {
242            input_tokens: 100,
243            output_tokens: 50,
244            cache_read_tokens: 25,
245            cache_creation_tokens: 10,
246            thinking_tokens: 5,
247            model: Some("test-model".to_string()),
248        };
249        let cloned = usage.clone();
250        assert_eq!(usage.input_tokens, cloned.input_tokens);
251        assert_eq!(usage.output_tokens, cloned.output_tokens);
252        assert_eq!(usage.cache_read_tokens, cloned.cache_read_tokens);
253        assert_eq!(usage.cache_creation_tokens, cloned.cache_creation_tokens);
254        assert_eq!(usage.thinking_tokens, cloned.thinking_tokens);
255        assert_eq!(usage.model, cloned.model);
256    }
257
258    #[test]
259    fn test_claude_usage_serialize_deserialize() {
260        let usage = ClaudeUsage {
261            input_tokens: 100,
262            output_tokens: 50,
263            cache_read_tokens: 25,
264            cache_creation_tokens: 10,
265            thinking_tokens: 5,
266            model: Some("test-model".to_string()),
267        };
268
269        let json = serde_json::to_string(&usage).unwrap();
270        let deserialized: ClaudeUsage = serde_json::from_str(&json).unwrap();
271
272        assert_eq!(usage.input_tokens, deserialized.input_tokens);
273        assert_eq!(usage.output_tokens, deserialized.output_tokens);
274        assert_eq!(usage.cache_read_tokens, deserialized.cache_read_tokens);
275        assert_eq!(
276            usage.cache_creation_tokens,
277            deserialized.cache_creation_tokens
278        );
279        assert_eq!(usage.thinking_tokens, deserialized.thinking_tokens);
280        assert_eq!(usage.model, deserialized.model);
281    }
282
283    #[test]
284    fn test_claude_usage_deserialize_partial() {
285        // Test backward compatibility - missing fields should default to 0/None
286        let json = r#"{"inputTokens": 100, "outputTokens": 50}"#;
287        let usage: ClaudeUsage = serde_json::from_str(json).unwrap();
288
289        assert_eq!(usage.input_tokens, 100);
290        assert_eq!(usage.output_tokens, 50);
291        assert_eq!(usage.cache_read_tokens, 0);
292        assert_eq!(usage.cache_creation_tokens, 0);
293        assert_eq!(usage.thinking_tokens, 0);
294        assert_eq!(usage.model, None);
295    }
296
297    #[test]
298    fn test_claude_usage_deserialize_empty() {
299        // Test that an empty object deserializes with defaults
300        let json = r#"{}"#;
301        let usage: ClaudeUsage = serde_json::from_str(json).unwrap();
302
303        assert_eq!(usage.input_tokens, 0);
304        assert_eq!(usage.output_tokens, 0);
305        assert_eq!(usage.cache_read_tokens, 0);
306        assert_eq!(usage.cache_creation_tokens, 0);
307        assert_eq!(usage.thinking_tokens, 0);
308        assert_eq!(usage.model, None);
309    }
310
311    // ClaudeErrorInfo tests
312
313    #[test]
314    fn test_claude_error_info_new() {
315        let info = ClaudeErrorInfo::new("test error message");
316        assert_eq!(info.message, "test error message");
317        assert_eq!(info.exit_code, None);
318        assert_eq!(info.stderr, None);
319    }
320
321    #[test]
322    fn test_claude_error_info_display() {
323        let info = ClaudeErrorInfo::new("test error");
324        assert_eq!(format!("{}", info), "test error");
325    }
326
327    #[test]
328    fn test_claude_error_info_into_string() {
329        let info = ClaudeErrorInfo::new("convertible error");
330        let s: String = info.into();
331        assert_eq!(s, "convertible error");
332    }
333
334    #[test]
335    fn test_claude_error_info_clone() {
336        let info = ClaudeErrorInfo {
337            message: "cloned error".to_string(),
338            exit_code: Some(42),
339            stderr: Some("stderr content".to_string()),
340        };
341        let cloned = info.clone();
342        assert_eq!(info, cloned);
343    }
344
345    #[test]
346    fn test_claude_error_info_equality() {
347        let info1 = ClaudeErrorInfo::new("error");
348        let info2 = ClaudeErrorInfo::new("error");
349        let info3 = ClaudeErrorInfo::new("different");
350        assert_eq!(info1, info2);
351        assert_ne!(info1, info3);
352    }
353}