Skip to main content

ccboard_types/models/
session.rs

1//! Session models for JSONL session files
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::path::PathBuf;
7
8/// A single line from a session JSONL file
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct SessionLine {
12    /// Session ID
13    #[serde(default)]
14    pub session_id: Option<String>,
15
16    /// Event type: "user", "assistant", "file-history-snapshot", "session_end", etc.
17    #[serde(rename = "type")]
18    pub line_type: String,
19
20    /// Timestamp of the event
21    #[serde(default)]
22    pub timestamp: Option<DateTime<Utc>>,
23
24    /// Current working directory
25    #[serde(default)]
26    pub cwd: Option<String>,
27
28    /// Git branch (if available)
29    #[serde(default)]
30    pub git_branch: Option<String>,
31
32    /// Message content (for user/assistant types)
33    #[serde(default)]
34    pub message: Option<SessionMessage>,
35
36    /// Model used (for assistant messages)
37    #[serde(default)]
38    pub model: Option<String>,
39
40    /// Token usage for this message
41    #[serde(default)]
42    pub usage: Option<TokenUsage>,
43
44    /// Summary data (for session_end type)
45    #[serde(default)]
46    pub summary: Option<SessionSummary>,
47
48    /// Parent session ID (for subagents)
49    #[serde(default)]
50    pub parent_session_id: Option<String>,
51}
52
53/// Message content in a session
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct SessionMessage {
57    /// Role: "user" or "assistant"
58    #[serde(default)]
59    pub role: Option<String>,
60
61    /// Text content (can be String or Array of content blocks in newer Claude Code versions)
62    #[serde(default)]
63    pub content: Option<Value>,
64
65    /// Tool calls made
66    #[serde(default)]
67    pub tool_calls: Option<Vec<serde_json::Value>>,
68
69    /// Tool results
70    #[serde(default)]
71    pub tool_results: Option<Vec<serde_json::Value>>,
72
73    /// Token usage (for assistant messages)
74    #[serde(default)]
75    pub usage: Option<TokenUsage>,
76}
77
78/// Token usage for a message
79#[derive(Debug, Clone, Default, Serialize, Deserialize)]
80pub struct TokenUsage {
81    #[serde(default)]
82    pub input_tokens: u64,
83
84    #[serde(default)]
85    pub output_tokens: u64,
86
87    /// Cache read tokens (from cache_read_input_tokens in JSONL)
88    #[serde(default, alias = "cache_read_input_tokens")]
89    pub cache_read_tokens: u64,
90
91    /// Cache creation tokens (from cache_creation_input_tokens in JSONL)
92    #[serde(default, alias = "cache_creation_input_tokens")]
93    pub cache_write_tokens: u64,
94}
95
96impl TokenUsage {
97    /// Total tokens including cache reads and writes
98    ///
99    /// This is the sum of all token types:
100    /// - input_tokens: Regular input tokens (not cached)
101    /// - output_tokens: Generated tokens
102    /// - cache_read_tokens: Tokens read from cache (cache hits)
103    /// - cache_write_tokens: Tokens written to cache (cache creation)
104    pub fn total(&self) -> u64 {
105        self.input_tokens + self.output_tokens + self.cache_read_tokens + self.cache_write_tokens
106    }
107}
108
109/// Summary at session end
110#[derive(Debug, Clone, Default, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct SessionSummary {
113    #[serde(default)]
114    pub total_tokens: u64,
115    #[serde(default)]
116    pub total_input_tokens: u64,
117    #[serde(default)]
118    pub total_output_tokens: u64,
119    #[serde(default)]
120    pub total_cache_read_tokens: u64,
121    #[serde(default)]
122    pub total_cache_write_tokens: u64,
123    #[serde(default)]
124    pub message_count: u64,
125    #[serde(default)]
126    pub duration_seconds: Option<u64>,
127    #[serde(default)]
128    pub models_used: Option<Vec<String>>,
129}
130
131/// Metadata extracted from a session without full parse
132///
133/// Created by streaming the JSONL until session_end event.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct SessionMetadata {
136    /// Session ID (from filename or content)
137    pub id: String,
138
139    /// Full path to the JSONL file
140    pub file_path: PathBuf,
141
142    /// Project path (extracted from directory structure)
143    pub project_path: String,
144
145    /// First timestamp in session
146    pub first_timestamp: Option<DateTime<Utc>>,
147
148    /// Last timestamp in session
149    pub last_timestamp: Option<DateTime<Utc>>,
150
151    /// Total message count (from summary or counted)
152    pub message_count: u64,
153
154    /// Total tokens (from summary)
155    pub total_tokens: u64,
156
157    /// Token breakdown for precise pricing
158    pub input_tokens: u64,
159    pub output_tokens: u64,
160    pub cache_creation_tokens: u64,
161    pub cache_read_tokens: u64,
162
163    /// Models used in this session
164    pub models_used: Vec<String>,
165
166    /// File size in bytes
167    pub file_size_bytes: u64,
168
169    /// Preview of first user message (truncated to 200 chars)
170    pub first_user_message: Option<String>,
171
172    /// Whether this session spawned subagents
173    pub has_subagents: bool,
174
175    /// Duration in seconds (from summary)
176    pub duration_seconds: Option<u64>,
177
178    /// Git branch name (normalized, extracted from first gitBranch in session)
179    pub branch: Option<String>,
180
181    /// Tool usage statistics: tool name -> call count
182    /// Extracted from tool_calls in assistant messages during session scan
183    pub tool_usage: std::collections::HashMap<String, usize>,
184
185    /// Per-tool token attribution: tool name -> tokens
186    /// Proportionally distributed from message-level token counts
187    #[serde(default)]
188    pub tool_token_usage: std::collections::HashMap<String, u64>,
189}
190
191impl SessionMetadata {
192    /// Create a minimal metadata from just file path
193    pub fn from_path(path: PathBuf, project_path: String) -> Self {
194        let id = path
195            .file_stem()
196            .and_then(|s| s.to_str())
197            .unwrap_or("unknown")
198            .to_string();
199
200        let file_size_bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
201
202        Self {
203            id,
204            file_path: path,
205            project_path,
206            first_timestamp: None,
207            last_timestamp: None,
208            message_count: 0,
209            total_tokens: 0,
210            input_tokens: 0,
211            output_tokens: 0,
212            cache_creation_tokens: 0,
213            cache_read_tokens: 0,
214            models_used: Vec::new(),
215            file_size_bytes,
216            first_user_message: None,
217            has_subagents: false,
218            duration_seconds: None,
219            branch: None,
220            tool_usage: std::collections::HashMap::new(),
221            tool_token_usage: std::collections::HashMap::new(),
222        }
223    }
224
225    /// Human-readable duration
226    pub fn duration_display(&self) -> String {
227        match self.duration_seconds {
228            Some(s) if s >= 3600 => format!("{}h {}m", s / 3600, (s % 3600) / 60),
229            Some(s) if s >= 60 => format!("{}m {}s", s / 60, s % 60),
230            Some(s) => format!("{}s", s),
231            None => "unknown".to_string(),
232        }
233    }
234
235    /// Human-readable file size
236    pub fn size_display(&self) -> String {
237        let bytes = self.file_size_bytes;
238        if bytes >= 1_000_000 {
239            format!("{:.1} MB", bytes as f64 / 1_000_000.0)
240        } else if bytes >= 1_000 {
241            format!("{:.1} KB", bytes as f64 / 1_000.0)
242        } else {
243            format!("{} B", bytes)
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_token_usage_total() {
254        let usage = TokenUsage {
255            input_tokens: 100,
256            output_tokens: 50,
257            ..Default::default()
258        };
259        assert_eq!(usage.total(), 150);
260    }
261
262    #[test]
263    fn test_session_metadata_duration_display() {
264        let mut meta = SessionMetadata::from_path(PathBuf::from("/test.jsonl"), "test".to_string());
265
266        meta.duration_seconds = Some(90);
267        assert_eq!(meta.duration_display(), "1m 30s");
268
269        meta.duration_seconds = Some(3665);
270        assert_eq!(meta.duration_display(), "1h 1m");
271
272        meta.duration_seconds = Some(45);
273        assert_eq!(meta.duration_display(), "45s");
274    }
275
276    #[test]
277    fn test_session_metadata_size_display() {
278        let mut meta = SessionMetadata::from_path(PathBuf::from("/test.jsonl"), "test".to_string());
279
280        meta.file_size_bytes = 500;
281        assert_eq!(meta.size_display(), "500 B");
282
283        meta.file_size_bytes = 5_000;
284        assert_eq!(meta.size_display(), "5.0 KB");
285
286        meta.file_size_bytes = 2_500_000;
287        assert_eq!(meta.size_display(), "2.5 MB");
288    }
289}
290
291#[cfg(test)]
292mod token_tests {
293    use super::*;
294
295    #[test]
296    fn test_real_claude_token_format_deserialization() {
297        // CRITICAL: Real format from Claude Code v2.1.29+
298        let json = r#"{
299            "input_tokens": 10,
300            "cache_creation_input_tokens": 64100,
301            "cache_read_input_tokens": 19275,
302            "cache_creation": {
303                "ephemeral_5m_input_tokens": 0,
304                "ephemeral_1h_input_tokens": 64100
305            },
306            "output_tokens": 1,
307            "service_tier": "standard"
308        }"#;
309
310        let result: Result<TokenUsage, _> = serde_json::from_str(json);
311
312        assert!(
313            result.is_ok(),
314            "Deserialization MUST succeed for real Claude format. Error: {:?}",
315            result.err()
316        );
317
318        let usage = result.unwrap();
319        assert_eq!(usage.input_tokens, 10);
320        assert_eq!(usage.output_tokens, 1);
321        assert_eq!(usage.cache_read_tokens, 19275);
322        assert_eq!(usage.cache_write_tokens, 64100);
323
324        let total = usage.total();
325        assert_eq!(total, 83386, "Total should be 10+1+19275+64100 = 83386");
326    }
327}