Skip to main content

evolve_adapters/
aider.rs

1//! Aider adapter.
2//!
3//! Uses `aider.conf.yml` as the configuration surface and a git post-commit
4//! hook for session signals. Sessions are identified by the commit SHA.
5
6use crate::signals::{ParsedSignal, SessionLog, SignalKind};
7use crate::traits::{Adapter, AdapterDetection, AdapterError};
8use async_trait::async_trait;
9use evolve_core::agent_config::AgentConfig;
10use evolve_core::ids::AdapterId;
11use std::path::{Path, PathBuf};
12use tokio::fs;
13
14const MANAGED_START: &str = "# evolve:start";
15const MANAGED_END: &str = "# evolve:end";
16const HOOK_MARKER: &str = "evolve record-aider";
17
18/// Aider integration.
19#[derive(Debug, Clone, Default)]
20pub struct AiderAdapter;
21
22impl AiderAdapter {
23    /// Construct.
24    pub fn new() -> Self {
25        Self
26    }
27
28    fn conf_path(root: &Path) -> PathBuf {
29        root.join("aider.conf.yml")
30    }
31
32    fn post_commit_hook_path(root: &Path) -> PathBuf {
33        root.join(".git").join("hooks").join("post-commit")
34    }
35
36    /// Render a config as the YAML commentary that goes inside the managed section.
37    pub fn render_managed_section(config: &AgentConfig) -> String {
38        let mut out = String::new();
39        out.push_str("# System prompt prefix:\n");
40        for line in config.system_prompt_prefix.lines() {
41            out.push_str(&format!("#   {line}\n"));
42        }
43        if !config.behavioral_rules.is_empty() {
44            out.push_str("# Behavioral rules:\n");
45            for rule in &config.behavioral_rules {
46                out.push_str(&format!("#   - {rule}\n"));
47            }
48        }
49        out.push_str(&format!("# Response style: {:?}\n", config.response_style));
50        out.push_str(&format!("# Model preference: {:?}\n", config.model_pref));
51        out
52    }
53}
54
55#[async_trait]
56impl Adapter for AiderAdapter {
57    fn id(&self) -> AdapterId {
58        AdapterId::new("aider")
59    }
60
61    fn detect(&self, root: &Path) -> AdapterDetection {
62        if root.join("aider.conf.yml").is_file()
63            || root.join(".aider.conf.yml").is_file()
64            || root.join(".aider.tags.cache.v3").exists()
65        {
66            AdapterDetection::Detected
67        } else {
68            AdapterDetection::NotDetected
69        }
70    }
71
72    async fn install(&self, root: &Path, config: &AgentConfig) -> Result<(), AdapterError> {
73        // Write managed section into aider.conf.yml.
74        self.apply_config(root, config).await?;
75
76        // Install post-commit hook if .git/hooks exists.
77        let hooks_dir = root.join(".git").join("hooks");
78        if !hooks_dir.is_dir() {
79            // Not a git repo — skip hook install but not an error.
80            return Ok(());
81        }
82        let hook_path = Self::post_commit_hook_path(root);
83        let existing = if hook_path.is_file() {
84            fs::read_to_string(&hook_path).await?
85        } else {
86            "#!/bin/sh\n".to_string()
87        };
88        if existing.contains(HOOK_MARKER) {
89            return Ok(());
90        }
91        let mut new_hook = existing.clone();
92        if !new_hook.ends_with('\n') {
93            new_hook.push('\n');
94        }
95        new_hook.push_str("# evolve:hook-start\n");
96        new_hook.push_str(&format!("{HOOK_MARKER} HEAD >/dev/null 2>&1 || true\n"));
97        new_hook.push_str("# evolve:hook-end\n");
98        fs::write(&hook_path, new_hook).await?;
99        Ok(())
100    }
101
102    async fn apply_config(&self, root: &Path, config: &AgentConfig) -> Result<(), AdapterError> {
103        let path = Self::conf_path(root);
104        let existing = if path.is_file() {
105            fs::read_to_string(&path).await?
106        } else {
107            String::new()
108        };
109        let new_section = Self::render_managed_section(config);
110        let updated = replace_managed_section(&existing, &new_section);
111        fs::write(&path, updated).await?;
112        Ok(())
113    }
114
115    async fn parse_session(&self, log: SessionLog) -> Result<Vec<ParsedSignal>, AdapterError> {
116        let sha = match log {
117            SessionLog::GitCommit(s) => s,
118            _ => {
119                return Err(AdapterError::Parse(
120                    "aider adapter expects GitCommit log".into(),
121                ));
122            }
123        };
124        // For v1, the only implicit signal we can reliably derive from a bare
125        // commit SHA is "commit exists + was not reverted within N commits".
126        // Full test/lint signals require runtime config (per-project commands)
127        // — emitted as a single "commit_observed" baseline signal for now.
128        Ok(vec![ParsedSignal {
129            kind: SignalKind::Implicit,
130            source: "aider_commit_observed".into(),
131            value: 0.5,
132            payload_json: Some(format!("{{\"sha\":\"{sha}\"}}")),
133        }])
134    }
135
136    async fn forget(&self, root: &Path) -> Result<(), AdapterError> {
137        // Strip managed section from aider.conf.yml.
138        let conf = Self::conf_path(root);
139        if conf.is_file() {
140            let raw = fs::read_to_string(&conf).await?;
141            let stripped = strip_managed_section(&raw);
142            if stripped.trim().is_empty() {
143                fs::remove_file(&conf).await?;
144            } else {
145                fs::write(&conf, stripped).await?;
146            }
147        }
148        // Remove hook block.
149        let hook = Self::post_commit_hook_path(root);
150        if hook.is_file() {
151            let raw = fs::read_to_string(&hook).await?;
152            let stripped = raw
153                .lines()
154                .filter(|line| !line.contains(HOOK_MARKER) && !line.contains("evolve:hook-"))
155                .collect::<Vec<_>>()
156                .join("\n");
157            fs::write(&hook, stripped).await?;
158        }
159        Ok(())
160    }
161}
162
163fn replace_managed_section(existing: &str, new_body: &str) -> String {
164    let block = format!("{MANAGED_START}\n{}\n{MANAGED_END}", new_body.trim_end());
165    if let (Some(start), Some(end)) = (existing.find(MANAGED_START), existing.find(MANAGED_END)) {
166        if end > start {
167            let end_full = end + MANAGED_END.len();
168            let mut out = String::new();
169            out.push_str(&existing[..start]);
170            out.push_str(&block);
171            out.push_str(&existing[end_full..]);
172            return out;
173        }
174    }
175    let mut out = String::from(existing);
176    if !out.is_empty() && !out.ends_with('\n') {
177        out.push('\n');
178    }
179    if !out.is_empty() {
180        out.push('\n');
181    }
182    out.push_str(&block);
183    out.push('\n');
184    out
185}
186
187fn strip_managed_section(existing: &str) -> String {
188    if let (Some(start), Some(end)) = (existing.find(MANAGED_START), existing.find(MANAGED_END)) {
189        if end > start {
190            let end_full = end + MANAGED_END.len();
191            let mut out = String::new();
192            out.push_str(&existing[..start]);
193            out.push_str(existing[end_full..].trim_start_matches('\n'));
194            return out;
195        }
196    }
197    existing.to_string()
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use tempfile::TempDir;
204
205    fn sample_config() -> AgentConfig {
206        AgentConfig::default_for("aider")
207    }
208
209    #[tokio::test]
210    async fn detect_recognizes_aider_conf() {
211        let tmp = TempDir::new().unwrap();
212        std::fs::write(tmp.path().join("aider.conf.yml"), "model: gpt-4\n").unwrap();
213        assert_eq!(
214            AiderAdapter::new().detect(tmp.path()),
215            AdapterDetection::Detected
216        );
217    }
218
219    #[tokio::test]
220    async fn apply_config_writes_managed_section() {
221        let tmp = TempDir::new().unwrap();
222        AiderAdapter::new()
223            .apply_config(tmp.path(), &sample_config())
224            .await
225            .unwrap();
226        let raw = std::fs::read_to_string(tmp.path().join("aider.conf.yml")).unwrap();
227        assert!(raw.contains(MANAGED_START));
228        assert!(raw.contains("Response style"));
229    }
230
231    #[tokio::test]
232    async fn install_writes_post_commit_hook_when_git_repo() {
233        let tmp = TempDir::new().unwrap();
234        std::fs::create_dir_all(tmp.path().join(".git").join("hooks")).unwrap();
235        AiderAdapter::new()
236            .install(tmp.path(), &sample_config())
237            .await
238            .unwrap();
239        let hook =
240            std::fs::read_to_string(tmp.path().join(".git").join("hooks").join("post-commit"))
241                .unwrap();
242        assert!(hook.contains(HOOK_MARKER));
243    }
244
245    #[tokio::test]
246    async fn install_is_idempotent() {
247        let tmp = TempDir::new().unwrap();
248        std::fs::create_dir_all(tmp.path().join(".git").join("hooks")).unwrap();
249        let adapter = AiderAdapter::new();
250        adapter.install(tmp.path(), &sample_config()).await.unwrap();
251        let once =
252            std::fs::read_to_string(tmp.path().join(".git").join("hooks").join("post-commit"))
253                .unwrap();
254        adapter.install(tmp.path(), &sample_config()).await.unwrap();
255        let twice =
256            std::fs::read_to_string(tmp.path().join(".git").join("hooks").join("post-commit"))
257                .unwrap();
258        assert_eq!(once, twice);
259    }
260
261    #[tokio::test]
262    async fn parse_session_emits_commit_observed_signal() {
263        let signals = AiderAdapter::new()
264            .parse_session(SessionLog::GitCommit("abc123".into()))
265            .await
266            .unwrap();
267        assert_eq!(signals.len(), 1);
268        assert_eq!(signals[0].source, "aider_commit_observed");
269        assert!(
270            signals[0]
271                .payload_json
272                .as_deref()
273                .unwrap()
274                .contains("abc123")
275        );
276    }
277
278    #[tokio::test]
279    async fn forget_removes_managed_section_and_hook() {
280        let tmp = TempDir::new().unwrap();
281        std::fs::create_dir_all(tmp.path().join(".git").join("hooks")).unwrap();
282        let adapter = AiderAdapter::new();
283        adapter.install(tmp.path(), &sample_config()).await.unwrap();
284        adapter.forget(tmp.path()).await.unwrap();
285        let hook =
286            std::fs::read_to_string(tmp.path().join(".git").join("hooks").join("post-commit"))
287                .unwrap();
288        assert!(!hook.contains(HOOK_MARKER));
289        // aider.conf.yml was created by install with only our section, so forget removes it
290        assert!(!tmp.path().join("aider.conf.yml").exists());
291    }
292}