Skip to main content

ccboard_core/models/
session.rs

1//! Session models for JSONL session files
2
3use chrono::{DateTime, Utc};
4use rusqlite::types::{FromSql, FromSqlError, ToSql, ToSqlOutput, ValueRef};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::borrow::{Borrow, Cow};
8use std::fmt;
9use std::ops::{Deref, Index, Range, RangeFrom, RangeFull, RangeTo};
10use std::path::PathBuf;
11
12/// Newtype for Session ID - zero-cost type safety
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(transparent)]
15pub struct SessionId(String);
16
17impl SessionId {
18    /// Create a new SessionId
19    pub fn new(id: String) -> Self {
20        Self(id)
21    }
22
23    /// Get reference to inner string
24    pub fn as_str(&self) -> &str {
25        &self.0
26    }
27
28    /// Extract inner String, consuming self
29    pub fn into_inner(self) -> String {
30        self.0
31    }
32
33    /// Check if the session ID is empty
34    pub fn is_empty(&self) -> bool {
35        self.0.is_empty()
36    }
37
38    /// Get an iterator over the characters
39    pub fn chars(&self) -> std::str::Chars<'_> {
40        self.0.chars()
41    }
42
43    /// Check if the session ID starts with a given pattern
44    pub fn starts_with(&self, pattern: &str) -> bool {
45        self.0.starts_with(pattern)
46    }
47
48    /// Get the length of the session ID
49    pub fn len(&self) -> usize {
50        self.0.len()
51    }
52}
53
54impl From<String> for SessionId {
55    fn from(s: String) -> Self {
56        Self(s)
57    }
58}
59
60impl From<&str> for SessionId {
61    fn from(s: &str) -> Self {
62        Self(s.to_string())
63    }
64}
65
66impl fmt::Display for SessionId {
67    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
68        write!(f, "{}", self.0)
69    }
70}
71
72impl AsRef<str> for SessionId {
73    fn as_ref(&self) -> &str {
74        &self.0
75    }
76}
77
78impl PartialEq<str> for SessionId {
79    fn eq(&self, other: &str) -> bool {
80        self.0 == other
81    }
82}
83
84impl PartialEq<&str> for SessionId {
85    fn eq(&self, other: &&str) -> bool {
86        self.0 == *other
87    }
88}
89
90impl PartialEq<String> for SessionId {
91    fn eq(&self, other: &String) -> bool {
92        &self.0 == other
93    }
94}
95
96impl ToSql for SessionId {
97    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
98        Ok(ToSqlOutput::from(self.0.as_str()))
99    }
100}
101
102impl FromSql for SessionId {
103    fn column_result(value: ValueRef<'_>) -> Result<Self, FromSqlError> {
104        value.as_str().map(SessionId::from)
105    }
106}
107
108impl Borrow<str> for SessionId {
109    fn borrow(&self) -> &str {
110        &self.0
111    }
112}
113
114impl Deref for SessionId {
115    type Target = str;
116
117    fn deref(&self) -> &Self::Target {
118        &self.0
119    }
120}
121
122impl Index<RangeFull> for SessionId {
123    type Output = str;
124
125    fn index(&self, _index: RangeFull) -> &Self::Output {
126        &self.0
127    }
128}
129
130impl Index<Range<usize>> for SessionId {
131    type Output = str;
132
133    fn index(&self, index: Range<usize>) -> &Self::Output {
134        &self.0[index]
135    }
136}
137
138impl Index<RangeFrom<usize>> for SessionId {
139    type Output = str;
140
141    fn index(&self, index: RangeFrom<usize>) -> &Self::Output {
142        &self.0[index]
143    }
144}
145
146impl Index<RangeTo<usize>> for SessionId {
147    type Output = str;
148
149    fn index(&self, index: RangeTo<usize>) -> &Self::Output {
150        &self.0[index]
151    }
152}
153
154impl<'a> From<&'a SessionId> for Cow<'a, str> {
155    fn from(id: &'a SessionId) -> Self {
156        Cow::Borrowed(id.as_str())
157    }
158}
159
160/// Newtype for Project ID - zero-cost type safety
161#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
162#[serde(transparent)]
163pub struct ProjectId(String);
164
165impl ProjectId {
166    /// Create a new ProjectId
167    pub fn new(id: String) -> Self {
168        Self(id)
169    }
170
171    /// Get reference to inner string
172    pub fn as_str(&self) -> &str {
173        &self.0
174    }
175
176    /// Extract inner String, consuming self
177    pub fn into_inner(self) -> String {
178        self.0
179    }
180
181    /// Get the length of the project ID
182    pub fn len(&self) -> usize {
183        self.0.len()
184    }
185
186    /// Check if the project ID is empty
187    #[allow(dead_code)]
188    pub fn is_empty(&self) -> bool {
189        self.0.is_empty()
190    }
191
192    /// Convert to lowercase
193    pub fn to_lowercase(&self) -> String {
194        self.0.to_lowercase()
195    }
196}
197
198impl From<String> for ProjectId {
199    fn from(s: String) -> Self {
200        Self(s)
201    }
202}
203
204impl From<&str> for ProjectId {
205    fn from(s: &str) -> Self {
206        Self(s.to_string())
207    }
208}
209
210impl fmt::Display for ProjectId {
211    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
212        write!(f, "{}", self.0)
213    }
214}
215
216impl AsRef<str> for ProjectId {
217    fn as_ref(&self) -> &str {
218        &self.0
219    }
220}
221
222impl ToSql for ProjectId {
223    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
224        Ok(ToSqlOutput::from(self.0.as_str()))
225    }
226}
227
228impl FromSql for ProjectId {
229    fn column_result(value: ValueRef<'_>) -> Result<Self, FromSqlError> {
230        value.as_str().map(ProjectId::from)
231    }
232}
233
234impl Deref for ProjectId {
235    type Target = str;
236
237    fn deref(&self) -> &Self::Target {
238        &self.0
239    }
240}
241
242impl<'a> From<&'a ProjectId> for Cow<'a, str> {
243    fn from(id: &'a ProjectId) -> Self {
244        Cow::Borrowed(id.as_str())
245    }
246}
247
248/// Message role in conversation
249#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(rename_all = "lowercase")]
251pub enum MessageRole {
252    #[default]
253    User,
254    Assistant,
255    System,
256}
257
258/// A single line from a session JSONL file
259#[derive(Debug, Clone, Default, Serialize, Deserialize)]
260#[serde(rename_all = "camelCase")]
261pub struct SessionLine {
262    /// Session ID
263    #[serde(default)]
264    pub session_id: Option<String>,
265
266    /// Event type: "user", "assistant", "file-history-snapshot", "session_end", etc.
267    #[serde(rename = "type")]
268    pub line_type: String,
269
270    /// Timestamp of the event
271    #[serde(default)]
272    pub timestamp: Option<DateTime<Utc>>,
273
274    /// Current working directory
275    #[serde(default)]
276    pub cwd: Option<String>,
277
278    /// Git branch (if available)
279    #[serde(default)]
280    pub git_branch: Option<String>,
281
282    /// Message content (for user/assistant types)
283    #[serde(default)]
284    pub message: Option<SessionMessage>,
285
286    /// Model used (for assistant messages)
287    #[serde(default)]
288    pub model: Option<String>,
289
290    /// Token usage for this message
291    #[serde(default)]
292    pub usage: Option<TokenUsage>,
293
294    /// Summary data (for session_end type)
295    #[serde(default)]
296    pub summary: Option<SessionSummary>,
297
298    /// Parent session ID (for subagents)
299    #[serde(default)]
300    pub parent_session_id: Option<String>,
301}
302
303/// Message content in a session
304#[derive(Debug, Clone, Serialize, Deserialize)]
305#[serde(rename_all = "camelCase")]
306pub struct SessionMessage {
307    /// Role: "user" or "assistant"
308    #[serde(default)]
309    pub role: Option<String>,
310
311    /// Text content (can be String or Array of content blocks in newer Claude Code versions)
312    #[serde(default)]
313    pub content: Option<Value>,
314
315    /// Tool calls made
316    #[serde(default)]
317    pub tool_calls: Option<Vec<serde_json::Value>>,
318
319    /// Tool results
320    #[serde(default)]
321    pub tool_results: Option<Vec<serde_json::Value>>,
322
323    /// Token usage (for assistant messages)
324    #[serde(default)]
325    pub usage: Option<TokenUsage>,
326}
327
328/// Token usage for a message
329#[derive(Debug, Clone, Default, Serialize, Deserialize)]
330pub struct TokenUsage {
331    #[serde(default)]
332    pub input_tokens: u64,
333
334    #[serde(default)]
335    pub output_tokens: u64,
336
337    /// Cache read tokens (from cache_read_input_tokens in JSONL)
338    #[serde(default, alias = "cache_read_input_tokens")]
339    pub cache_read_tokens: u64,
340
341    /// Cache creation tokens (from cache_creation_input_tokens in JSONL)
342    #[serde(default, alias = "cache_creation_input_tokens")]
343    pub cache_write_tokens: u64,
344}
345
346impl TokenUsage {
347    /// Total tokens including cache reads and writes
348    ///
349    /// This is the sum of all token types:
350    /// - input_tokens: Regular input tokens (not cached)
351    /// - output_tokens: Generated tokens
352    /// - cache_read_tokens: Tokens read from cache (cache hits)
353    /// - cache_write_tokens: Tokens written to cache (cache creation)
354    pub fn total(&self) -> u64 {
355        self.input_tokens + self.output_tokens + self.cache_read_tokens + self.cache_write_tokens
356    }
357}
358
359/// Summary at session end
360#[derive(Debug, Clone, Default, Serialize, Deserialize)]
361#[serde(rename_all = "camelCase")]
362pub struct SessionSummary {
363    #[serde(default)]
364    pub total_tokens: u64,
365    #[serde(default)]
366    pub total_input_tokens: u64,
367    #[serde(default)]
368    pub total_output_tokens: u64,
369    #[serde(default)]
370    pub total_cache_read_tokens: u64,
371    #[serde(default)]
372    pub total_cache_write_tokens: u64,
373    #[serde(default)]
374    pub message_count: u64,
375    #[serde(default)]
376    pub duration_seconds: Option<u64>,
377    #[serde(default)]
378    pub models_used: Option<Vec<String>>,
379}
380
381/// Origin tool that produced this session
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
383#[serde(rename_all = "lowercase")]
384pub enum SourceTool {
385    #[default]
386    ClaudeCode,
387    Cursor,
388    Codex,
389    OpenCode,
390}
391
392impl SourceTool {
393    /// Short badge label shown in TUI (e.g. "[Cu]")
394    pub fn badge(&self) -> &'static str {
395        match self {
396            SourceTool::ClaudeCode => "",
397            SourceTool::Cursor => "[Cu]",
398            SourceTool::Codex => "[Cx]",
399            SourceTool::OpenCode => "[Oc]",
400        }
401    }
402}
403
404/// Metadata extracted from a session without full parse
405///
406/// Created by streaming the JSONL until session_end event.
407#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct SessionMetadata {
409    /// Session ID (from filename or content)
410    pub id: SessionId,
411
412    /// Full path to the JSONL file
413    pub file_path: PathBuf,
414
415    /// Project path (extracted from directory structure)
416    pub project_path: ProjectId,
417
418    /// First timestamp in session
419    pub first_timestamp: Option<DateTime<Utc>>,
420
421    /// Last timestamp in session
422    pub last_timestamp: Option<DateTime<Utc>>,
423
424    /// Total message count (from summary or counted)
425    pub message_count: u64,
426
427    /// Total tokens (from summary)
428    pub total_tokens: u64,
429
430    /// Token breakdown for precise pricing
431    pub input_tokens: u64,
432    pub output_tokens: u64,
433    pub cache_creation_tokens: u64,
434    pub cache_read_tokens: u64,
435
436    /// Models used in this session
437    pub models_used: Vec<String>,
438
439    /// Ordered model segments: (model_id, assistant_message_count).
440    /// Captures mid-session model switches, e.g. Opus → Sonnet → Haiku.
441    /// Empty for sessions parsed before this field was added.
442    #[serde(default)]
443    pub model_segments: Vec<(String, usize)>,
444
445    /// File size in bytes
446    pub file_size_bytes: u64,
447
448    /// Preview of first user message (truncated to 200 chars)
449    pub first_user_message: Option<String>,
450
451    /// Whether this session spawned subagents (detected when another session references this one)
452    pub has_subagents: bool,
453
454    /// Parent session ID if this session is a subagent (derived from JSONL parentSessionId field)
455    #[serde(default)]
456    pub parent_session_id: Option<String>,
457
458    /// Duration in seconds (from summary)
459    pub duration_seconds: Option<u64>,
460
461    /// Git branch name (normalized, extracted from first gitBranch in session)
462    pub branch: Option<String>,
463
464    /// Tool usage statistics: tool name -> call count
465    /// Extracted from tool_calls in assistant messages during session scan
466    pub tool_usage: std::collections::HashMap<String, usize>,
467
468    /// Per-tool token attribution: tool name -> tokens
469    /// Proportionally distributed from message-level token counts
470    #[serde(default)]
471    pub tool_token_usage: std::collections::HashMap<String, u64>,
472
473    /// Which AI coding tool produced this session
474    #[serde(default)]
475    pub source_tool: SourceTool,
476
477    /// Lines added in this session (from Edit/Write tool inputs)
478    #[serde(default)]
479    pub lines_added: u64,
480
481    /// Lines removed in this session (from Edit old_string)
482    #[serde(default)]
483    pub lines_removed: u64,
484}
485
486impl SessionMetadata {
487    /// Create a minimal metadata from just file path
488    pub fn from_path(path: PathBuf, project_path: ProjectId) -> Self {
489        let id = SessionId::new(
490            path.file_stem()
491                .and_then(|s| s.to_str())
492                .unwrap_or("unknown")
493                .to_string(),
494        );
495
496        let file_size_bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
497
498        Self {
499            id,
500            file_path: path,
501            project_path,
502            first_timestamp: None,
503            last_timestamp: None,
504            message_count: 0,
505            total_tokens: 0,
506            input_tokens: 0,
507            output_tokens: 0,
508            cache_creation_tokens: 0,
509            cache_read_tokens: 0,
510            models_used: Vec::new(),
511            model_segments: Vec::new(),
512            file_size_bytes,
513            first_user_message: None,
514            has_subagents: false,
515            parent_session_id: None,
516            duration_seconds: None,
517            branch: None,
518            tool_usage: std::collections::HashMap::new(),
519            tool_token_usage: std::collections::HashMap::new(),
520            source_tool: SourceTool::ClaudeCode,
521            lines_added: 0,
522            lines_removed: 0,
523        }
524    }
525
526    /// Human-readable duration
527    pub fn duration_display(&self) -> String {
528        match self.duration_seconds {
529            Some(s) if s >= 3600 => format!("{}h {}m", s / 3600, (s % 3600) / 60),
530            Some(s) if s >= 60 => format!("{}m {}s", s / 60, s % 60),
531            Some(s) => format!("{}s", s),
532            None => "unknown".to_string(),
533        }
534    }
535
536    /// Human-readable file size
537    pub fn size_display(&self) -> String {
538        let bytes = self.file_size_bytes;
539        if bytes >= 1_000_000 {
540            format!("{:.1} MB", bytes as f64 / 1_000_000.0)
541        } else if bytes >= 1_000 {
542            format!("{:.1} KB", bytes as f64 / 1_000.0)
543        } else {
544            format!("{} B", bytes)
545        }
546    }
547}
548
549/// A single conversation message extracted from session JSONL
550///
551/// Simplified representation for display in conversation viewer.
552#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct ConversationMessage {
554    /// Message role (User, Assistant, System)
555    pub role: MessageRole,
556
557    /// Text content (extracted from SessionLine.message.content)
558    pub content: String,
559
560    /// Timestamp when message was sent
561    pub timestamp: Option<DateTime<Utc>>,
562
563    /// Model used (for assistant messages)
564    pub model: Option<String>,
565
566    /// Token usage (for assistant messages)
567    pub tokens: Option<TokenUsage>,
568
569    /// Tool calls made in this message (if any)
570    #[serde(default)]
571    pub tool_calls: Vec<ToolCall>,
572
573    /// Tool results received (if any)
574    #[serde(default)]
575    pub tool_results: Vec<ToolResult>,
576}
577
578/// A tool call made by the assistant
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct ToolCall {
581    /// Tool name (e.g., "Read", "Bash", "Edit")
582    pub name: String,
583
584    /// Tool call ID for matching with results
585    pub id: String,
586
587    /// Input parameters as JSON
588    pub input: serde_json::Value,
589}
590
591/// Result of a tool call execution
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct ToolResult {
594    /// Tool call ID this result corresponds to
595    pub tool_call_id: String,
596
597    /// Whether the tool succeeded
598    pub is_error: bool,
599
600    /// Output content
601    pub content: String,
602}
603
604/// Full session content with metadata + messages
605///
606/// Returned by SessionContentParser for lazy-loaded session display.
607#[derive(Debug, Clone, Serialize, Deserialize)]
608pub struct SessionContent {
609    /// Messages in chronological order
610    pub messages: Vec<ConversationMessage>,
611
612    /// Session metadata (from cache or index)
613    pub metadata: SessionMetadata,
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn test_token_usage_total() {
622        let usage = TokenUsage {
623            input_tokens: 100,
624            output_tokens: 50,
625            ..Default::default()
626        };
627        assert_eq!(usage.total(), 150);
628    }
629
630    #[test]
631    fn test_session_metadata_duration_display() {
632        let mut meta =
633            SessionMetadata::from_path(PathBuf::from("/test.jsonl"), ProjectId::from("test"));
634
635        meta.duration_seconds = Some(90);
636        assert_eq!(meta.duration_display(), "1m 30s");
637
638        meta.duration_seconds = Some(3665);
639        assert_eq!(meta.duration_display(), "1h 1m");
640
641        meta.duration_seconds = Some(45);
642        assert_eq!(meta.duration_display(), "45s");
643    }
644
645    #[test]
646    fn test_session_metadata_size_display() {
647        let mut meta =
648            SessionMetadata::from_path(PathBuf::from("/test.jsonl"), ProjectId::from("test"));
649
650        meta.file_size_bytes = 500;
651        assert_eq!(meta.size_display(), "500 B");
652
653        meta.file_size_bytes = 5_000;
654        assert_eq!(meta.size_display(), "5.0 KB");
655
656        meta.file_size_bytes = 2_500_000;
657        assert_eq!(meta.size_display(), "2.5 MB");
658    }
659}
660
661#[cfg(test)]
662mod token_tests {
663    use super::*;
664
665    #[test]
666    fn test_real_claude_token_format_deserialization() {
667        // CRITICAL: Real format from Claude Code v2.1.29+
668        let json = r#"{
669            "input_tokens": 10,
670            "cache_creation_input_tokens": 64100,
671            "cache_read_input_tokens": 19275,
672            "cache_creation": {
673                "ephemeral_5m_input_tokens": 0,
674                "ephemeral_1h_input_tokens": 64100
675            },
676            "output_tokens": 1,
677            "service_tier": "standard"
678        }"#;
679
680        let result: Result<TokenUsage, _> = serde_json::from_str(json);
681
682        assert!(
683            result.is_ok(),
684            "Deserialization MUST succeed for real Claude format. Error: {:?}",
685            result.err()
686        );
687
688        let usage = result.unwrap();
689        assert_eq!(usage.input_tokens, 10);
690        assert_eq!(usage.output_tokens, 1);
691        assert_eq!(usage.cache_read_tokens, 19275);
692        assert_eq!(usage.cache_write_tokens, 64100);
693
694        let total = usage.total();
695        assert_eq!(total, 83386, "Total should be 10+1+19275+64100 = 83386");
696    }
697}