1use crate::ClaudeConvo;
7use crate::error::Result;
8use crate::types::{Conversation, ConversationEntry, MessageRole};
9use std::collections::HashSet;
10
11#[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 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 pub fn with_role_filter(mut self, role: MessageRole) -> Self {
64 self.role_filter = Some(role);
65 self
66 }
67
68 pub fn project(&self) -> &str {
70 &self.project
71 }
72
73 pub fn session_id(&self) -> &str {
75 &self.session_id
76 }
77
78 pub fn seen_count(&self) -> usize {
80 self.seen_uuids.len()
81 }
82
83 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 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 pub fn reset(&mut self) {
109 self.seen_uuids.clear();
110 }
111
112 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 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 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 let entries = watcher.poll().unwrap();
197 assert_eq!(entries.len(), 2);
198 assert_eq!(watcher.seen_count(), 2);
199
200 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 let skipped = watcher.skip_existing().unwrap();
225 assert_eq!(skipped, 1);
226
227 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 let entries = watcher.poll().unwrap();
271 assert_eq!(entries.len(), 1);
272 assert_eq!(watcher.seen_count(), 1);
273
274 watcher.reset();
276 assert_eq!(watcher.seen_count(), 0);
277
278 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 let convo = watcher.poll().unwrap();
303 watcher.reset();
304
305 watcher.mark_seen(&convo[..1]);
307 assert_eq!(watcher.seen_count(), 1);
308
309 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 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 let (convo2, new_entries2) = watcher.poll_with_full().unwrap();
364 assert_eq!(convo2.entries.len(), 1);
365 assert!(new_entries2.is_empty());
366 }
367}