use super::{SessionMessage, SessionProvider, SessionSummary, ShareError, SharedSession, MessageRole};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
const HISTORY_FILENAME: &str = ".aider.chat.history.md";
const MAX_DEPTH: usize = 6;
const SKIP_DIRS: &[&str] = &[
"node_modules", "target", ".git", "dist", "build", "vendor",
"__pycache__", ".next", "out", ".venv", "venv",
];
#[derive(Default)]
pub struct AiderProvider {
search_roots_override: Option<Vec<PathBuf>>,
}
impl AiderProvider {
pub fn with_search_roots(roots: Vec<PathBuf>) -> Self {
Self { search_roots_override: Some(roots) }
}
fn search_roots(&self) -> Vec<PathBuf> {
if let Some(r) = &self.search_roots_override {
return r.clone();
}
if let Ok(env) = std::env::var("ISELF_AIDER_SEARCH_ROOTS") {
return env
.split(':')
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect();
}
dirs::home_dir().map(|h| vec![h]).unwrap_or_default()
}
fn find_history_files(&self) -> Vec<PathBuf> {
let mut out = Vec::new();
for root in self.search_roots() {
for entry in WalkDir::new(&root)
.max_depth(MAX_DEPTH)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
!e.file_name()
.to_str()
.map(|n| SKIP_DIRS.contains(&n))
.unwrap_or(false)
})
.filter_map(|e| e.ok())
{
if entry.file_name() == HISTORY_FILENAME {
out.push(entry.path().to_path_buf());
}
}
}
out
}
}
impl SessionProvider for AiderProvider {
fn name(&self) -> &str {
"aider"
}
fn list_sessions(&self) -> Result<Vec<SessionSummary>, ShareError> {
let mut out = Vec::new();
for path in self.find_history_files() {
let project_path = path.parent().map(|p| p.to_path_buf());
let id = aider_id_for(&path);
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let (started_at, message_count, title_hint) = scan_summary(&content);
out.push(SessionSummary {
provider: "aider".to_string(),
id,
project_path,
started_at,
message_count,
title_hint,
imported: false, });
}
Ok(out)
}
fn load_session(&self, id: &str) -> Result<SharedSession, ShareError> {
for path in self.find_history_files() {
if aider_id_for(&path) == id {
let content = std::fs::read_to_string(&path)?;
let messages = parse_history(&content);
let started_at = first_timestamp(&content);
return Ok(SharedSession {
provider: "aider".to_string(),
id: id.to_string(),
project_path: path.parent().map(|p| p.to_path_buf()),
started_at,
messages,
});
}
}
Err(ShareError::NotFound(id.to_string()))
}
}
fn aider_id_for(history_path: &Path) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
history_path.hash(&mut h);
format!("aider-{:016x}", h.finish())
}
fn first_timestamp(content: &str) -> Option<chrono::DateTime<chrono::Utc>> {
for line in content.lines() {
if let Some(rest) = line.strip_prefix("# aider chat started at ") {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(rest.trim(), "%Y-%m-%d %H:%M:%S")
{
return Some(dt.and_utc());
}
}
}
None
}
fn scan_summary(content: &str) -> (Option<chrono::DateTime<chrono::Utc>>, usize, Option<String>) {
let started_at = first_timestamp(content);
let messages = parse_history(content);
let title_hint = messages
.iter()
.find(|m| m.role == MessageRole::User)
.map(|m| m.content.chars().take(80).collect::<String>());
(started_at, messages.len(), title_hint)
}
fn parse_history(content: &str) -> Vec<SessionMessage> {
let mut out: Vec<SessionMessage> = Vec::new();
let mut user_buf: Vec<String> = Vec::new();
let mut asst_buf: Vec<String> = Vec::new();
let flush = |out: &mut Vec<SessionMessage>,
user_buf: &mut Vec<String>,
asst_buf: &mut Vec<String>| {
if !user_buf.is_empty() {
out.push(SessionMessage {
role: MessageRole::User,
content: user_buf.join("\n"),
timestamp: None,
metadata: Default::default(),
});
user_buf.clear();
}
if !asst_buf.is_empty() {
out.push(SessionMessage {
role: MessageRole::Assistant,
content: asst_buf.join("\n"),
timestamp: None,
metadata: Default::default(),
});
asst_buf.clear();
}
};
for line in content.lines() {
if line.starts_with("# aider chat") {
flush(&mut out, &mut user_buf, &mut asst_buf);
continue;
}
if let Some(stripped) = line.strip_prefix("> ") {
if !asst_buf.is_empty() {
flush(&mut out, &mut user_buf, &mut asst_buf);
}
user_buf.push(stripped.to_string());
} else if line == ">" {
if !asst_buf.is_empty() {
flush(&mut out, &mut user_buf, &mut asst_buf);
}
user_buf.push(String::new());
} else if line.trim().is_empty() {
flush(&mut out, &mut user_buf, &mut asst_buf);
} else {
if !user_buf.is_empty() {
flush(&mut out, &mut user_buf, &mut asst_buf);
}
asst_buf.push(line.to_string());
}
}
flush(&mut out, &mut user_buf, &mut asst_buf);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn write_history(root: &Path, project: &str, content: &str) -> PathBuf {
let dir = root.join(project);
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join(HISTORY_FILENAME);
std::fs::write(&p, content).unwrap();
p
}
#[test]
fn parses_simple_alternating_history() {
let h = "# aider chat started at 2026-05-06 09:30:00\n\
\n\
> Refactor the auth module\n\
> to use JWT\n\
\n\
Sure — I'll start by replacing the session check.\n\
\n\
> Run the tests\n\
\n\
All passing.";
let msgs = parse_history(h);
assert_eq!(msgs.len(), 4);
assert_eq!(msgs[0].role, MessageRole::User);
assert_eq!(msgs[0].content, "Refactor the auth module\nto use JWT");
assert_eq!(msgs[1].role, MessageRole::Assistant);
assert!(msgs[1].content.contains("session check"));
assert_eq!(msgs[2].role, MessageRole::User);
assert_eq!(msgs[3].role, MessageRole::Assistant);
}
#[test]
fn discovers_history_under_search_root() {
let tmp = tempfile::tempdir().unwrap();
write_history(
tmp.path(),
"myproj",
"# aider chat started at 2026-05-06 09:30:00\n\n> hello\n\nhi back",
);
let p = AiderProvider::with_search_roots(vec![tmp.path().to_path_buf()]);
let sessions = p.list_sessions().unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].provider, "aider");
assert_eq!(sessions[0].title_hint.as_deref(), Some("hello"));
assert!(sessions[0].started_at.is_some());
let id = sessions[0].id.clone();
let s = p.load_session(&id).unwrap();
assert_eq!(s.messages.len(), 2);
}
#[test]
fn skips_node_modules() {
let tmp = tempfile::tempdir().unwrap();
write_history(
tmp.path(),
"node_modules/some-pkg",
"> nope\n\nignore me",
);
write_history(
tmp.path(),
"real-proj",
"> real\n\nyes",
);
let p = AiderProvider::with_search_roots(vec![tmp.path().to_path_buf()]);
let sessions = p.list_sessions().unwrap();
assert_eq!(sessions.len(), 1, "node_modules should be skipped");
assert!(sessions[0]
.project_path
.as_ref()
.unwrap()
.ends_with("real-proj"));
}
}