Skip to main content

atm_protocol/
parse.rs

1//! Parsing Claude Code JSON structures.
2//!
3//! Based on validated integration testing (Week 1).
4
5use atm_core::{HookEventType, SessionDomain, SessionId};
6use serde::Deserialize;
7
8/// Raw status line JSON structure from Claude Code.
9///
10/// Based on validated integration testing (Week 1).
11/// All fields except session_id are optional to handle partial updates.
12#[derive(Debug, Clone, Deserialize)]
13pub struct RawStatusLine {
14    pub session_id: String,
15    #[serde(default)]
16    pub transcript_path: Option<String>,
17    #[serde(default)]
18    pub cwd: Option<String>,
19    #[serde(default)]
20    pub model: Option<RawModel>,
21    #[serde(default)]
22    pub workspace: Option<RawWorkspace>,
23    #[serde(default)]
24    pub version: Option<String>,
25    #[serde(default)]
26    pub cost: Option<RawCost>,
27    #[serde(default)]
28    pub context_window: Option<RawContextWindow>,
29    #[serde(default)]
30    pub exceeds_200k_tokens: Option<bool>,
31    /// Process ID of the Claude Code process (injected by status line script via $PPID)
32    #[serde(default)]
33    pub pid: Option<u32>,
34    /// Tmux pane ID (injected by hook script via $TMUX_PANE)
35    #[serde(default)]
36    pub tmux_pane: Option<String>,
37}
38
39#[derive(Debug, Clone, Deserialize)]
40pub struct RawModel {
41    pub id: String,
42    #[serde(default)]
43    pub display_name: Option<String>,
44}
45
46#[derive(Debug, Clone, Deserialize)]
47pub struct RawWorkspace {
48    #[serde(default)]
49    pub current_dir: Option<String>,
50    #[serde(default)]
51    pub project_dir: Option<String>,
52}
53
54#[derive(Debug, Clone, Deserialize)]
55pub struct RawCost {
56    pub total_cost_usd: f64,
57    pub total_duration_ms: u64,
58    #[serde(default)]
59    pub total_api_duration_ms: u64,
60    #[serde(default)]
61    pub total_lines_added: u64,
62    #[serde(default)]
63    pub total_lines_removed: u64,
64}
65
66#[derive(Debug, Clone, Deserialize)]
67pub struct RawContextWindow {
68    #[serde(default)]
69    pub total_input_tokens: u64,
70    #[serde(default)]
71    pub total_output_tokens: u64,
72    #[serde(default = "default_context_window_size")]
73    pub context_window_size: u32,
74    /// Pre-calculated percentage of context window used (0-100), provided by Claude Code
75    #[serde(default)]
76    pub used_percentage: Option<f64>,
77    /// Pre-calculated percentage of context window remaining (0-100), provided by Claude Code
78    #[serde(default)]
79    pub remaining_percentage: Option<f64>,
80    #[serde(default)]
81    pub current_usage: Option<RawCurrentUsage>,
82}
83
84fn default_context_window_size() -> u32 {
85    200_000
86}
87
88#[derive(Debug, Clone, Deserialize)]
89pub struct RawCurrentUsage {
90    #[serde(default)]
91    pub input_tokens: u64,
92    #[serde(default)]
93    pub output_tokens: u64,
94    #[serde(default)]
95    pub cache_creation_input_tokens: u64,
96    #[serde(default)]
97    pub cache_read_input_tokens: u64,
98}
99
100impl RawStatusLine {
101    /// Converts to SessionDomain.
102    /// Returns None if required fields (model) are missing.
103    pub fn to_session_domain(&self) -> Option<SessionDomain> {
104        // Model is required to create a new session
105        let model = self.model.as_ref()?;
106
107        let cost = self.cost.as_ref();
108        let context = self.context_window.as_ref();
109        let current = context.and_then(|c| c.current_usage.as_ref());
110
111        Some(SessionDomain::from_status_line(
112            &self.session_id,
113            &model.id,
114            cost.map(|c| c.total_cost_usd).unwrap_or(0.0),
115            cost.map(|c| c.total_duration_ms).unwrap_or(0),
116            cost.map(|c| c.total_api_duration_ms).unwrap_or(0),
117            cost.map(|c| c.total_lines_added).unwrap_or(0),
118            cost.map(|c| c.total_lines_removed).unwrap_or(0),
119            context.map(|c| c.total_input_tokens).unwrap_or(0),
120            context.map(|c| c.total_output_tokens).unwrap_or(0),
121            context.map(|c| c.context_window_size).unwrap_or(200_000),
122            current.map(|c| c.input_tokens).unwrap_or(0),
123            current.map(|c| c.output_tokens).unwrap_or(0),
124            current.map(|c| c.cache_creation_input_tokens).unwrap_or(0),
125            current.map(|c| c.cache_read_input_tokens).unwrap_or(0),
126            self.cwd.as_deref(),
127            self.version.as_deref(),
128        ))
129    }
130
131    /// Updates an existing SessionDomain with new data.
132    /// Only updates fields that are present in this status line.
133    pub fn update_session(&self, session: &mut SessionDomain) {
134        use atm_core::Model;
135
136        let cost = self.cost.as_ref();
137        let context = self.context_window.as_ref();
138        let current = context.and_then(|c| c.current_usage.as_ref());
139
140        // Update model if present (fills in Unknown for discovered/hook-created sessions)
141        if let Some(model) = &self.model {
142            session.model = Model::from_id(&model.id);
143        }
144
145        session.update_from_status_line(
146            cost.map(|c| c.total_cost_usd).unwrap_or(0.0),
147            cost.map(|c| c.total_duration_ms).unwrap_or(0),
148            cost.map(|c| c.total_api_duration_ms).unwrap_or(0),
149            cost.map(|c| c.total_lines_added).unwrap_or(0),
150            cost.map(|c| c.total_lines_removed).unwrap_or(0),
151            context.map(|c| c.total_input_tokens).unwrap_or(0),
152            context.map(|c| c.total_output_tokens).unwrap_or(0),
153            current.map(|c| c.input_tokens).unwrap_or(0),
154            current.map(|c| c.output_tokens).unwrap_or(0),
155            current.map(|c| c.cache_creation_input_tokens).unwrap_or(0),
156            current.map(|c| c.cache_read_input_tokens).unwrap_or(0),
157        );
158    }
159}
160
161/// Raw hook event JSON structure from Claude Code.
162#[derive(Debug, Clone, Deserialize)]
163pub struct RawHookEvent {
164    pub session_id: String,
165    pub hook_event_name: String,
166    #[serde(default)]
167    pub tool_name: Option<String>,
168    #[serde(default)]
169    pub tool_input: Option<serde_json::Value>,
170    #[serde(default)]
171    pub tool_use_id: Option<String>,
172    /// Process ID of the Claude Code process (injected by hook script via $PPID)
173    #[serde(default)]
174    pub pid: Option<u32>,
175    /// Tmux pane ID (injected by hook script via $TMUX_PANE)
176    #[serde(default)]
177    pub tmux_pane: Option<String>,
178}
179
180impl RawHookEvent {
181    /// Parses the hook event type.
182    pub fn event_type(&self) -> Option<HookEventType> {
183        HookEventType::from_event_name(&self.hook_event_name)
184    }
185
186    /// Returns the session ID.
187    pub fn session_id(&self) -> SessionId {
188        SessionId::new(&self.session_id)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use atm_core::Model;
196
197    #[test]
198    fn test_raw_status_line_parsing() {
199        let json = r#"{
200            "session_id": "test-123",
201            "model": {"id": "claude-opus-4-5-20251101", "display_name": "Opus 4.5"},
202            "cost": {"total_cost_usd": 0.35, "total_duration_ms": 35000},
203            "context_window": {"total_input_tokens": 5000, "context_window_size": 200000}
204        }"#;
205
206        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
207        let session = raw.to_session_domain().expect("should create session");
208
209        assert_eq!(session.id.as_str(), "test-123");
210        assert_eq!(session.model, Model::Opus45);
211        assert!((session.cost.as_usd() - 0.35).abs() < 0.001);
212        assert_eq!(session.context.total_input_tokens.as_u64(), 5000);
213    }
214
215    #[test]
216    fn test_raw_hook_event_parsing() {
217        let json = r#"{
218            "session_id": "test-123",
219            "hook_event_name": "PreToolUse",
220            "tool_name": "Bash"
221        }"#;
222
223        let event: RawHookEvent = serde_json::from_str(json).unwrap();
224        assert_eq!(event.event_type(), Some(HookEventType::PreToolUse));
225        assert_eq!(event.tool_name.as_deref(), Some("Bash"));
226    }
227
228    #[test]
229    fn test_raw_status_line_with_current_usage() {
230        let json = r#"{
231            "session_id": "test-456",
232            "model": {"id": "claude-sonnet-4-20250514"},
233            "cost": {"total_cost_usd": 0.10, "total_duration_ms": 10000},
234            "context_window": {
235                "total_input_tokens": 1000,
236                "total_output_tokens": 500,
237                "context_window_size": 200000,
238                "current_usage": {
239                    "input_tokens": 200,
240                    "output_tokens": 100,
241                    "cache_creation_input_tokens": 50,
242                    "cache_read_input_tokens": 25
243                }
244            }
245        }"#;
246
247        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
248        let session = raw.to_session_domain().expect("should create session");
249
250        assert_eq!(session.context.current_input_tokens.as_u64(), 200);
251        assert_eq!(session.context.cache_creation_tokens.as_u64(), 50);
252    }
253
254    #[test]
255    fn test_raw_status_line_context_from_current_usage() {
256        // Context percentage is calculated from current_usage fields
257        // context_tokens = cache_read + input + cache_creation
258        let json = r#"{
259            "session_id": "test-pct",
260            "model": {"id": "claude-sonnet-4-20250514"},
261            "context_window": {
262                "total_input_tokens": 50000,
263                "total_output_tokens": 10000,
264                "context_window_size": 200000,
265                "current_usage": {
266                    "input_tokens": 1000,
267                    "output_tokens": 500,
268                    "cache_creation_input_tokens": 2000,
269                    "cache_read_input_tokens": 40000
270                }
271            }
272        }"#;
273
274        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
275        let session = raw.to_session_domain().expect("should create session");
276
277        // context_tokens = 40000 + 1000 + 2000 = 43000
278        // percentage = 43000 / 200000 = 21.5%
279        assert_eq!(session.context.context_tokens().as_u64(), 43_000);
280        assert!((session.context.usage_percentage() - 21.5).abs() < 0.01);
281    }
282
283    #[test]
284    fn test_raw_status_line_zero_without_current_usage() {
285        // When current_usage is missing (like after /clear), context should be 0%
286        let json = r#"{
287            "session_id": "test-fallback",
288            "model": {"id": "claude-sonnet-4-20250514"},
289            "context_window": {
290                "total_input_tokens": 50000,
291                "total_output_tokens": 10000,
292                "context_window_size": 200000
293            }
294        }"#;
295
296        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
297        let session = raw.to_session_domain().expect("should create session");
298
299        // No current_usage means context_tokens is 0, so 0%
300        assert_eq!(session.context.context_tokens().as_u64(), 0);
301        assert!((session.context.usage_percentage() - 0.0).abs() < 0.01);
302    }
303
304    #[test]
305    fn test_raw_status_line_missing_model_returns_none() {
306        // Status line without model should not create a session
307        let json = r#"{"session_id": "test-789"}"#;
308
309        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
310        assert!(raw.to_session_domain().is_none());
311    }
312
313    #[test]
314    fn test_update_session_fills_in_model() {
315        use atm_core::{AgentType, SessionDomain};
316
317        // Simulate a session created via discovery (model Unknown)
318        let mut session = SessionDomain::new(
319            atm_core::SessionId::new("test-discovered"),
320            AgentType::GeneralPurpose,
321            Model::Unknown,
322        );
323        assert_eq!(session.model, Model::Unknown);
324
325        // Status line arrives with model info
326        let json = r#"{
327            "session_id": "test-discovered",
328            "model": {"id": "claude-opus-4-5-20251101"},
329            "cost": {"total_cost_usd": 0.50, "total_duration_ms": 10000}
330        }"#;
331
332        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
333        raw.update_session(&mut session);
334
335        // Model should now be filled in
336        assert_eq!(session.model, Model::Opus45);
337    }
338
339    #[test]
340    fn test_raw_status_line_partial_data() {
341        // Status line with model but no cost/context should create session with defaults
342        let json = r#"{
343            "session_id": "test-partial",
344            "model": {"id": "claude-sonnet-4-20250514"}
345        }"#;
346
347        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
348        let session = raw.to_session_domain().expect("should create with defaults");
349
350        assert_eq!(session.id.as_str(), "test-partial");
351        assert!((session.cost.as_usd() - 0.0).abs() < 0.001);
352        assert_eq!(session.context.total_input_tokens.as_u64(), 0);
353    }
354}