//! [`ClaudeTranscriptInspector`] — a best-effort [`SessionInspector`] adapter //! (CONTEXT §T6) for the **Claude Code** CLI. //! //! Claude Code records each conversation as a JSONL transcript under the user's //! home directory: //! //! ```text //! /.claude/projects//.jsonl //! ``` //! //! where `` is the agent's working directory with its path //! separators flattened to `-` (see [`encode_cwd`]). Each line of the `.jsonl` //! is one JSON message object. //! //! This adapter is the **only** place that knows the Claude transcript shape: //! it reads the file through the injected [`FileSystem`] port, extracts a //! best-effort `last_topic` (the last `user` message text) and `token_count` //! (the cumulative `usage` input+output tokens of `assistant` messages), and //! returns the CLI-agnostic [`ConversationDetails`]. No Claude-specific type //! ever leaves this module. Anything it cannot parse is skipped, never fatal. use std::sync::Arc; use async_trait::async_trait; use serde::Deserialize; use domain::ports::{ConversationDetails, FileSystem, FsError, InspectError, SessionInspector}; use domain::profile::AgentProfile; use domain::project::ProjectPath; use super::claude_paths::{claude_transcript_path, supports_claude}; /// Max byte length of the extracted `last_topic` before it is truncated. /// /// Kept small: the topic only labels a resume popup, it is not the message. const TOPIC_MAX_LEN: usize = 120; /// Reads Claude Code conversation transcripts. /// /// Composes a [`FileSystem`] port (so it stays Tauri- and OS-agnostic and is /// trivially testable) plus the base directory that stands in for the user's /// home (`/.claude/projects/`). The composition root passes the real /// `$HOME`; tests pass a temp directory via [`with_base_dir`](Self::with_base_dir). #[derive(Clone)] pub struct ClaudeTranscriptInspector { fs: Arc, /// Directory that plays the role of the user's home directory; the adapter /// looks under `/.claude/projects/`. home_dir: String, } impl ClaudeTranscriptInspector { /// Builds the inspector from an injected [`FileSystem`] and the user's home /// directory (resolved by the composition root, e.g. from `$HOME`). #[must_use] pub fn new(fs: Arc, home_dir: impl Into) -> Self { Self { fs, home_dir: home_dir.into(), } } /// Test/seam constructor: identical to [`new`](Self::new) but named to make /// the injected base directory explicit at call sites (tests point it at a /// temp `~/.claude/projects/` fixture). #[must_use] pub fn with_base_dir(fs: Arc, home_dir: impl Into) -> Self { Self::new(fs, home_dir) } } /// One transcript line, as far as we care about it. Everything is optional so /// that an unexpected/partial line never fails deserialization of the fields we /// *can* read; truly unparseable lines are skipped by the caller. #[derive(Debug, Deserialize)] struct TranscriptLine { #[serde(default)] role: Option, /// Some Claude transcript variants nest the chat turn under `message`. #[serde(default)] message: Option, #[serde(default)] content: Option, #[serde(default)] usage: Option, } /// The nested `{ role, content, usage }` object some transcript shapes use. #[derive(Debug, Deserialize)] struct InnerMessage { #[serde(default)] role: Option, #[serde(default)] content: Option, #[serde(default)] usage: Option, } /// Message content is either a plain string or an array of typed blocks. #[derive(Debug, Deserialize)] #[serde(untagged)] enum Content { /// `"content": "hello"`. Text(String), /// `"content": [{ "type": "text", "text": "hello" }, ...]`. Blocks(Vec), } impl Content { /// Flattens the content to plain text (concatenating text blocks). fn to_text(&self) -> String { match self { Self::Text(s) => s.clone(), Self::Blocks(blocks) => blocks .iter() .filter_map(|b| b.text.as_deref()) .collect::>() .join(" "), } } } /// A single content block; we only care about text blocks. #[derive(Debug, Deserialize)] struct ContentBlock { #[serde(default)] text: Option, } /// Token usage as Claude reports it on assistant turns. #[derive(Debug, Deserialize)] struct Usage { #[serde(default)] input_tokens: Option, #[serde(default)] output_tokens: Option, } impl TranscriptLine { /// The effective role, looking at the top level then the nested message. fn effective_role(&self) -> Option<&str> { self.role .as_deref() .or_else(|| self.message.as_ref().and_then(|m| m.role.as_deref())) } /// The effective content, top level then nested. fn effective_content(&self) -> Option<&Content> { self.content .as_ref() .or_else(|| self.message.as_ref().and_then(|m| m.content.as_ref())) } /// The effective usage, top level then nested. fn effective_usage(&self) -> Option<&Usage> { self.usage .as_ref() .or_else(|| self.message.as_ref().and_then(|m| m.usage.as_ref())) } } /// Parses an already-read transcript body (the JSONL bytes) into best-effort /// [`ConversationDetails`]. Pulled out of the async `details` so it is a pure, /// directly unit-testable function. Malformed lines are silently skipped. fn parse_transcript(body: &[u8]) -> ConversationDetails { let text = String::from_utf8_lossy(body); let mut last_topic: Option = None; let mut token_total: u64 = 0; let mut saw_usage = false; for line in text.lines() { let line = line.trim(); if line.is_empty() { continue; } // Malformed line → skip, never fatal. let Ok(parsed) = serde_json::from_str::(line) else { continue; }; let role = parsed.effective_role(); // last_topic = the last user message's text (best-effort). if role == Some("user") { if let Some(content) = parsed.effective_content() { let t = content.to_text(); let t = t.trim(); if !t.is_empty() { last_topic = Some(truncate_topic(t)); } } } // token_count = cumulative usage across assistant turns (best-effort). if let Some(usage) = parsed.effective_usage() { let input = usage.input_tokens.unwrap_or(0); let output = usage.output_tokens.unwrap_or(0); if input != 0 || output != 0 { saw_usage = true; token_total = token_total.saturating_add(input).saturating_add(output); } } } ConversationDetails { last_topic, token_count: saw_usage.then_some(token_total), } } /// Truncates a topic to [`TOPIC_MAX_LEN`] bytes on a char boundary, appending an /// ellipsis when cut. fn truncate_topic(s: &str) -> String { if s.len() <= TOPIC_MAX_LEN { return s.to_owned(); } let mut end = TOPIC_MAX_LEN; while end > 0 && !s.is_char_boundary(end) { end -= 1; } format!("{}…", &s[..end]) } #[async_trait] impl SessionInspector for ClaudeTranscriptInspector { fn supports(&self, profile: &AgentProfile) -> bool { supports_claude(profile) } async fn details( &self, _profile: &AgentProfile, conversation_id: &str, cwd: &ProjectPath, ) -> Result { let path = claude_transcript_path(&self.home_dir, conversation_id, cwd); match self.fs.read(&path).await { Ok(bytes) => Ok(parse_transcript(&bytes)), Err(FsError::NotFound(_)) => Err(InspectError::NotFound), Err(e) => Err(InspectError::Read(e.to_string())), } } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_extracts_last_user_topic_and_token_sum() { let body = concat!( r#"{"role":"user","content":"first question"}"#, "\n", r#"{"role":"assistant","content":"hi","usage":{"input_tokens":10,"output_tokens":5}}"#, "\n", r#"{"role":"user","content":"second question"}"#, "\n", r#"{"role":"assistant","content":"there","usage":{"input_tokens":20,"output_tokens":7}}"#, "\n", ); let d = parse_transcript(body.as_bytes()); assert_eq!(d.last_topic.as_deref(), Some("second question")); assert_eq!(d.token_count, Some(42)); } #[test] fn parse_skips_malformed_lines_without_panicking() { let body = concat!( r#"{"role":"user","content":"valid one"}"#, "\n", "this is not json at all", "\n", r#"{"role":"assistant","usage":{"input_tokens":3,"output_tokens":4}}"#, "\n", "{ broken json", "\n", ); let d = parse_transcript(body.as_bytes()); assert_eq!(d.last_topic.as_deref(), Some("valid one")); assert_eq!(d.token_count, Some(7)); } #[test] fn parse_handles_nested_message_and_block_content() { let body = concat!( r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"nested topic"}]}}"#, "\n", r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":100,"output_tokens":50}}}"#, "\n", ); let d = parse_transcript(body.as_bytes()); assert_eq!(d.last_topic.as_deref(), Some("nested topic")); assert_eq!(d.token_count, Some(150)); } #[test] fn parse_returns_none_when_no_topic_or_usage() { let body = concat!(r#"{"role":"assistant","content":"no usage here"}"#, "\n",); let d = parse_transcript(body.as_bytes()); assert_eq!(d.last_topic, None); assert_eq!(d.token_count, None); } #[test] fn truncate_topic_cuts_long_text() { let long = "x".repeat(TOPIC_MAX_LEN + 50); let t = truncate_topic(&long); assert!(t.ends_with('…')); assert!(t.len() <= TOPIC_MAX_LEN + 4); } }