use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use std::fs;
use std::path::{Path, PathBuf};
use uuid::Uuid;
use crate::storage::models::{Message, MessageContent, MessageRole, Session};
use super::{Watcher, WatcherInfo};
pub struct AiderWatcher;
impl Watcher for AiderWatcher {
fn info(&self) -> WatcherInfo {
WatcherInfo {
name: "aider",
description: "Aider terminal AI chat sessions",
default_paths: vec![],
}
}
fn is_available(&self) -> bool {
if std::process::Command::new("aider")
.arg("--version")
.output()
.is_ok()
{
return true;
}
find_aider_history_files()
.map(|files| !files.is_empty())
.unwrap_or(false)
}
fn find_sources(&self) -> Result<Vec<PathBuf>> {
find_aider_history_files()
}
fn parse_source(&self, path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
let parsed = parse_aider_history(path)?;
if parsed.is_empty() {
return Ok(vec![]);
}
Ok(parsed)
}
fn watch_paths(&self) -> Vec<PathBuf> {
vec![]
}
}
fn find_aider_history_files() -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
if let Some(home) = dirs::home_dir() {
let home_history = home.join(".aider.chat.history.md");
if home_history.exists() {
files.push(home_history);
}
for dir_name in &["projects", "code", "src", "dev", "workspace", "repos"] {
let dir = home.join(dir_name);
if dir.exists() {
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.filter_map(|e| e.ok()) {
let history_file = entry.path().join(".aider.chat.history.md");
if history_file.exists() {
files.push(history_file);
}
}
}
}
}
}
Ok(files)
}
const SKIP_DIRS: &[&str] = &[
".git",
".svn",
".hg",
"node_modules",
"target",
"build",
"dist",
"out",
"__pycache__",
".pytest_cache",
".mypy_cache",
"venv",
".venv",
"env",
".env",
".tox",
".nox",
".npm",
".yarn",
".pnpm",
".cargo",
".rustup",
".idea",
".vscode",
".eclipse",
".cache",
".local",
".config",
".Trash",
"Library",
"vendor",
".bundle",
];
const ALLOW_HIDDEN_DIRS: &[&str] = &[".claude", ".continue", ".codex", ".amp"];
pub fn scan_directories_for_aider_files<F>(
directories: &[PathBuf],
mut progress_callback: F,
) -> Vec<PathBuf>
where
F: FnMut(&Path, usize),
{
let mut found_files = Vec::new();
for dir in directories {
if dir.exists() && dir.is_dir() {
scan_directory_recursive(dir, &mut found_files, &mut progress_callback);
}
}
found_files
}
fn scan_directory_recursive<F>(
dir: &Path,
found_files: &mut Vec<PathBuf>,
progress_callback: &mut F,
) where
F: FnMut(&Path, usize),
{
progress_callback(dir, found_files.len());
let history_file = dir.join(".aider.chat.history.md");
if history_file.exists() {
found_files.push(history_file);
}
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return, };
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name,
None => continue,
};
if SKIP_DIRS.contains(&dir_name) {
continue;
}
if dir_name.starts_with('.') && !ALLOW_HIDDEN_DIRS.contains(&dir_name) {
continue;
}
scan_directory_recursive(&path, found_files, progress_callback);
}
}
fn parse_aider_history(path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
let content = fs::read_to_string(path).context("Failed to read Aider history file")?;
let working_directory = path
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| ".".to_string());
let mut sessions = Vec::new();
let mut current_messages: Vec<ParsedMessage> = Vec::new();
let mut current_role: Option<MessageRole> = None;
let mut current_content = String::new();
let mut in_tool_output = false;
for line in content.lines() {
if line.starts_with("#### ") {
if let Some(role) = current_role.take() {
if !current_content.trim().is_empty() {
current_messages.push(ParsedMessage {
role,
content: current_content.trim().to_string(),
});
}
}
current_role = Some(MessageRole::User);
current_content = line.strip_prefix("#### ").unwrap_or("").to_string();
in_tool_output = false;
}
else if line.starts_with("> ") || line == ">" {
if current_role == Some(MessageRole::User) && !current_content.trim().is_empty() {
current_messages.push(ParsedMessage {
role: MessageRole::User,
content: current_content.trim().to_string(),
});
current_content.clear();
current_role = Some(MessageRole::Assistant);
} else if current_role.is_none() {
current_role = Some(MessageRole::Assistant);
}
in_tool_output = true;
let tool_line = line
.strip_prefix("> ")
.unwrap_or(line.strip_prefix(">").unwrap_or(""));
if !current_content.is_empty() {
current_content.push('\n');
}
current_content.push_str(tool_line);
}
else if line.trim().is_empty() {
if in_tool_output {
in_tool_output = false;
if !current_content.is_empty() {
current_content.push('\n');
}
} else if current_role == Some(MessageRole::User) && !current_content.trim().is_empty()
{
current_messages.push(ParsedMessage {
role: MessageRole::User,
content: current_content.trim().to_string(),
});
current_content.clear();
current_role = Some(MessageRole::Assistant);
} else if current_role == Some(MessageRole::Assistant) {
if !current_content.is_empty() {
current_content.push('\n');
}
}
}
else {
if current_role.is_none() {
current_role = Some(MessageRole::Assistant);
} else if current_role == Some(MessageRole::User) && !line.starts_with("####") {
if !current_content.trim().is_empty() {
current_messages.push(ParsedMessage {
role: MessageRole::User,
content: current_content.trim().to_string(),
});
current_content.clear();
current_role = Some(MessageRole::Assistant);
}
}
if !current_content.is_empty() {
current_content.push('\n');
}
current_content.push_str(line);
}
}
if let Some(role) = current_role {
if !current_content.trim().is_empty() {
current_messages.push(ParsedMessage {
role,
content: current_content.trim().to_string(),
});
}
}
if !current_messages.is_empty() {
let session = create_session(path, &working_directory, current_messages.len());
let messages = create_messages(&session, ¤t_messages);
sessions.push((session, messages));
}
Ok(sessions)
}
struct ParsedMessage {
role: MessageRole,
content: String,
}
fn create_session(path: &Path, working_directory: &str, message_count: usize) -> Session {
let ended_at = fs::metadata(path)
.ok()
.and_then(|m| m.modified().ok())
.map(DateTime::<Utc>::from);
let started_at = ended_at
.map(|t| t - chrono::Duration::minutes(message_count as i64 * 2))
.unwrap_or_else(Utc::now);
Session {
id: Uuid::new_v4(),
tool: "aider".to_string(),
tool_version: None,
started_at,
ended_at,
model: None,
working_directory: working_directory.to_string(),
git_branch: None,
source_path: Some(path.to_string_lossy().to_string()),
message_count: message_count as i32,
machine_id: crate::storage::get_machine_id(),
}
}
fn create_messages(session: &Session, parsed_messages: &[ParsedMessage]) -> Vec<Message> {
let time_per_message = chrono::Duration::seconds(30);
let mut current_time = session.started_at;
parsed_messages
.iter()
.enumerate()
.map(|(idx, msg)| {
let message = Message {
id: Uuid::new_v4(),
session_id: session.id,
parent_id: None,
index: idx as i32,
timestamp: current_time,
role: msg.role.clone(),
content: MessageContent::Text(msg.content.clone()),
model: None,
git_branch: None,
cwd: Some(session.working_directory.clone()),
};
current_time += time_per_message;
message
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_temp_history_file(content: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().expect("Failed to create temp file");
file.write_all(content.as_bytes())
.expect("Failed to write content");
file.flush().expect("Failed to flush");
file
}
#[test]
fn test_parse_simple_conversation() {
let content = r#"#### Hello, can you help me with a Rust project?
Sure! I'd be happy to help you with your Rust project. What would you like to do?
#### Can you create a simple function?
Here's a simple function:
```rust
fn hello() {
println!("Hello, world!");
}
```
"#;
let file = create_temp_history_file(content);
let result = parse_aider_history(file.path()).expect("Should parse");
assert_eq!(result.len(), 1);
let (session, messages) = &result[0];
assert_eq!(session.tool, "aider");
assert!(messages.len() >= 2);
}
#[test]
fn test_parse_with_tool_output() {
let content = r#"#### Run the tests
> Running tests...
> test result: ok. 5 passed; 0 failed
All tests passed successfully!
"#;
let file = create_temp_history_file(content);
let result = parse_aider_history(file.path()).expect("Should parse");
assert_eq!(result.len(), 1);
let (_, messages) = &result[0];
assert!(!messages.is_empty());
}
#[test]
fn test_parse_empty_file() {
let content = "";
let file = create_temp_history_file(content);
let result = parse_aider_history(file.path()).expect("Should parse");
assert!(result.is_empty());
}
#[test]
fn test_parse_user_message_only() {
let content = "#### What is Rust?\n";
let file = create_temp_history_file(content);
let result = parse_aider_history(file.path()).expect("Should parse");
assert_eq!(result.len(), 1);
let (_, messages) = &result[0];
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].role, MessageRole::User);
}
#[test]
fn test_parse_multiple_exchanges() {
let content = r#"#### First question
First answer
#### Second question
Second answer
#### Third question
Third answer
"#;
let file = create_temp_history_file(content);
let result = parse_aider_history(file.path()).expect("Should parse");
assert_eq!(result.len(), 1);
let (_, messages) = &result[0];
assert!(messages.len() >= 3);
}
#[test]
fn test_session_metadata() {
let content = "#### Test message\n\nTest response\n";
let file = create_temp_history_file(content);
let result = parse_aider_history(file.path()).expect("Should parse");
let (session, _) = &result[0];
assert_eq!(session.tool, "aider");
assert!(session.source_path.is_some());
assert!(session.ended_at.is_some());
}
#[test]
fn test_find_aider_history_files_returns_ok() {
let result = find_aider_history_files();
assert!(result.is_ok());
}
#[test]
fn test_watcher_parse_source() {
let watcher = AiderWatcher;
let content = "#### Test\n\nResponse\n";
let file = create_temp_history_file(content);
let result = watcher
.parse_source(file.path())
.expect("Should parse successfully");
assert!(!result.is_empty());
let (session, _) = &result[0];
assert_eq!(session.tool, "aider");
}
#[test]
fn test_message_roles_alternate() {
let content = r#"#### User message 1
Assistant response 1
#### User message 2
Assistant response 2
"#;
let file = create_temp_history_file(content);
let result = parse_aider_history(file.path()).expect("Should parse");
let (_, messages) = &result[0];
assert!(messages.len() >= 2);
for (i, msg) in messages.iter().enumerate() {
if i % 2 == 0 {
assert_eq!(msg.role, MessageRole::User);
} else {
assert_eq!(msg.role, MessageRole::Assistant);
}
}
}
#[test]
fn test_scan_directories_finds_aider_files() {
use tempfile::TempDir;
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let project_dir = temp_dir.path().join("my-project");
std::fs::create_dir(&project_dir).expect("Failed to create project dir");
let history_file = project_dir.join(".aider.chat.history.md");
std::fs::write(&history_file, "#### Test\n\nResponse\n").expect("Failed to write file");
let mut progress_calls = 0;
let found = scan_directories_for_aider_files(&[temp_dir.path().to_path_buf()], |_, _| {
progress_calls += 1;
});
assert_eq!(found.len(), 1);
assert_eq!(found[0], history_file);
assert!(progress_calls > 0, "Progress callback should be called");
}
#[test]
fn test_scan_directories_skips_hidden_dirs() {
use tempfile::TempDir;
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let hidden_dir = temp_dir.path().join(".hidden-project");
std::fs::create_dir(&hidden_dir).expect("Failed to create hidden dir");
let history_file = hidden_dir.join(".aider.chat.history.md");
std::fs::write(&history_file, "#### Test\n\nResponse\n").expect("Failed to write file");
let found = scan_directories_for_aider_files(&[temp_dir.path().to_path_buf()], |_, _| {});
assert!(
found.is_empty(),
"Should not find files in hidden directories"
);
}
#[test]
fn test_scan_directories_skips_node_modules() {
use tempfile::TempDir;
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let node_modules = temp_dir.path().join("node_modules").join("some-package");
std::fs::create_dir_all(&node_modules).expect("Failed to create node_modules");
let history_file = node_modules.join(".aider.chat.history.md");
std::fs::write(&history_file, "#### Test\n\nResponse\n").expect("Failed to write file");
let found = scan_directories_for_aider_files(&[temp_dir.path().to_path_buf()], |_, _| {});
assert!(found.is_empty(), "Should not find files in node_modules");
}
#[test]
fn test_scan_directories_empty_input() {
let found = scan_directories_for_aider_files(&[], |_, _| {});
assert!(found.is_empty());
}
}