lore_cli/capture/watchers/
aider.rs1use anyhow::{Context, Result};
16use chrono::{DateTime, Utc};
17use std::fs;
18use std::path::{Path, PathBuf};
19use uuid::Uuid;
20
21use crate::storage::models::{Message, MessageContent, MessageRole, Session};
22
23use super::{Watcher, WatcherInfo};
24
25pub struct AiderWatcher;
31
32impl Watcher for AiderWatcher {
33 fn info(&self) -> WatcherInfo {
34 WatcherInfo {
35 name: "aider",
36 description: "Aider terminal AI chat sessions",
37 default_paths: vec![],
38 }
39 }
40
41 fn is_available(&self) -> bool {
42 if std::process::Command::new("aider")
44 .arg("--version")
45 .output()
46 .is_ok()
47 {
48 return true;
49 }
50
51 find_aider_history_files()
53 .map(|files| !files.is_empty())
54 .unwrap_or(false)
55 }
56
57 fn find_sources(&self) -> Result<Vec<PathBuf>> {
58 find_aider_history_files()
59 }
60
61 fn parse_source(&self, path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
62 let parsed = parse_aider_history(path)?;
63 if parsed.is_empty() {
64 return Ok(vec![]);
65 }
66 Ok(parsed)
67 }
68
69 fn watch_paths(&self) -> Vec<PathBuf> {
70 vec![]
77 }
78}
79
80fn find_aider_history_files() -> Result<Vec<PathBuf>> {
86 let mut files = Vec::new();
87
88 if let Some(home) = dirs::home_dir() {
90 let home_history = home.join(".aider.chat.history.md");
91 if home_history.exists() {
92 files.push(home_history);
93 }
94
95 for dir_name in &["projects", "code", "src", "dev", "workspace", "repos"] {
97 let dir = home.join(dir_name);
98 if dir.exists() {
99 if let Ok(entries) = fs::read_dir(&dir) {
100 for entry in entries.filter_map(|e| e.ok()) {
101 let history_file = entry.path().join(".aider.chat.history.md");
102 if history_file.exists() {
103 files.push(history_file);
104 }
105 }
106 }
107 }
108 }
109 }
110
111 Ok(files)
112}
113
114const SKIP_DIRS: &[&str] = &[
119 ".git",
121 ".svn",
122 ".hg",
123 "node_modules",
125 "target",
126 "build",
127 "dist",
128 "out",
129 "__pycache__",
130 ".pytest_cache",
131 ".mypy_cache",
132 "venv",
133 ".venv",
134 "env",
135 ".env",
136 ".tox",
137 ".nox",
138 ".npm",
140 ".yarn",
141 ".pnpm",
142 ".cargo",
143 ".rustup",
144 ".idea",
146 ".vscode",
147 ".eclipse",
148 ".cache",
150 ".local",
151 ".config",
152 ".Trash",
153 "Library",
155 "vendor",
157 ".bundle",
158];
159
160const ALLOW_HIDDEN_DIRS: &[&str] = &[".claude", ".continue", ".codex", ".amp"];
164
165pub fn scan_directories_for_aider_files<F>(
177 directories: &[PathBuf],
178 mut progress_callback: F,
179) -> Vec<PathBuf>
180where
181 F: FnMut(&Path, usize),
182{
183 let mut found_files = Vec::new();
184
185 for dir in directories {
186 if dir.exists() && dir.is_dir() {
187 scan_directory_recursive(dir, &mut found_files, &mut progress_callback);
188 }
189 }
190
191 found_files
192}
193
194fn scan_directory_recursive<F>(
196 dir: &Path,
197 found_files: &mut Vec<PathBuf>,
198 progress_callback: &mut F,
199) where
200 F: FnMut(&Path, usize),
201{
202 progress_callback(dir, found_files.len());
204
205 let history_file = dir.join(".aider.chat.history.md");
207 if history_file.exists() {
208 found_files.push(history_file);
209 }
210
211 let entries = match fs::read_dir(dir) {
213 Ok(entries) => entries,
214 Err(_) => return, };
216
217 for entry in entries.filter_map(|e| e.ok()) {
219 let path = entry.path();
220
221 if !path.is_dir() {
222 continue;
223 }
224
225 let dir_name = match path.file_name().and_then(|n| n.to_str()) {
226 Some(name) => name,
227 None => continue,
228 };
229
230 if SKIP_DIRS.contains(&dir_name) {
232 continue;
233 }
234
235 if dir_name.starts_with('.') && !ALLOW_HIDDEN_DIRS.contains(&dir_name) {
237 continue;
238 }
239
240 scan_directory_recursive(&path, found_files, progress_callback);
242 }
243}
244
245fn parse_aider_history(path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
255 let content = fs::read_to_string(path).context("Failed to read Aider history file")?;
256
257 let working_directory = path
258 .parent()
259 .map(|p| p.to_string_lossy().to_string())
260 .unwrap_or_else(|| ".".to_string());
261
262 let mut sessions = Vec::new();
263 let mut current_messages: Vec<ParsedMessage> = Vec::new();
264 let mut current_role: Option<MessageRole> = None;
265 let mut current_content = String::new();
266 let mut in_tool_output = false;
267
268 for line in content.lines() {
269 if line.starts_with("#### ") {
271 if let Some(role) = current_role.take() {
273 if !current_content.trim().is_empty() {
274 current_messages.push(ParsedMessage {
275 role,
276 content: current_content.trim().to_string(),
277 });
278 }
279 }
280
281 current_role = Some(MessageRole::User);
283 current_content = line.strip_prefix("#### ").unwrap_or("").to_string();
284 in_tool_output = false;
285 }
286 else if line.starts_with("> ") || line == ">" {
288 if current_role == Some(MessageRole::User) && !current_content.trim().is_empty() {
290 current_messages.push(ParsedMessage {
292 role: MessageRole::User,
293 content: current_content.trim().to_string(),
294 });
295 current_content.clear();
296 current_role = Some(MessageRole::Assistant);
297 } else if current_role.is_none() {
298 current_role = Some(MessageRole::Assistant);
299 }
300
301 in_tool_output = true;
302 let tool_line = line
303 .strip_prefix("> ")
304 .unwrap_or(line.strip_prefix(">").unwrap_or(""));
305 if !current_content.is_empty() {
306 current_content.push('\n');
307 }
308 current_content.push_str(tool_line);
309 }
310 else if line.trim().is_empty() {
312 if in_tool_output {
313 in_tool_output = false;
315 if !current_content.is_empty() {
316 current_content.push('\n');
317 }
318 } else if current_role == Some(MessageRole::User) && !current_content.trim().is_empty()
319 {
320 current_messages.push(ParsedMessage {
322 role: MessageRole::User,
323 content: current_content.trim().to_string(),
324 });
325 current_content.clear();
326 current_role = Some(MessageRole::Assistant);
327 } else if current_role == Some(MessageRole::Assistant) {
328 if !current_content.is_empty() {
330 current_content.push('\n');
331 }
332 }
333 }
334 else {
336 if current_role.is_none() {
337 current_role = Some(MessageRole::Assistant);
339 } else if current_role == Some(MessageRole::User) && !line.starts_with("####") {
340 if !current_content.trim().is_empty() {
343 current_messages.push(ParsedMessage {
344 role: MessageRole::User,
345 content: current_content.trim().to_string(),
346 });
347 current_content.clear();
348 current_role = Some(MessageRole::Assistant);
349 }
350 }
351
352 if !current_content.is_empty() {
353 current_content.push('\n');
354 }
355 current_content.push_str(line);
356 }
357 }
358
359 if let Some(role) = current_role {
361 if !current_content.trim().is_empty() {
362 current_messages.push(ParsedMessage {
363 role,
364 content: current_content.trim().to_string(),
365 });
366 }
367 }
368
369 if !current_messages.is_empty() {
371 let session = create_session(path, &working_directory, current_messages.len());
372 let messages = create_messages(&session, ¤t_messages);
373 sessions.push((session, messages));
374 }
375
376 Ok(sessions)
377}
378
379struct ParsedMessage {
381 role: MessageRole,
382 content: String,
383}
384
385fn create_session(path: &Path, working_directory: &str, message_count: usize) -> Session {
387 let ended_at = fs::metadata(path)
389 .ok()
390 .and_then(|m| m.modified().ok())
391 .map(DateTime::<Utc>::from);
392
393 let started_at = ended_at
395 .map(|t| t - chrono::Duration::minutes(message_count as i64 * 2))
396 .unwrap_or_else(Utc::now);
397
398 Session {
399 id: Uuid::new_v4(),
400 tool: "aider".to_string(),
401 tool_version: None,
402 started_at,
403 ended_at,
404 model: None,
405 working_directory: working_directory.to_string(),
406 git_branch: None,
407 source_path: Some(path.to_string_lossy().to_string()),
408 message_count: message_count as i32,
409 machine_id: crate::storage::get_machine_id(),
410 }
411}
412
413fn create_messages(session: &Session, parsed_messages: &[ParsedMessage]) -> Vec<Message> {
415 let time_per_message = chrono::Duration::seconds(30);
416 let mut current_time = session.started_at;
417
418 parsed_messages
419 .iter()
420 .enumerate()
421 .map(|(idx, msg)| {
422 let message = Message {
423 id: Uuid::new_v4(),
424 session_id: session.id,
425 parent_id: None,
426 index: idx as i32,
427 timestamp: current_time,
428 role: msg.role.clone(),
429 content: MessageContent::Text(msg.content.clone()),
430 model: None,
431 git_branch: None,
432 cwd: Some(session.working_directory.clone()),
433 };
434 current_time += time_per_message;
435 message
436 })
437 .collect()
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443 use std::io::Write;
444 use tempfile::NamedTempFile;
445
446 fn create_temp_history_file(content: &str) -> NamedTempFile {
448 let mut file = NamedTempFile::new().expect("Failed to create temp file");
449 file.write_all(content.as_bytes())
450 .expect("Failed to write content");
451 file.flush().expect("Failed to flush");
452 file
453 }
454
455 #[test]
464 fn test_parse_simple_conversation() {
465 let content = r#"#### Hello, can you help me with a Rust project?
466
467Sure! I'd be happy to help you with your Rust project. What would you like to do?
468
469#### Can you create a simple function?
470
471Here's a simple function:
472
473```rust
474fn hello() {
475 println!("Hello, world!");
476}
477```
478"#;
479
480 let file = create_temp_history_file(content);
481 let result = parse_aider_history(file.path()).expect("Should parse");
482
483 assert_eq!(result.len(), 1);
484 let (session, messages) = &result[0];
485 assert_eq!(session.tool, "aider");
486 assert!(messages.len() >= 2);
487 }
488
489 #[test]
490 fn test_parse_with_tool_output() {
491 let content = r#"#### Run the tests
492
493> Running tests...
494> test result: ok. 5 passed; 0 failed
495
496All tests passed successfully!
497"#;
498
499 let file = create_temp_history_file(content);
500 let result = parse_aider_history(file.path()).expect("Should parse");
501
502 assert_eq!(result.len(), 1);
503 let (_, messages) = &result[0];
504 assert!(!messages.is_empty());
505 }
506
507 #[test]
508 fn test_parse_empty_file() {
509 let content = "";
510
511 let file = create_temp_history_file(content);
512 let result = parse_aider_history(file.path()).expect("Should parse");
513
514 assert!(result.is_empty());
515 }
516
517 #[test]
518 fn test_parse_user_message_only() {
519 let content = "#### What is Rust?\n";
520
521 let file = create_temp_history_file(content);
522 let result = parse_aider_history(file.path()).expect("Should parse");
523
524 assert_eq!(result.len(), 1);
525 let (_, messages) = &result[0];
526 assert_eq!(messages.len(), 1);
527 assert_eq!(messages[0].role, MessageRole::User);
528 }
529
530 #[test]
531 fn test_parse_multiple_exchanges() {
532 let content = r#"#### First question
533
534First answer
535
536#### Second question
537
538Second answer
539
540#### Third question
541
542Third answer
543"#;
544
545 let file = create_temp_history_file(content);
546 let result = parse_aider_history(file.path()).expect("Should parse");
547
548 assert_eq!(result.len(), 1);
549 let (_, messages) = &result[0];
550 assert!(messages.len() >= 3);
552 }
553
554 #[test]
555 fn test_session_metadata() {
556 let content = "#### Test message\n\nTest response\n";
557
558 let file = create_temp_history_file(content);
559 let result = parse_aider_history(file.path()).expect("Should parse");
560
561 let (session, _) = &result[0];
562 assert_eq!(session.tool, "aider");
563 assert!(session.source_path.is_some());
564 assert!(session.ended_at.is_some());
565 }
566
567 #[test]
568 fn test_find_aider_history_files_returns_ok() {
569 let result = find_aider_history_files();
571 assert!(result.is_ok());
572 }
573
574 #[test]
575 fn test_watcher_parse_source() {
576 let watcher = AiderWatcher;
577 let content = "#### Test\n\nResponse\n";
578
579 let file = create_temp_history_file(content);
580 let result = watcher
581 .parse_source(file.path())
582 .expect("Should parse successfully");
583
584 assert!(!result.is_empty());
585 let (session, _) = &result[0];
586 assert_eq!(session.tool, "aider");
587 }
588
589 #[test]
590 fn test_message_roles_alternate() {
591 let content = r#"#### User message 1
592
593Assistant response 1
594
595#### User message 2
596
597Assistant response 2
598"#;
599
600 let file = create_temp_history_file(content);
601 let result = parse_aider_history(file.path()).expect("Should parse");
602
603 let (_, messages) = &result[0];
604 assert!(messages.len() >= 2);
605
606 for (i, msg) in messages.iter().enumerate() {
608 if i % 2 == 0 {
609 assert_eq!(msg.role, MessageRole::User);
610 } else {
611 assert_eq!(msg.role, MessageRole::Assistant);
612 }
613 }
614 }
615
616 #[test]
617 fn test_scan_directories_finds_aider_files() {
618 use tempfile::TempDir;
619
620 let temp_dir = TempDir::new().expect("Failed to create temp dir");
622 let project_dir = temp_dir.path().join("my-project");
623 std::fs::create_dir(&project_dir).expect("Failed to create project dir");
624
625 let history_file = project_dir.join(".aider.chat.history.md");
627 std::fs::write(&history_file, "#### Test\n\nResponse\n").expect("Failed to write file");
628
629 let mut progress_calls = 0;
631 let found = scan_directories_for_aider_files(&[temp_dir.path().to_path_buf()], |_, _| {
632 progress_calls += 1;
633 });
634
635 assert_eq!(found.len(), 1);
636 assert_eq!(found[0], history_file);
637 assert!(progress_calls > 0, "Progress callback should be called");
638 }
639
640 #[test]
641 fn test_scan_directories_skips_hidden_dirs() {
642 use tempfile::TempDir;
643
644 let temp_dir = TempDir::new().expect("Failed to create temp dir");
646 let hidden_dir = temp_dir.path().join(".hidden-project");
647 std::fs::create_dir(&hidden_dir).expect("Failed to create hidden dir");
648
649 let history_file = hidden_dir.join(".aider.chat.history.md");
651 std::fs::write(&history_file, "#### Test\n\nResponse\n").expect("Failed to write file");
652
653 let found = scan_directories_for_aider_files(&[temp_dir.path().to_path_buf()], |_, _| {});
655
656 assert!(
657 found.is_empty(),
658 "Should not find files in hidden directories"
659 );
660 }
661
662 #[test]
663 fn test_scan_directories_skips_node_modules() {
664 use tempfile::TempDir;
665
666 let temp_dir = TempDir::new().expect("Failed to create temp dir");
668 let node_modules = temp_dir.path().join("node_modules").join("some-package");
669 std::fs::create_dir_all(&node_modules).expect("Failed to create node_modules");
670
671 let history_file = node_modules.join(".aider.chat.history.md");
673 std::fs::write(&history_file, "#### Test\n\nResponse\n").expect("Failed to write file");
674
675 let found = scan_directories_for_aider_files(&[temp_dir.path().to_path_buf()], |_, _| {});
677
678 assert!(found.is_empty(), "Should not find files in node_modules");
679 }
680
681 #[test]
682 fn test_scan_directories_empty_input() {
683 let found = scan_directories_for_aider_files(&[], |_, _| {});
684 assert!(found.is_empty());
685 }
686}