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
186impl SessionMetadata {
187    /// Create a minimal metadata from just file path
188    pub fn from_path(path: PathBuf, project_path: String) -> Self {
189        let id = path
190            .file_stem()
191            .and_then(|s| s.to_str())
192            .unwrap_or("unknown")
193            .to_string();
194
195        let file_size_bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
196
197        Self {
198            id,
199            file_path: path,
200            project_path,
201            first_timestamp: None,
202            last_timestamp: None,
203            message_count: 0,
204            total_tokens: 0,
205            input_tokens: 0,
206            output_tokens: 0,
207            cache_creation_tokens: 0,
208            cache_read_tokens: 0,
209            models_used: Vec::new(),
210            file_size_bytes,
211            first_user_message: None,
212            has_subagents: false,
213            duration_seconds: None,
214            branch: None,
215            tool_usage: std::collections::HashMap::new(),
216        }
217    }
218
219    /// Human-readable duration
220    pub fn duration_display(&self) -> String {
221        match self.duration_seconds {
222            Some(s) if s >= 3600 => format!("{}h {}m", s / 3600, (s % 3600) / 60),
223            Some(s) if s >= 60 => format!("{}m {}s", s / 60, s % 60),
224            Some(s) => format!("{}s", s),
225            None => "unknown".to_string(),
226        }
227    }
228
229    /// Human-readable file size
230    pub fn size_display(&self) -> String {
231        let bytes = self.file_size_bytes;
232        if bytes >= 1_000_000 {
233            format!("{:.1} MB", bytes as f64 / 1_000_000.0)
234        } else if bytes >= 1_000 {
235            format!("{:.1} KB", bytes as f64 / 1_000.0)
236        } else {
237            format!("{} B", bytes)
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_token_usage_total() {
248        let usage = TokenUsage {
249            input_tokens: 100,
250            output_tokens: 50,
251            ..Default::default()
252        };
253        assert_eq!(usage.total(), 150);
254    }
255
256    #[test]
257    fn test_session_metadata_duration_display() {
258        let mut meta = SessionMetadata::from_path(PathBuf::from("/test.jsonl"), "test".to_string());
259
260        meta.duration_seconds = Some(90);
261        assert_eq!(meta.duration_display(), "1m 30s");
262
263        meta.duration_seconds = Some(3665);
264        assert_eq!(meta.duration_display(), "1h 1m");
265
266        meta.duration_seconds = Some(45);
267        assert_eq!(meta.duration_display(), "45s");
268    }
269
270    #[test]
271    fn test_session_metadata_size_display() {
272        let mut meta = SessionMetadata::from_path(PathBuf::from("/test.jsonl"), "test".to_string());
273
274        meta.file_size_bytes = 500;
275        assert_eq!(meta.size_display(), "500 B");
276
277        meta.file_size_bytes = 5_000;
278        assert_eq!(meta.size_display(), "5.0 KB");
279
280        meta.file_size_bytes = 2_500_000;
281        assert_eq!(meta.size_display(), "2.5 MB");
282    }
283}
284
285#[cfg(test)]
286mod token_tests {
287    use super::*;
288
289    #[test]
290    fn test_real_claude_token_format_deserialization() {
291        // CRITICAL: Real format from Claude Code v2.1.29+
292        let json = r#"{
293            "input_tokens": 10,
294            "cache_creation_input_tokens": 64100,
295            "cache_read_input_tokens": 19275,
296            "cache_creation": {
297                "ephemeral_5m_input_tokens": 0,
298                "ephemeral_1h_input_tokens": 64100
299            },
300            "output_tokens": 1,
301            "service_tier": "standard"
302        }"#;
303
304        let result: Result<TokenUsage, _> = serde_json::from_str(json);
305
306        assert!(
307            result.is_ok(),
308            "Deserialization MUST succeed for real Claude format. Error: {:?}",
309            result.err()
310        );
311
312        let usage = result.unwrap();
313        assert_eq!(usage.input_tokens, 10);
314        assert_eq!(usage.output_tokens, 1);
315        assert_eq!(usage.cache_read_tokens, 19275);
316        assert_eq!(usage.cache_write_tokens, 64100);
317
318        let total = usage.total();
319        assert_eq!(total, 83386, "Total should be 10+1+19275+64100 = 83386");
320    }
321}