1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14#[serde(transparent)]
15pub struct SessionId(String);
16
17impl SessionId {
18 pub fn new(id: String) -> Self {
20 Self(id)
21 }
22
23 pub fn as_str(&self) -> &str {
25 &self.0
26 }
27
28 pub fn into_inner(self) -> String {
30 self.0
31 }
32
33 pub fn is_empty(&self) -> bool {
35 self.0.is_empty()
36 }
37
38 pub fn chars(&self) -> std::str::Chars<'_> {
40 self.0.chars()
41 }
42
43 pub fn starts_with(&self, pattern: &str) -> bool {
45 self.0.starts_with(pattern)
46 }
47
48 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
162#[serde(transparent)]
163pub struct ProjectId(String);
164
165impl ProjectId {
166 pub fn new(id: String) -> Self {
168 Self(id)
169 }
170
171 pub fn as_str(&self) -> &str {
173 &self.0
174 }
175
176 pub fn into_inner(self) -> String {
178 self.0
179 }
180
181 pub fn len(&self) -> usize {
183 self.0.len()
184 }
185
186 #[allow(dead_code)]
188 pub fn is_empty(&self) -> bool {
189 self.0.is_empty()
190 }
191
192 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#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
260#[serde(rename_all = "camelCase")]
261pub struct SessionLine {
262 #[serde(default)]
264 pub session_id: Option<String>,
265
266 #[serde(rename = "type")]
268 pub line_type: String,
269
270 #[serde(default)]
272 pub timestamp: Option<DateTime<Utc>>,
273
274 #[serde(default)]
276 pub cwd: Option<String>,
277
278 #[serde(default)]
280 pub git_branch: Option<String>,
281
282 #[serde(default)]
284 pub message: Option<SessionMessage>,
285
286 #[serde(default)]
288 pub model: Option<String>,
289
290 #[serde(default)]
292 pub usage: Option<TokenUsage>,
293
294 #[serde(default)]
296 pub summary: Option<SessionSummary>,
297
298 #[serde(default)]
300 pub parent_session_id: Option<String>,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
305#[serde(rename_all = "camelCase")]
306pub struct SessionMessage {
307 #[serde(default)]
309 pub role: Option<String>,
310
311 #[serde(default)]
313 pub content: Option<Value>,
314
315 #[serde(default)]
317 pub tool_calls: Option<Vec<serde_json::Value>>,
318
319 #[serde(default)]
321 pub tool_results: Option<Vec<serde_json::Value>>,
322
323 #[serde(default)]
325 pub usage: Option<TokenUsage>,
326}
327
328#[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 #[serde(default, alias = "cache_read_input_tokens")]
339 pub cache_read_tokens: u64,
340
341 #[serde(default, alias = "cache_creation_input_tokens")]
343 pub cache_write_tokens: u64,
344}
345
346impl TokenUsage {
347 pub fn total(&self) -> u64 {
355 self.input_tokens + self.output_tokens + self.cache_read_tokens + self.cache_write_tokens
356 }
357}
358
359#[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#[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct SessionMetadata {
409 pub id: SessionId,
411
412 pub file_path: PathBuf,
414
415 pub project_path: ProjectId,
417
418 pub first_timestamp: Option<DateTime<Utc>>,
420
421 pub last_timestamp: Option<DateTime<Utc>>,
423
424 pub message_count: u64,
426
427 pub total_tokens: u64,
429
430 pub input_tokens: u64,
432 pub output_tokens: u64,
433 pub cache_creation_tokens: u64,
434 pub cache_read_tokens: u64,
435
436 pub models_used: Vec<String>,
438
439 #[serde(default)]
443 pub model_segments: Vec<(String, usize)>,
444
445 pub file_size_bytes: u64,
447
448 pub first_user_message: Option<String>,
450
451 pub has_subagents: bool,
453
454 #[serde(default)]
456 pub parent_session_id: Option<String>,
457
458 pub duration_seconds: Option<u64>,
460
461 pub branch: Option<String>,
463
464 pub tool_usage: std::collections::HashMap<String, usize>,
467
468 #[serde(default)]
471 pub tool_token_usage: std::collections::HashMap<String, u64>,
472
473 #[serde(default)]
475 pub source_tool: SourceTool,
476
477 #[serde(default)]
479 pub lines_added: u64,
480
481 #[serde(default)]
483 pub lines_removed: u64,
484}
485
486impl SessionMetadata {
487 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct ConversationMessage {
554 pub role: MessageRole,
556
557 pub content: String,
559
560 pub timestamp: Option<DateTime<Utc>>,
562
563 pub model: Option<String>,
565
566 pub tokens: Option<TokenUsage>,
568
569 #[serde(default)]
571 pub tool_calls: Vec<ToolCall>,
572
573 #[serde(default)]
575 pub tool_results: Vec<ToolResult>,
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct ToolCall {
581 pub name: String,
583
584 pub id: String,
586
587 pub input: serde_json::Value,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct ToolResult {
594 pub tool_call_id: String,
596
597 pub is_error: bool,
599
600 pub content: String,
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize)]
608pub struct SessionContent {
609 pub messages: Vec<ConversationMessage>,
611
612 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 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}