use chrono::{DateTime, Utc};
use rusqlite::types::{FromSql, FromSqlError, ToSql, ToSqlOutput, ValueRef};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::borrow::{Borrow, Cow};
use std::fmt;
use std::ops::{Deref, Index, Range, RangeFrom, RangeFull, RangeTo};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionId(String);
impl SessionId {
pub fn new(id: String) -> Self {
Self(id)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn chars(&self) -> std::str::Chars<'_> {
self.0.chars()
}
pub fn starts_with(&self, pattern: &str) -> bool {
self.0.starts_with(pattern)
}
pub fn len(&self) -> usize {
self.0.len()
}
}
impl From<String> for SessionId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for SessionId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl fmt::Display for SessionId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<str> for SessionId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl PartialEq<str> for SessionId {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for SessionId {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<String> for SessionId {
fn eq(&self, other: &String) -> bool {
&self.0 == other
}
}
impl ToSql for SessionId {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(ToSqlOutput::from(self.0.as_str()))
}
}
impl FromSql for SessionId {
fn column_result(value: ValueRef<'_>) -> Result<Self, FromSqlError> {
value.as_str().map(|s| SessionId::from(s))
}
}
impl Borrow<str> for SessionId {
fn borrow(&self) -> &str {
&self.0
}
}
impl Deref for SessionId {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Index<RangeFull> for SessionId {
type Output = str;
fn index(&self, _index: RangeFull) -> &Self::Output {
&self.0
}
}
impl Index<Range<usize>> for SessionId {
type Output = str;
fn index(&self, index: Range<usize>) -> &Self::Output {
&self.0[index]
}
}
impl Index<RangeFrom<usize>> for SessionId {
type Output = str;
fn index(&self, index: RangeFrom<usize>) -> &Self::Output {
&self.0[index]
}
}
impl Index<RangeTo<usize>> for SessionId {
type Output = str;
fn index(&self, index: RangeTo<usize>) -> &Self::Output {
&self.0[index]
}
}
impl<'a> From<&'a SessionId> for Cow<'a, str> {
fn from(id: &'a SessionId) -> Self {
Cow::Borrowed(id.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ProjectId(String);
impl ProjectId {
pub fn new(id: String) -> Self {
Self(id)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
pub fn len(&self) -> usize {
self.0.len()
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn to_lowercase(&self) -> String {
self.0.to_lowercase()
}
}
impl From<String> for ProjectId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for ProjectId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl fmt::Display for ProjectId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl AsRef<str> for ProjectId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl ToSql for ProjectId {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(ToSqlOutput::from(self.0.as_str()))
}
}
impl FromSql for ProjectId {
fn column_result(value: ValueRef<'_>) -> Result<Self, FromSqlError> {
value.as_str().map(|s| ProjectId::from(s))
}
}
impl Deref for ProjectId {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'a> From<&'a ProjectId> for Cow<'a, str> {
fn from(id: &'a ProjectId) -> Self {
Cow::Borrowed(id.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MessageRole {
User,
Assistant,
System,
}
impl Default for MessageRole {
fn default() -> Self {
Self::User
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionLine {
#[serde(default)]
pub session_id: Option<String>,
#[serde(rename = "type")]
pub line_type: String,
#[serde(default)]
pub timestamp: Option<DateTime<Utc>>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub git_branch: Option<String>,
#[serde(default)]
pub message: Option<SessionMessage>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub usage: Option<TokenUsage>,
#[serde(default)]
pub summary: Option<SessionSummary>,
#[serde(default)]
pub parent_session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionMessage {
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub content: Option<Value>,
#[serde(default)]
pub tool_calls: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub tool_results: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub usage: Option<TokenUsage>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TokenUsage {
#[serde(default)]
pub input_tokens: u64,
#[serde(default)]
pub output_tokens: u64,
#[serde(default, alias = "cache_read_input_tokens")]
pub cache_read_tokens: u64,
#[serde(default, alias = "cache_creation_input_tokens")]
pub cache_write_tokens: u64,
}
impl TokenUsage {
pub fn total(&self) -> u64 {
self.input_tokens + self.output_tokens + self.cache_read_tokens + self.cache_write_tokens
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSummary {
#[serde(default)]
pub total_tokens: u64,
#[serde(default)]
pub total_input_tokens: u64,
#[serde(default)]
pub total_output_tokens: u64,
#[serde(default)]
pub total_cache_read_tokens: u64,
#[serde(default)]
pub total_cache_write_tokens: u64,
#[serde(default)]
pub message_count: u64,
#[serde(default)]
pub duration_seconds: Option<u64>,
#[serde(default)]
pub models_used: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMetadata {
pub id: SessionId,
pub file_path: PathBuf,
pub project_path: ProjectId,
pub first_timestamp: Option<DateTime<Utc>>,
pub last_timestamp: Option<DateTime<Utc>>,
pub message_count: u64,
pub total_tokens: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_creation_tokens: u64,
pub cache_read_tokens: u64,
pub models_used: Vec<String>,
pub file_size_bytes: u64,
pub first_user_message: Option<String>,
pub has_subagents: bool,
pub duration_seconds: Option<u64>,
pub branch: Option<String>,
pub tool_usage: std::collections::HashMap<String, usize>,
}
impl SessionMetadata {
pub fn from_path(path: PathBuf, project_path: ProjectId) -> Self {
let id = SessionId::new(
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string(),
);
let file_size_bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
Self {
id,
file_path: path,
project_path,
first_timestamp: None,
last_timestamp: None,
message_count: 0,
total_tokens: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
models_used: Vec::new(),
file_size_bytes,
first_user_message: None,
has_subagents: false,
duration_seconds: None,
branch: None,
tool_usage: std::collections::HashMap::new(),
}
}
pub fn duration_display(&self) -> String {
match self.duration_seconds {
Some(s) if s >= 3600 => format!("{}h {}m", s / 3600, (s % 3600) / 60),
Some(s) if s >= 60 => format!("{}m {}s", s / 60, s % 60),
Some(s) => format!("{}s", s),
None => "unknown".to_string(),
}
}
pub fn size_display(&self) -> String {
let bytes = self.file_size_bytes;
if bytes >= 1_000_000 {
format!("{:.1} MB", bytes as f64 / 1_000_000.0)
} else if bytes >= 1_000 {
format!("{:.1} KB", bytes as f64 / 1_000.0)
} else {
format!("{} B", bytes)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationMessage {
pub role: MessageRole,
pub content: String,
pub timestamp: Option<DateTime<Utc>>,
pub model: Option<String>,
pub tokens: Option<TokenUsage>,
#[serde(default)]
pub tool_calls: Vec<ToolCall>,
#[serde(default)]
pub tool_results: Vec<ToolResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub name: String,
pub id: String,
pub input: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub tool_call_id: String,
pub is_error: bool,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionContent {
pub messages: Vec<ConversationMessage>,
pub metadata: SessionMetadata,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_token_usage_total() {
let usage = TokenUsage {
input_tokens: 100,
output_tokens: 50,
..Default::default()
};
assert_eq!(usage.total(), 150);
}
#[test]
fn test_session_metadata_duration_display() {
let mut meta =
SessionMetadata::from_path(PathBuf::from("/test.jsonl"), ProjectId::from("test"));
meta.duration_seconds = Some(90);
assert_eq!(meta.duration_display(), "1m 30s");
meta.duration_seconds = Some(3665);
assert_eq!(meta.duration_display(), "1h 1m");
meta.duration_seconds = Some(45);
assert_eq!(meta.duration_display(), "45s");
}
#[test]
fn test_session_metadata_size_display() {
let mut meta =
SessionMetadata::from_path(PathBuf::from("/test.jsonl"), ProjectId::from("test"));
meta.file_size_bytes = 500;
assert_eq!(meta.size_display(), "500 B");
meta.file_size_bytes = 5_000;
assert_eq!(meta.size_display(), "5.0 KB");
meta.file_size_bytes = 2_500_000;
assert_eq!(meta.size_display(), "2.5 MB");
}
}
#[cfg(test)]
mod token_tests {
use super::*;
#[test]
fn test_real_claude_token_format_deserialization() {
let json = r#"{
"input_tokens": 10,
"cache_creation_input_tokens": 64100,
"cache_read_input_tokens": 19275,
"cache_creation": {
"ephemeral_5m_input_tokens": 0,
"ephemeral_1h_input_tokens": 64100
},
"output_tokens": 1,
"service_tier": "standard"
}"#;
let result: Result<TokenUsage, _> = serde_json::from_str(json);
assert!(
result.is_ok(),
"Deserialization MUST succeed for real Claude format. Error: {:?}",
result.err()
);
let usage = result.unwrap();
assert_eq!(usage.input_tokens, 10);
assert_eq!(usage.output_tokens, 1);
assert_eq!(usage.cache_read_tokens, 19275);
assert_eq!(usage.cache_write_tokens, 64100);
let total = usage.total();
assert_eq!(total, 83386, "Total should be 10+1+19275+64100 = 83386");
}
}