claude_code_cli_acp/transcript/
tailer.rs1use std::fs::File;
2use std::io::{BufRead, BufReader, Seek, SeekFrom};
3use std::path::{Path, PathBuf};
4
5use anyhow::Context;
6
7use crate::transcript::events::{TranscriptEvent, parse_transcript_line};
8
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub struct TranscriptLocator {
11 claude_home: PathBuf,
12}
13
14impl TranscriptLocator {
15 pub fn new(claude_home: impl Into<PathBuf>) -> Self {
16 Self {
17 claude_home: claude_home.into(),
18 }
19 }
20
21 pub fn default_home() -> anyhow::Result<Self> {
22 let home = dirs::home_dir().context("home directory unavailable")?;
23 Ok(Self::new(home.join(".claude")))
24 }
25
26 pub fn find_transcript(&self, session_id: &str) -> anyhow::Result<Option<PathBuf>> {
27 let projects = self.claude_home.join("projects");
28 if !projects.exists() {
29 return Ok(None);
30 }
31
32 let target = format!("{session_id}.jsonl");
33 find_file_named(&projects, &target)
34 }
35}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct TranscriptTailer {
39 session_id: String,
40 path: PathBuf,
41 offset: u64,
42}
43
44impl TranscriptTailer {
45 pub fn from_path(session_id: impl Into<String>, path: impl AsRef<Path>) -> Self {
46 Self {
47 session_id: session_id.into(),
48 path: path.as_ref().to_path_buf(),
49 offset: 0,
50 }
51 }
52
53 pub fn from_path_at_end(
54 session_id: impl Into<String>,
55 path: impl AsRef<Path>,
56 ) -> anyhow::Result<Self> {
57 let path = path.as_ref().to_path_buf();
58 let offset = std::fs::metadata(&path)
59 .with_context(|| format!("stat transcript {}", path.display()))?
60 .len();
61 Ok(Self {
62 session_id: session_id.into(),
63 path,
64 offset,
65 })
66 }
67
68 pub fn from_locator(
69 session_id: impl Into<String>,
70 locator: &TranscriptLocator,
71 ) -> anyhow::Result<Option<Self>> {
72 let session_id = session_id.into();
73 Ok(locator
74 .find_transcript(&session_id)?
75 .map(|path| Self::from_path(session_id, path)))
76 }
77
78 pub fn from_locator_at_end(
79 session_id: impl Into<String>,
80 locator: &TranscriptLocator,
81 ) -> anyhow::Result<Option<Self>> {
82 let session_id = session_id.into();
83 locator
84 .find_transcript(&session_id)?
85 .map(|path| Self::from_path_at_end(session_id, path))
86 .transpose()
87 }
88
89 pub fn path(&self) -> &Path {
90 &self.path
91 }
92
93 pub fn poll(&mut self) -> anyhow::Result<Vec<TranscriptEvent>> {
94 let mut file = File::open(&self.path)
95 .with_context(|| format!("open transcript {}", self.path.display()))?;
96 file.seek(SeekFrom::Start(self.offset))
97 .with_context(|| format!("seek transcript {}", self.path.display()))?;
98
99 let mut reader = BufReader::new(file);
100 let mut events = Vec::new();
101 let mut line = String::new();
102 loop {
103 line.clear();
104 let read = reader
105 .read_line(&mut line)
106 .with_context(|| format!("read transcript {}", self.path.display()))?;
107 if read == 0 {
108 break;
109 }
110 self.offset += read as u64;
111 for event in parse_transcript_line(line.trim_end_matches(['\r', '\n']))? {
112 match event.session_id() {
113 Some(session_id) if session_id == self.session_id => events.push(event),
114 None => events.push(event),
115 _ => {}
116 }
117 }
118 }
119 Ok(events)
120 }
121}
122
123fn find_file_named(root: &Path, filename: &str) -> anyhow::Result<Option<PathBuf>> {
124 let mut stack = vec![root.to_path_buf()];
125 while let Some(path) = stack.pop() {
126 for entry in std::fs::read_dir(&path)
127 .with_context(|| format!("read transcript directory {}", path.display()))?
128 {
129 let entry = entry?;
130 let entry_path = entry.path();
131 let file_type = entry.file_type()?;
132 if file_type.is_dir() {
133 stack.push(entry_path);
134 } else if file_type.is_file()
135 && entry_path.file_name().and_then(|name| name.to_str()) == Some(filename)
136 {
137 return Ok(Some(entry_path));
138 }
139 }
140 }
141 Ok(None)
142}