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, StatusLineData};
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 raw JSON data to a StatusLineData struct.
102    ///
103    /// Returns None if required fields (model) are missing.
104    pub fn to_status_line_data(&self) -> Option<StatusLineData> {
105        let model = self.model.as_ref()?;
106        let cost = self.cost.as_ref();
107        let context = self.context_window.as_ref();
108        let current = context.and_then(|c| c.current_usage.as_ref());
109
110        Some(StatusLineData {
111            session_id: self.session_id.clone(),
112            model_id: model.id.clone(),
113            cost_usd: cost.map(|c| c.total_cost_usd).unwrap_or(0.0),
114            total_duration_ms: cost.map(|c| c.total_duration_ms).unwrap_or(0),
115            api_duration_ms: cost.map(|c| c.total_api_duration_ms).unwrap_or(0),
116            lines_added: cost.map(|c| c.total_lines_added).unwrap_or(0),
117            lines_removed: cost.map(|c| c.total_lines_removed).unwrap_or(0),
118            total_input_tokens: context.map(|c| c.total_input_tokens).unwrap_or(0),
119            total_output_tokens: context.map(|c| c.total_output_tokens).unwrap_or(0),
120            context_window_size: context.map(|c| c.context_window_size).unwrap_or(200_000),
121            current_input_tokens: current.map(|c| c.input_tokens).unwrap_or(0),
122            current_output_tokens: current.map(|c| c.output_tokens).unwrap_or(0),
123            cache_creation_tokens: current.map(|c| c.cache_creation_input_tokens).unwrap_or(0),
124            cache_read_tokens: current.map(|c| c.cache_read_input_tokens).unwrap_or(0),
125            cwd: self.cwd.clone(),
126            version: self.version.clone(),
127        })
128    }
129
130    /// Converts to SessionDomain.
131    /// Returns None if required fields (model) are missing.
132    pub fn to_session_domain(&self) -> Option<SessionDomain> {
133        let data = self.to_status_line_data()?;
134        Some(SessionDomain::from_status_line(&data))
135    }
136
137    /// Updates an existing SessionDomain with new data.
138    /// Only updates fields that are present in this status line.
139    pub fn update_session(&self, session: &mut SessionDomain) {
140        use atm_core::Model;
141
142        // Update model if present (fills in Unknown for discovered/hook-created sessions)
143        if let Some(model) = &self.model {
144            session.model = Model::from_id(&model.id);
145        }
146
147        // Build StatusLineData for the update (model_id not used in update)
148        let cost = self.cost.as_ref();
149        let context = self.context_window.as_ref();
150        let current = context.and_then(|c| c.current_usage.as_ref());
151
152        let data = StatusLineData {
153            session_id: self.session_id.clone(),
154            model_id: String::new(), // Not used in update
155            cost_usd: cost.map(|c| c.total_cost_usd).unwrap_or(0.0),
156            total_duration_ms: cost.map(|c| c.total_duration_ms).unwrap_or(0),
157            api_duration_ms: cost.map(|c| c.total_api_duration_ms).unwrap_or(0),
158            lines_added: cost.map(|c| c.total_lines_added).unwrap_or(0),
159            lines_removed: cost.map(|c| c.total_lines_removed).unwrap_or(0),
160            total_input_tokens: context.map(|c| c.total_input_tokens).unwrap_or(0),
161            total_output_tokens: context.map(|c| c.total_output_tokens).unwrap_or(0),
162            context_window_size: context.map(|c| c.context_window_size).unwrap_or(200_000),
163            current_input_tokens: current.map(|c| c.input_tokens).unwrap_or(0),
164            current_output_tokens: current.map(|c| c.output_tokens).unwrap_or(0),
165            cache_creation_tokens: current.map(|c| c.cache_creation_input_tokens).unwrap_or(0),
166            cache_read_tokens: current.map(|c| c.cache_read_input_tokens).unwrap_or(0),
167            cwd: self.cwd.clone(),
168            version: self.version.clone(),
169        };
170
171        session.update_from_status_line(&data);
172    }
173}
174
175/// Raw hook event JSON structure from Claude Code.
176///
177/// Flat structure with all possible fields as Option<T>.
178/// Use typed conversion for domain-layer type safety.
179#[derive(Debug, Clone, Deserialize)]
180pub struct RawHookEvent {
181    // === Common Fields (all events) ===
182    pub session_id: String,
183    pub hook_event_name: String,
184    #[serde(default)]
185    pub cwd: Option<String>,
186    #[serde(default)]
187    pub permission_mode: Option<String>,
188
189    // === Injected by hook script ===
190    #[serde(default)]
191    pub pid: Option<u32>,
192    #[serde(default)]
193    pub tmux_pane: Option<String>,
194
195    // === Tool Events (PreToolUse, PostToolUse, PostToolUseFailure) ===
196    #[serde(default)]
197    pub tool_name: Option<String>,
198    #[serde(default)]
199    pub tool_input: Option<serde_json::Value>,
200    #[serde(default)]
201    pub tool_response: Option<serde_json::Value>,
202    #[serde(default)]
203    pub tool_use_id: Option<String>,
204
205    // === User Prompt (UserPromptSubmit) ===
206    #[serde(default)]
207    pub prompt: Option<String>,
208
209    // === Stop Events (Stop, SubagentStop) ===
210    #[serde(default)]
211    pub stop_hook_active: Option<bool>,
212
213    // === Subagent Events (SubagentStart, SubagentStop) ===
214    #[serde(default)]
215    pub agent_id: Option<String>,
216    #[serde(default)]
217    pub agent_type: Option<String>,
218    #[serde(default)]
219    pub agent_transcript_path: Option<String>,
220
221    // === Session Events (SessionStart, SessionEnd) ===
222    #[serde(default)]
223    pub source: Option<String>,
224    #[serde(default)]
225    pub reason: Option<String>,
226    #[serde(default)]
227    pub model: Option<String>,
228
229    // === Compaction/Setup (PreCompact, Setup) ===
230    #[serde(default)]
231    pub trigger: Option<String>,
232    #[serde(default)]
233    pub custom_instructions: Option<String>,
234
235    // === Notification ===
236    #[serde(default)]
237    pub notification_type: Option<String>,
238    #[serde(default)]
239    pub message: Option<String>,
240}
241
242impl RawHookEvent {
243    /// Parses the hook event type.
244    pub fn event_type(&self) -> Option<HookEventType> {
245        HookEventType::from_event_name(&self.hook_event_name)
246    }
247
248    /// Returns the session ID.
249    pub fn session_id(&self) -> SessionId {
250        SessionId::new(&self.session_id)
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use atm_core::Model;
258
259    #[test]
260    fn test_raw_status_line_parsing() {
261        let json = r#"{
262            "session_id": "test-123",
263            "model": {"id": "claude-opus-4-5-20251101", "display_name": "Opus 4.5"},
264            "cost": {"total_cost_usd": 0.35, "total_duration_ms": 35000},
265            "context_window": {"total_input_tokens": 5000, "context_window_size": 200000}
266        }"#;
267
268        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
269        let session = raw.to_session_domain().expect("should create session");
270
271        assert_eq!(session.id.as_str(), "test-123");
272        assert_eq!(session.model, Model::Opus45);
273        assert!((session.cost.as_usd() - 0.35).abs() < 0.001);
274        assert_eq!(session.context.total_input_tokens.as_u64(), 5000);
275    }
276
277    #[test]
278    fn test_raw_hook_event_parsing() {
279        let json = r#"{
280            "session_id": "test-123",
281            "hook_event_name": "PreToolUse",
282            "tool_name": "Bash"
283        }"#;
284
285        let event: RawHookEvent = serde_json::from_str(json).unwrap();
286        assert_eq!(event.event_type(), Some(HookEventType::PreToolUse));
287        assert_eq!(event.tool_name.as_deref(), Some("Bash"));
288    }
289
290    #[test]
291    fn test_raw_status_line_with_current_usage() {
292        let json = r#"{
293            "session_id": "test-456",
294            "model": {"id": "claude-sonnet-4-20250514"},
295            "cost": {"total_cost_usd": 0.10, "total_duration_ms": 10000},
296            "context_window": {
297                "total_input_tokens": 1000,
298                "total_output_tokens": 500,
299                "context_window_size": 200000,
300                "current_usage": {
301                    "input_tokens": 200,
302                    "output_tokens": 100,
303                    "cache_creation_input_tokens": 50,
304                    "cache_read_input_tokens": 25
305                }
306            }
307        }"#;
308
309        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
310        let session = raw.to_session_domain().expect("should create session");
311
312        assert_eq!(session.context.current_input_tokens.as_u64(), 200);
313        assert_eq!(session.context.cache_creation_tokens.as_u64(), 50);
314    }
315
316    #[test]
317    fn test_raw_status_line_context_from_current_usage() {
318        // Context percentage is calculated from current_usage fields
319        // context_tokens = cache_read + input + cache_creation
320        let json = r#"{
321            "session_id": "test-pct",
322            "model": {"id": "claude-sonnet-4-20250514"},
323            "context_window": {
324                "total_input_tokens": 50000,
325                "total_output_tokens": 10000,
326                "context_window_size": 200000,
327                "current_usage": {
328                    "input_tokens": 1000,
329                    "output_tokens": 500,
330                    "cache_creation_input_tokens": 2000,
331                    "cache_read_input_tokens": 40000
332                }
333            }
334        }"#;
335
336        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
337        let session = raw.to_session_domain().expect("should create session");
338
339        // context_tokens = 40000 + 1000 + 2000 = 43000
340        // percentage = 43000 / 200000 = 21.5%
341        assert_eq!(session.context.context_tokens().as_u64(), 43_000);
342        assert!((session.context.usage_percentage() - 21.5).abs() < 0.01);
343    }
344
345    #[test]
346    fn test_raw_status_line_zero_without_current_usage() {
347        // When current_usage is missing (like after /clear), context should be 0%
348        let json = r#"{
349            "session_id": "test-fallback",
350            "model": {"id": "claude-sonnet-4-20250514"},
351            "context_window": {
352                "total_input_tokens": 50000,
353                "total_output_tokens": 10000,
354                "context_window_size": 200000
355            }
356        }"#;
357
358        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
359        let session = raw.to_session_domain().expect("should create session");
360
361        // No current_usage means context_tokens is 0, so 0%
362        assert_eq!(session.context.context_tokens().as_u64(), 0);
363        assert!((session.context.usage_percentage() - 0.0).abs() < 0.01);
364    }
365
366    #[test]
367    fn test_raw_status_line_missing_model_returns_none() {
368        // Status line without model should not create a session
369        let json = r#"{"session_id": "test-789"}"#;
370
371        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
372        assert!(raw.to_session_domain().is_none());
373    }
374
375    #[test]
376    fn test_raw_hook_event_stop() {
377        let json = r#"{
378            "session_id": "test-123",
379            "hook_event_name": "Stop",
380            "stop_hook_active": true
381        }"#;
382
383        let event: RawHookEvent = serde_json::from_str(json).unwrap();
384        assert_eq!(event.event_type(), Some(HookEventType::Stop));
385        assert_eq!(event.stop_hook_active, Some(true));
386    }
387
388    #[test]
389    fn test_raw_hook_event_user_prompt() {
390        let json = r#"{
391            "session_id": "test-123",
392            "hook_event_name": "UserPromptSubmit",
393            "prompt": "Help me write a function"
394        }"#;
395
396        let event: RawHookEvent = serde_json::from_str(json).unwrap();
397        assert_eq!(event.event_type(), Some(HookEventType::UserPromptSubmit));
398        assert_eq!(event.prompt.as_deref(), Some("Help me write a function"));
399    }
400
401    #[test]
402    fn test_raw_hook_event_subagent_start() {
403        let json = r#"{
404            "session_id": "test-123",
405            "hook_event_name": "SubagentStart",
406            "agent_id": "agent_456",
407            "agent_type": "Explore"
408        }"#;
409
410        let event: RawHookEvent = serde_json::from_str(json).unwrap();
411        assert_eq!(event.event_type(), Some(HookEventType::SubagentStart));
412        assert_eq!(event.agent_id.as_deref(), Some("agent_456"));
413        assert_eq!(event.agent_type.as_deref(), Some("Explore"));
414    }
415
416    #[test]
417    fn test_raw_hook_event_notification() {
418        let json = r#"{
419            "session_id": "test-123",
420            "hook_event_name": "Notification",
421            "notification_type": "permission_prompt",
422            "message": "Allow tool execution?"
423        }"#;
424
425        let event: RawHookEvent = serde_json::from_str(json).unwrap();
426        assert_eq!(event.event_type(), Some(HookEventType::Notification));
427        assert_eq!(event.notification_type.as_deref(), Some("permission_prompt"));
428    }
429
430    #[test]
431    fn test_raw_hook_event_session_start() {
432        let json = r#"{
433            "session_id": "test-123",
434            "hook_event_name": "SessionStart",
435            "source": "resume",
436            "model": "claude-opus-4-5-20251101"
437        }"#;
438
439        let event: RawHookEvent = serde_json::from_str(json).unwrap();
440        assert_eq!(event.event_type(), Some(HookEventType::SessionStart));
441        assert_eq!(event.source.as_deref(), Some("resume"));
442    }
443
444    #[test]
445    fn test_raw_hook_event_pre_compact() {
446        let json = r#"{
447            "session_id": "test-123",
448            "hook_event_name": "PreCompact",
449            "trigger": "auto"
450        }"#;
451
452        let event: RawHookEvent = serde_json::from_str(json).unwrap();
453        assert_eq!(event.event_type(), Some(HookEventType::PreCompact));
454        assert_eq!(event.trigger.as_deref(), Some("auto"));
455    }
456
457    #[test]
458    fn test_update_session_fills_in_model() {
459        use atm_core::{AgentType, SessionDomain};
460
461        // Simulate a session created via discovery (model Unknown)
462        let mut session = SessionDomain::new(
463            atm_core::SessionId::new("test-discovered"),
464            AgentType::GeneralPurpose,
465            Model::Unknown,
466        );
467        assert_eq!(session.model, Model::Unknown);
468
469        // Status line arrives with model info
470        let json = r#"{
471            "session_id": "test-discovered",
472            "model": {"id": "claude-opus-4-5-20251101"},
473            "cost": {"total_cost_usd": 0.50, "total_duration_ms": 10000}
474        }"#;
475
476        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
477        raw.update_session(&mut session);
478
479        // Model should now be filled in
480        assert_eq!(session.model, Model::Opus45);
481    }
482
483    #[test]
484    fn test_raw_status_line_partial_data() {
485        // Status line with model but no cost/context should create session with defaults
486        let json = r#"{
487            "session_id": "test-partial",
488            "model": {"id": "claude-sonnet-4-20250514"}
489        }"#;
490
491        let raw: RawStatusLine = serde_json::from_str(json).unwrap();
492        let session = raw.to_session_domain().expect("should create with defaults");
493
494        assert_eq!(session.id.as_str(), "test-partial");
495        assert!((session.cost.as_usd() - 0.0).abs() < 0.001);
496        assert_eq!(session.context.total_input_tokens.as_u64(), 0);
497    }
498}