i-self 0.4.3

Personal developer-companion CLI: scans your repos, indexes code semantically, watches your activity, and moves AI-agent sessions between tools (Claude Code, Aider, Goose, OpenAI Codex CLI, Continue.dev, OpenCode).
//! Aider session provider.
//!
//! Aider stores chat history per project as a single Markdown file at
//! `<project>/.aider.chat.history.md`. Format (informal):
//!
//! ```text
//! # aider chat started at YYYY-MM-DD HH:MM:SS
//!
//! > user message line 1
//! > user message line 2
//!
//! assistant reply paragraph 1
//!
//! assistant reply paragraph 2
//!
//! > next user message
//! ```
//!
//! `>` prefixes user lines, blank lines separate turns, and the assistant
//! reply is everything until the next `>` block. We treat each `# aider chat
//! started at ...` header as a new session (rare for repeated sessions in
//! the same project; usually there's just one growing file).
//!
//! Discovery searches under `$ISELF_AIDER_SEARCH_ROOTS` (colon-separated)
//! when set, otherwise uses the user's home directory. To avoid scanning
//! gigabytes of `node_modules`, we walk only to a fixed depth and skip
//! common build dirs.

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 {
    /// Override search roots for tests. Production: empty → use $ISELF_AIDER_SEARCH_ROOTS or $HOME.
    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());
            // Aider stuffs all chats into one growing file; we expose it as a
            // single "session" keyed by the project path. Multi-session
            // splitting happens at render time if needed.
            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, // populated centrally in `list_all_sessions`
            });
        }
        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()))
    }
}

/// Aider doesn't have session UUIDs — synthesize a stable id from the
/// project path. Hash so it doesn't disclose the absolute filesystem path
/// in shared output.
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 ") {
            // Format: YYYY-MM-DD HH:MM:SS (local time, no zone). Parse as naive,
            // assume UTC for sorting purposes — close enough for a sort key.
            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)
}

/// Walk the markdown file producing alternating User/Assistant messages.
/// State machine: lines beginning with `> ` accumulate into a user buffer;
/// any non-blank, non-`>` line accumulates into an assistant buffer; a
/// blank line flushes whichever buffer is open.
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("> ") {
            // Switching to user: flush any pending assistant content first.
            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() {
            // Blank line: flush the current open buffer (only one is open at a time).
            flush(&mut out, &mut user_buf, &mut asst_buf);
        } else {
            // Non-`>` non-blank: assistant. If user_buf is open, flush it.
            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"));
    }
}