Skip to main content

normalize_chat_sessions/
session.rs

1//! Unified session types for format-agnostic session representation.
2//!
3//! These types represent parsed session data in a normalized format,
4//! allowing consumers to work with sessions regardless of their source
5//! format (Claude Code, Gemini CLI, Codex, etc.).
6
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9
10/// A parsed session in unified format.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13pub struct Session {
14    /// Path to the original session file.
15    pub path: PathBuf,
16    /// Name of the format that parsed this session.
17    pub format: String,
18    /// Session metadata (IDs, timestamps, provider info).
19    pub metadata: SessionMetadata,
20    /// Conversation turns (request/response pairs).
21    pub turns: Vec<Turn>,
22}
23
24/// Session metadata extracted from the log.
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27pub struct SessionMetadata {
28    /// Session identifier (format-specific).
29    pub session_id: Option<String>,
30    /// Session start timestamp.
31    pub timestamp: Option<String>,
32    /// LLM provider (e.g., "anthropic", "google", "openai").
33    pub provider: Option<String>,
34    /// Model identifier.
35    pub model: Option<String>,
36    /// Project path or context.
37    pub project: Option<String>,
38}
39
40/// A single turn in the conversation (typically one user message + assistant response).
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
42#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
43pub struct Turn {
44    /// Messages in this turn.
45    pub messages: Vec<Message>,
46    /// Token usage for this turn (if available).
47    pub token_usage: Option<TokenUsage>,
48}
49
50/// A message from a participant.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53pub struct Message {
54    /// Who sent this message.
55    pub role: Role,
56    /// Message content blocks.
57    pub content: Vec<ContentBlock>,
58    /// Timestamp of this message (if available).
59    pub timestamp: Option<String>,
60}
61
62/// Message sender role.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
65#[serde(rename_all = "lowercase")]
66pub enum Role {
67    User,
68    Assistant,
69    System,
70}
71
72/// A content block within a message.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
75#[serde(tag = "type", rename_all = "snake_case")]
76pub enum ContentBlock {
77    /// Plain text content.
78    Text { text: String },
79    /// Tool invocation by the assistant.
80    ToolUse {
81        id: String,
82        name: String,
83        input: serde_json::Value,
84    },
85    /// Result of a tool invocation.
86    ToolResult {
87        tool_use_id: String,
88        content: String,
89        is_error: bool,
90    },
91    /// Thinking/reasoning content (e.g., Claude's extended thinking).
92    Thinking { text: String },
93}
94
95/// Token usage for an API call.
96#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
98pub struct TokenUsage {
99    /// Input tokens (prompt).
100    pub input: u64,
101    /// Output tokens (completion).
102    pub output: u64,
103    /// Tokens read from cache.
104    pub cache_read: Option<u64>,
105    /// Tokens written to cache.
106    pub cache_create: Option<u64>,
107}
108
109impl Session {
110    /// Create a new empty session.
111    pub fn new(path: PathBuf, format: impl Into<String>) -> Self {
112        Self {
113            path,
114            format: format.into(),
115            metadata: SessionMetadata::default(),
116            turns: Vec::new(),
117        }
118    }
119
120    /// Total number of messages across all turns.
121    pub fn message_count(&self) -> usize {
122        self.turns.iter().map(|t| t.messages.len()).sum()
123    }
124
125    /// Count messages by role.
126    pub fn messages_by_role(&self, role: Role) -> usize {
127        self.turns
128            .iter()
129            .flat_map(|t| &t.messages)
130            .filter(|m| m.role == role)
131            .count()
132    }
133
134    /// Iterate over all tool use blocks.
135    pub fn tool_uses(&self) -> impl Iterator<Item = (&str, &serde_json::Value)> {
136        self.turns.iter().flat_map(|t| &t.messages).flat_map(|m| {
137            m.content.iter().filter_map(|block| match block {
138                ContentBlock::ToolUse { name, input, .. } => Some((name.as_str(), input)),
139                _ => None,
140            })
141        })
142    }
143
144    /// Iterate over all tool results.
145    pub fn tool_results(&self) -> impl Iterator<Item = (&str, bool)> {
146        self.turns.iter().flat_map(|t| &t.messages).flat_map(|m| {
147            m.content.iter().filter_map(|block| match block {
148                ContentBlock::ToolResult {
149                    content, is_error, ..
150                } => Some((content.as_str(), *is_error)),
151                _ => None,
152            })
153        })
154    }
155
156    /// Total token usage across all turns.
157    pub fn total_tokens(&self) -> TokenUsage {
158        let mut total = TokenUsage::default();
159        for turn in &self.turns {
160            if let Some(usage) = &turn.token_usage {
161                total.input += usage.input;
162                total.output += usage.output;
163                if let Some(cache_read) = usage.cache_read {
164                    *total.cache_read.get_or_insert(0) += cache_read;
165                }
166                if let Some(cache_create) = usage.cache_create {
167                    *total.cache_create.get_or_insert(0) += cache_create;
168                }
169            }
170        }
171        total
172    }
173}
174
175impl std::fmt::Display for Role {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        match self {
178            Role::User => write!(f, "user"),
179            Role::Assistant => write!(f, "assistant"),
180            Role::System => write!(f, "system"),
181        }
182    }
183}