Skip to main content

claude_code_cli_acp/transcript/
tailer.rs

1use 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}