Skip to main content

toolpath_claude/
watcher.rs

1//! Conversation watching/tailing functionality.
2//!
3//! Provides a way to watch a conversation for new entries without re-processing
4//! entries that have already been seen.
5
6use crate::ClaudeConvo;
7use crate::error::Result;
8use crate::types::{Conversation, ConversationEntry, MessageRole};
9use std::collections::HashSet;
10
11/// Watches a conversation for new entries.
12///
13/// Tracks which entries have been seen (by UUID) and only returns new entries
14/// on subsequent polls.
15///
16/// # Example
17///
18/// ```rust,no_run
19/// use toolpath_claude::{ClaudeConvo, ConversationWatcher};
20///
21/// let manager = ClaudeConvo::new();
22/// let mut watcher = ConversationWatcher::new(
23///     manager,
24///     "/path/to/project".to_string(),
25///     "session-uuid".to_string(),
26/// );
27///
28/// // First poll returns all existing entries
29/// let entries = watcher.poll().unwrap();
30/// println!("Initial entries: {}", entries.len());
31///
32/// // Subsequent polls return only new entries
33/// loop {
34///     std::thread::sleep(std::time::Duration::from_secs(1));
35///     let new_entries = watcher.poll().unwrap();
36///     for entry in new_entries {
37///         println!("New entry: {:?}", entry.uuid);
38///     }
39/// }
40/// ```
41#[derive(Debug)]
42pub struct ConversationWatcher {
43    manager: ClaudeConvo,
44    project: String,
45    session_id: String,
46    seen_uuids: HashSet<String>,
47    role_filter: Option<MessageRole>,
48}
49
50impl ConversationWatcher {
51    /// Creates a new watcher for the given conversation.
52    pub fn new(manager: ClaudeConvo, project: String, session_id: String) -> Self {
53        Self {
54            manager,
55            project,
56            session_id,
57            seen_uuids: HashSet::new(),
58            role_filter: None,
59        }
60    }
61
62    /// Sets a role filter - only entries with this role will be returned.
63    pub fn with_role_filter(mut self, role: MessageRole) -> Self {
64        self.role_filter = Some(role);
65        self
66    }
67
68    /// Returns the project path being watched.
69    pub fn project(&self) -> &str {
70        &self.project
71    }
72
73    /// Returns the session ID being watched.
74    pub fn session_id(&self) -> &str {
75        &self.session_id
76    }
77
78    /// Returns the number of entries that have been seen.
79    pub fn seen_count(&self) -> usize {
80        self.seen_uuids.len()
81    }
82
83    /// Polls for new conversation entries.
84    ///
85    /// On the first call, returns all existing entries (optionally filtered by role).
86    /// On subsequent calls, returns only entries that haven't been seen before.
87    pub fn poll(&mut self) -> Result<Vec<ConversationEntry>> {
88        let convo = self
89            .manager
90            .read_conversation(&self.project, &self.session_id)?;
91        self.extract_new_entries(&convo)
92    }
93
94    /// Polls and returns the full conversation along with just the new entries.
95    ///
96    /// Useful when you need both the full state and the delta.
97    pub fn poll_with_full(&mut self) -> Result<(Conversation, Vec<ConversationEntry>)> {
98        let convo = self
99            .manager
100            .read_conversation(&self.project, &self.session_id)?;
101        let new_entries = self.extract_new_entries(&convo)?;
102        Ok((convo, new_entries))
103    }
104
105    /// Resets the watcher, clearing all seen UUIDs.
106    ///
107    /// The next poll will return all entries as if it were the first call.
108    pub fn reset(&mut self) {
109        self.seen_uuids.clear();
110    }
111
112    /// Pre-marks entries as seen without returning them.
113    ///
114    /// Useful for initializing the watcher to only return future entries.
115    pub fn mark_seen(&mut self, entries: &[ConversationEntry]) {
116        for entry in entries {
117            self.seen_uuids.insert(entry.uuid.clone());
118        }
119    }
120
121    /// Skips existing entries - next poll will only return new entries.
122    pub fn skip_existing(&mut self) -> Result<usize> {
123        let convo = self
124            .manager
125            .read_conversation(&self.project, &self.session_id)?;
126        let count = convo.entries.len();
127        for entry in &convo.entries {
128            self.seen_uuids.insert(entry.uuid.clone());
129        }
130        Ok(count)
131    }
132
133    fn extract_new_entries(&mut self, convo: &Conversation) -> Result<Vec<ConversationEntry>> {
134        let mut new_entries = Vec::new();
135
136        for entry in &convo.entries {
137            if self.seen_uuids.contains(&entry.uuid) {
138                continue;
139            }
140
141            // Apply role filter if set
142            if let Some(role_filter) = self.role_filter {
143                if let Some(msg) = &entry.message {
144                    if msg.role != role_filter {
145                        self.seen_uuids.insert(entry.uuid.clone());
146                        continue;
147                    }
148                } else {
149                    self.seen_uuids.insert(entry.uuid.clone());
150                    continue;
151                }
152            }
153
154            new_entries.push(entry.clone());
155            self.seen_uuids.insert(entry.uuid.clone());
156        }
157
158        Ok(new_entries)
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::PathResolver;
166    use std::fs;
167    use tempfile::TempDir;
168
169    fn create_test_jsonl(dir: &std::path::Path, session_id: &str, entries: &[&str]) {
170        let project_dir = dir.join("projects/-test-project");
171        fs::create_dir_all(&project_dir).unwrap();
172        let file_path = project_dir.join(format!("{}.jsonl", session_id));
173        fs::write(&file_path, entries.join("\n")).unwrap();
174    }
175
176    #[test]
177    fn test_watcher_tracks_seen() {
178        let temp = TempDir::new().unwrap();
179        let claude_dir = temp.path().join(".claude");
180
181        let entry1 = r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#;
182        let entry2 = r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there"}}"#;
183
184        create_test_jsonl(&claude_dir, "session-1", &[entry1, entry2]);
185
186        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
187        let manager = ClaudeConvo::with_resolver(resolver);
188
189        let mut watcher = ConversationWatcher::new(
190            manager,
191            "/test/project".to_string(),
192            "session-1".to_string(),
193        );
194
195        // First poll returns all entries
196        let entries = watcher.poll().unwrap();
197        assert_eq!(entries.len(), 2);
198        assert_eq!(watcher.seen_count(), 2);
199
200        // Second poll returns nothing (no new entries)
201        let entries = watcher.poll().unwrap();
202        assert_eq!(entries.len(), 0);
203    }
204
205    #[test]
206    fn test_watcher_skip_existing() {
207        let temp = TempDir::new().unwrap();
208        let claude_dir = temp.path().join(".claude");
209
210        let entry1 = r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#;
211
212        create_test_jsonl(&claude_dir, "session-1", &[entry1]);
213
214        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
215        let manager = ClaudeConvo::with_resolver(resolver);
216
217        let mut watcher = ConversationWatcher::new(
218            manager,
219            "/test/project".to_string(),
220            "session-1".to_string(),
221        );
222
223        // Skip existing
224        let skipped = watcher.skip_existing().unwrap();
225        assert_eq!(skipped, 1);
226
227        // Poll returns nothing
228        let entries = watcher.poll().unwrap();
229        assert_eq!(entries.len(), 0);
230    }
231
232    #[test]
233    fn test_watcher_accessors() {
234        let temp = TempDir::new().unwrap();
235        let claude_dir = temp.path().join(".claude");
236        create_test_jsonl(&claude_dir, "session-1", &[]);
237
238        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
239        let manager = ClaudeConvo::with_resolver(resolver);
240
241        let watcher = ConversationWatcher::new(
242            manager,
243            "/test/project".to_string(),
244            "session-1".to_string(),
245        );
246
247        assert_eq!(watcher.project(), "/test/project");
248        assert_eq!(watcher.session_id(), "session-1");
249        assert_eq!(watcher.seen_count(), 0);
250    }
251
252    #[test]
253    fn test_watcher_reset() {
254        let temp = TempDir::new().unwrap();
255        let claude_dir = temp.path().join(".claude");
256
257        let entry1 = r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#;
258        create_test_jsonl(&claude_dir, "session-1", &[entry1]);
259
260        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
261        let manager = ClaudeConvo::with_resolver(resolver);
262
263        let mut watcher = ConversationWatcher::new(
264            manager,
265            "/test/project".to_string(),
266            "session-1".to_string(),
267        );
268
269        // First poll
270        let entries = watcher.poll().unwrap();
271        assert_eq!(entries.len(), 1);
272        assert_eq!(watcher.seen_count(), 1);
273
274        // Reset
275        watcher.reset();
276        assert_eq!(watcher.seen_count(), 0);
277
278        // Poll again should return entries
279        let entries = watcher.poll().unwrap();
280        assert_eq!(entries.len(), 1);
281    }
282
283    #[test]
284    fn test_watcher_mark_seen() {
285        let temp = TempDir::new().unwrap();
286        let claude_dir = temp.path().join(".claude");
287
288        let entry1 = r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#;
289        let entry2 = r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#;
290        create_test_jsonl(&claude_dir, "session-1", &[entry1, entry2]);
291
292        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
293        let manager = ClaudeConvo::with_resolver(resolver);
294
295        let mut watcher = ConversationWatcher::new(
296            manager,
297            "/test/project".to_string(),
298            "session-1".to_string(),
299        );
300
301        // Read conversation to get entries
302        let convo = watcher.poll().unwrap();
303        watcher.reset();
304
305        // Mark first entry as seen
306        watcher.mark_seen(&convo[..1]);
307        assert_eq!(watcher.seen_count(), 1);
308
309        // Poll should return only the second entry
310        let entries = watcher.poll().unwrap();
311        assert_eq!(entries.len(), 1);
312        assert_eq!(entries[0].uuid, "uuid-2");
313    }
314
315    #[test]
316    fn test_watcher_with_role_filter() {
317        let temp = TempDir::new().unwrap();
318        let claude_dir = temp.path().join(".claude");
319
320        let entry1 = r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#;
321        let entry2 = r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#;
322        create_test_jsonl(&claude_dir, "session-1", &[entry1, entry2]);
323
324        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
325        let manager = ClaudeConvo::with_resolver(resolver);
326
327        let mut watcher = ConversationWatcher::new(
328            manager,
329            "/test/project".to_string(),
330            "session-1".to_string(),
331        )
332        .with_role_filter(MessageRole::User);
333
334        let entries = watcher.poll().unwrap();
335        assert_eq!(entries.len(), 1);
336        assert_eq!(entries[0].uuid, "uuid-1");
337        // Both entries should be marked as seen (the assistant one was filtered but still seen)
338        assert_eq!(watcher.seen_count(), 2);
339    }
340
341    #[test]
342    fn test_watcher_poll_with_full() {
343        let temp = TempDir::new().unwrap();
344        let claude_dir = temp.path().join(".claude");
345
346        let entry1 = r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#;
347        create_test_jsonl(&claude_dir, "session-1", &[entry1]);
348
349        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
350        let manager = ClaudeConvo::with_resolver(resolver);
351
352        let mut watcher = ConversationWatcher::new(
353            manager,
354            "/test/project".to_string(),
355            "session-1".to_string(),
356        );
357
358        let (convo, new_entries) = watcher.poll_with_full().unwrap();
359        assert_eq!(convo.entries.len(), 1);
360        assert_eq!(new_entries.len(), 1);
361
362        // Second call should return full convo but no new entries
363        let (convo2, new_entries2) = watcher.poll_with_full().unwrap();
364        assert_eq!(convo2.entries.len(), 1);
365        assert!(new_entries2.is_empty());
366    }
367}