pub mod claude_code_jsonl;
use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsedTurn {
pub timestamp_iso: String,
pub role: TurnRole,
pub content_text: String,
pub tool_calls: Vec<ToolCallSummary>,
pub line_sha256_hex: String,
pub host_session_id: Option<String>,
pub host_turn_index: Option<i64>,
}
impl ParsedTurn {
#[must_use]
pub fn normalized_sha256_hex(&self) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(self.host_session_id.as_deref().unwrap_or("").as_bytes());
h.update([0x00]);
h.update(self.timestamp_iso.as_bytes());
h.update([0x00]);
h.update(self.role.as_str().as_bytes());
h.update([0x00]);
h.update(self.content_text.as_bytes());
h.update([0x00]);
for tc in &self.tool_calls {
h.update(tc.tool.as_bytes());
h.update([0x1f]);
h.update(tc.brief.as_bytes());
h.update([0x1e]);
}
format!("{:x}", h.finalize())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TurnRole {
User,
Assistant,
ToolUse,
ToolResult,
Other,
}
impl TurnRole {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::ToolUse => "tool_use",
Self::ToolResult => "tool_result",
Self::Other => "other",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallSummary {
pub tool: String,
pub brief: String,
}
pub trait TranscriptParser {
fn parse(&self, path: &Path, since_iso: Option<&str>) -> Result<Vec<ParsedTurn>, ParseError>;
}
#[derive(Debug)]
pub enum ParseError {
Read(String),
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Read(msg) => write!(f, "parser: read failed: {msg}"),
}
}
}
impl std::error::Error for ParseError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn turn_role_as_str_covers_every_variant() {
assert_eq!(TurnRole::User.as_str(), "user");
assert_eq!(TurnRole::Assistant.as_str(), "assistant");
assert_eq!(TurnRole::ToolUse.as_str(), "tool_use");
assert_eq!(TurnRole::ToolResult.as_str(), "tool_result");
assert_eq!(TurnRole::Other.as_str(), "other");
}
#[test]
fn turn_role_serde_round_trip_snake_case() {
for (role, wire) in [
(TurnRole::User, "\"user\""),
(TurnRole::Assistant, "\"assistant\""),
(TurnRole::ToolUse, "\"tool_use\""),
(TurnRole::ToolResult, "\"tool_result\""),
(TurnRole::Other, "\"other\""),
] {
let s = serde_json::to_string(&role).unwrap();
assert_eq!(s, wire);
let back: TurnRole = serde_json::from_str(wire).unwrap();
assert_eq!(back, role);
}
}
fn sample_turn() -> ParsedTurn {
ParsedTurn {
timestamp_iso: "2026-05-28T12:00:00Z".to_string(),
role: TurnRole::Assistant,
content_text: "ran a tool".to_string(),
tool_calls: vec![
ToolCallSummary {
tool: "Bash".to_string(),
brief: "list files".to_string(),
},
ToolCallSummary {
tool: "Read".to_string(),
brief: "/a/b.rs".to_string(),
},
],
line_sha256_hex: "ab".repeat(32),
host_session_id: Some("sess-1".to_string()),
host_turn_index: Some(7),
}
}
#[test]
fn normalized_sha256_is_64_hex_chars_and_stable() {
let t = sample_turn();
let a = t.normalized_sha256_hex();
let b = t.normalized_sha256_hex();
assert_eq!(a, b, "normalized hash must be deterministic");
assert_eq!(a.len(), 64);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn normalized_sha256_differs_with_content_and_tool_calls() {
let base = sample_turn();
let base_hash = base.normalized_sha256_hex();
let mut changed_content = base.clone();
changed_content.content_text = "different".to_string();
assert_ne!(changed_content.normalized_sha256_hex(), base_hash);
let mut changed_tool = base.clone();
changed_tool.tool_calls[0].brief = "rm -rf /".to_string();
assert_ne!(changed_tool.normalized_sha256_hex(), base_hash);
let mut no_session = base.clone();
no_session.host_session_id = None;
assert_eq!(no_session.normalized_sha256_hex().len(), 64);
assert_ne!(no_session.normalized_sha256_hex(), base_hash);
}
#[test]
fn parse_error_display_and_error_trait() {
let e = ParseError::Read("boom".to_string());
assert_eq!(e.to_string(), "parser: read failed: boom");
let _: &dyn std::error::Error = &e;
assert!(format!("{e:?}").contains("Read"));
}
#[test]
fn parsed_turn_serde_round_trips() {
let t = sample_turn();
let json = serde_json::to_string(&t).unwrap();
let back: ParsedTurn = serde_json::from_str(&json).unwrap();
assert_eq!(back.timestamp_iso, t.timestamp_iso);
assert_eq!(back.role, t.role);
assert_eq!(back.tool_calls.len(), 2);
assert_eq!(back.host_turn_index, Some(7));
}
}