Skip to main content

lore_cli/capture/watchers/
aider.rs

1//! Aider session parser.
2//!
3//! Parses chat history from Aider's markdown files. Aider stores conversation
4//! history in `.aider.chat.history.md` files in project directories.
5//!
6//! The format uses level 4 headings (`####`) for user messages, with assistant
7//! responses following as regular markdown text. Tool outputs are prefixed with
8//! `>` blockquotes.
9//!
10//! By default, Aider stores history in the project's root directory as
11//! `.aider.chat.history.md`. Users can configure a different location using
12//! the `--chat-history-file` option or `AIDER_CHAT_HISTORY_FILE` environment
13//! variable.
14
15use 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
25/// Watcher for Aider sessions.
26///
27/// Discovers and parses `.aider.chat.history.md` files from project directories.
28/// Aider is a terminal-based AI coding assistant that stores conversation
29/// history in markdown format.
30pub 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        // Check if aider command exists or if any history files exist
43        if std::process::Command::new("aider")
44            .arg("--version")
45            .output()
46            .is_ok()
47        {
48            return true;
49        }
50
51        // Fall back to checking if any history files exist
52        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        // Aider stores .aider.chat.history.md files in individual project directories,
71        // not in a central location. Watching the entire home directory is impractical
72        // (too many files, exceeds inotify limits, high memory usage).
73        //
74        // Instead, aider sessions are only captured via manual `lore import`.
75        // Real-time watching is not supported for aider.
76        vec![]
77    }
78}
79
80/// Finds Aider history files in common locations.
81///
82/// Searches the home directory and common project locations for
83/// `.aider.chat.history.md` files. This is a best-effort search since
84/// Aider files can be in any project directory.
85fn find_aider_history_files() -> Result<Vec<PathBuf>> {
86    let mut files = Vec::new();
87
88    // Check home directory
89    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        // Check common project directories
96        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
114/// Directories that should be skipped when scanning for aider files.
115///
116/// These are typically hidden directories, build artifacts, or cache directories
117/// that are unlikely to contain project files and would slow down scanning.
118const SKIP_DIRS: &[&str] = &[
119    // Hidden directories (general)
120    ".git",
121    ".svn",
122    ".hg",
123    // Build artifacts and dependencies
124    "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    // Package managers
139    ".npm",
140    ".yarn",
141    ".pnpm",
142    ".cargo",
143    ".rustup",
144    // IDE and editor directories
145    ".idea",
146    ".vscode",
147    ".eclipse",
148    // Cache and temp directories
149    ".cache",
150    ".local",
151    ".config",
152    ".Trash",
153    // macOS
154    "Library",
155    // Other
156    "vendor",
157    ".bundle",
158];
159
160/// Directories that should not be skipped even if they start with a dot.
161///
162/// These are known tool directories that may contain useful session data.
163const ALLOW_HIDDEN_DIRS: &[&str] = &[".claude", ".continue", ".codex", ".amp"];
164
165/// Scans directories recursively for aider history files.
166///
167/// This function searches the given directories for `.aider.chat.history.md` files,
168/// skipping hidden directories and common build artifact locations for efficiency.
169///
170/// # Arguments
171/// * `directories` - List of directories to scan
172/// * `progress_callback` - Called with (current_dir, files_found_so_far) during scanning
173///
174/// # Returns
175/// A vector of paths to discovered aider history files.
176pub 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
194/// Recursively scans a directory for aider history files.
195fn 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    // Report progress
203    progress_callback(dir, found_files.len());
204
205    // Check for aider history file in this directory
206    let history_file = dir.join(".aider.chat.history.md");
207    if history_file.exists() {
208        found_files.push(history_file);
209    }
210
211    // Read directory entries
212    let entries = match fs::read_dir(dir) {
213        Ok(entries) => entries,
214        Err(_) => return, // Skip directories we can't read
215    };
216
217    // Recurse into subdirectories
218    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        // Skip directories in the skip list
231        if SKIP_DIRS.contains(&dir_name) {
232            continue;
233        }
234
235        // Skip hidden directories unless they're in the allow list
236        if dir_name.starts_with('.') && !ALLOW_HIDDEN_DIRS.contains(&dir_name) {
237            continue;
238        }
239
240        // Recurse
241        scan_directory_recursive(&path, found_files, progress_callback);
242    }
243}
244
245/// Parses an Aider chat history markdown file.
246///
247/// The format consists of:
248/// - `####` headings for user messages
249/// - Regular text for assistant responses
250/// - `>` blockquotes for tool output
251///
252/// Each contiguous conversation (no blank lines between user/assistant) is
253/// treated as a session.
254fn 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        // User message starts with ####
270        if line.starts_with("#### ") {
271            // Save any pending content
272            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            // Start new user message
282            current_role = Some(MessageRole::User);
283            current_content = line.strip_prefix("#### ").unwrap_or("").to_string();
284            in_tool_output = false;
285        }
286        // Tool output (blockquote)
287        else if line.starts_with("> ") || line == ">" {
288            // Tool output is part of assistant response
289            if current_role == Some(MessageRole::User) && !current_content.trim().is_empty() {
290                // Save user message first
291                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        // Blank line might indicate end of message or section
311        else if line.trim().is_empty() {
312            if in_tool_output {
313                // End of tool output block
314                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                // End of user message, switch to assistant
321                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                // Blank line in assistant content
329                if !current_content.is_empty() {
330                    current_content.push('\n');
331                }
332            }
333        }
334        // Regular line (assistant response or continuation)
335        else {
336            if current_role.is_none() {
337                // Orphan content before any user message - treat as assistant
338                current_role = Some(MessageRole::Assistant);
339            } else if current_role == Some(MessageRole::User) && !line.starts_with("####") {
340                // This line follows user input - could be continuation or assistant response
341                // In Aider format, assistant responses directly follow user messages
342                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    // Save any remaining content
360    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    // Convert parsed messages to session
370    if !current_messages.is_empty() {
371        let session = create_session(path, &working_directory, current_messages.len());
372        let messages = create_messages(&session, &current_messages);
373        sessions.push((session, messages));
374    }
375
376    Ok(sessions)
377}
378
379/// A parsed message from Aider history.
380struct ParsedMessage {
381    role: MessageRole,
382    content: String,
383}
384
385/// Creates a Session from parsed Aider history.
386fn create_session(path: &Path, working_directory: &str, message_count: usize) -> Session {
387    // Use file modification time as session end time
388    let ended_at = fs::metadata(path)
389        .ok()
390        .and_then(|m| m.modified().ok())
391        .map(DateTime::<Utc>::from);
392
393    // Estimate start time as a bit before end time based on message count
394    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
413/// Creates Messages from parsed Aider content.
414fn 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    /// Creates a temporary Aider history file with given content.
447    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    // Note: Common watcher trait tests (info, watch_paths, find_sources) are in
456    // src/capture/watchers/test_common.rs to avoid duplication across all watchers.
457    // Only tool-specific parsing tests remain here.
458    //
459    // Aider is special: watch_paths returns empty because aider files are
460    // scattered across project directories. This is tested in test_common.rs
461    // by test_all_watchers_watch_paths_are_valid which accepts empty paths.
462
463    #[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        // Should have 3 user messages and 3 assistant messages
551        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        // Should not error even if no files exist
570        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        // Check that roles alternate properly
607        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        // Create a temp directory structure with an aider file
621        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        // Create an aider history file
626        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        // Scan the directory
630        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        // Create a temp directory with a hidden directory containing an aider file
645        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        // Create an aider history file in the hidden directory
650        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        // Scan the directory - should NOT find the file in hidden dir
654        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        // Create a temp directory with node_modules containing an aider file
667        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        // Create an aider history file in node_modules
672        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        // Scan the directory - should NOT find the file
676        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}