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, project_root) = match log {
117            SessionLog::GitCommit { sha, project_root } => (sha, project_root),
118            _ => {
119                return Err(AdapterError::Parse(
120                    "aider adapter expects GitCommit log".into(),
121                ));
122            }
123        };
124
125        // No baseline signal: emitting a "neutral 0.5" for every commit was
126        // misleading — it pulled the posterior towards 0.5 indefinitely for
127        // users who didn't configure test-cmd / lint-cmd, so experiments
128        // would Hold forever near indifference. Empty signal vec means the
129        // session aggregates to 0.5 (neutral prior) once, which is correct
130        // semantics for "no information".
131        let mut signals = Vec::new();
132
133        if let Some(root) = project_root.as_deref() {
134            let cmds = read_aider_cmds(root).await.unwrap_or_default();
135            let to = resolve_timeout(&cmds);
136            if let Some(test_cmd) = cmds.test_cmd.as_deref() {
137                signals.push(run_and_signal(root, test_cmd, "aider_tests", to).await);
138            }
139            if let Some(lint_cmd) = cmds.lint_cmd.as_deref() {
140                signals.push(run_and_signal(root, lint_cmd, "aider_lint", to).await);
141            }
142        }
143        Ok(signals)
144    }
145
146    async fn forget(&self, root: &Path) -> Result<(), AdapterError> {
147        // Strip managed section from aider.conf.yml.
148        let conf = Self::conf_path(root);
149        if conf.is_file() {
150            let raw = fs::read_to_string(&conf).await?;
151            let stripped = strip_managed_section(&raw);
152            if stripped.trim().is_empty() {
153                fs::remove_file(&conf).await?;
154            } else {
155                fs::write(&conf, stripped).await?;
156            }
157        }
158        // Remove hook block.
159        let hook = Self::post_commit_hook_path(root);
160        if hook.is_file() {
161            let raw = fs::read_to_string(&hook).await?;
162            let stripped = raw
163                .lines()
164                .filter(|line| !line.contains(HOOK_MARKER) && !line.contains("evolve:hook-"))
165                .collect::<Vec<_>>()
166                .join("\n");
167            fs::write(&hook, stripped).await?;
168        }
169        Ok(())
170    }
171}
172
173#[derive(Debug, Default, Clone)]
174struct AiderCmds {
175    test_cmd: Option<String>,
176    lint_cmd: Option<String>,
177    /// Per-project timeout override (seconds). `None` means use the
178    /// `EVOLVE_AIDER_TIMEOUT_SECS` env var, falling back to 300s.
179    timeout_secs: Option<u64>,
180}
181
182const DEFAULT_AIDER_TIMEOUT_SECS: u64 = 300;
183
184/// Read `test-cmd:` and `lint-cmd:` and `evolve-timeout-secs:` values from
185/// `aider.conf.yml`. Minimal YAML parsing — looks only for `key: value` at
186/// column 0.
187async fn read_aider_cmds(root: &Path) -> Option<AiderCmds> {
188    let conf = root.join("aider.conf.yml");
189    if !conf.is_file() {
190        return None;
191    }
192    let raw = fs::read_to_string(&conf).await.ok()?;
193    let mut out = AiderCmds::default();
194    for line in raw.lines() {
195        let trimmed = line.trim_start();
196        if trimmed.starts_with('#') {
197            continue;
198        }
199        if let Some(rest) = trimmed.strip_prefix("test-cmd:") {
200            out.test_cmd = Some(rest.trim().trim_matches('"').to_string());
201        } else if let Some(rest) = trimmed.strip_prefix("lint-cmd:") {
202            out.lint_cmd = Some(rest.trim().trim_matches('"').to_string());
203        } else if let Some(rest) = trimmed.strip_prefix("evolve-timeout-secs:") {
204            if let Ok(n) = rest.trim().parse::<u64>() {
205                out.timeout_secs = Some(n);
206            }
207        }
208    }
209    Some(out)
210}
211
212fn resolve_timeout(cmds: &AiderCmds) -> u64 {
213    if let Some(t) = cmds.timeout_secs {
214        return t;
215    }
216    if let Ok(s) = std::env::var("EVOLVE_AIDER_TIMEOUT_SECS") {
217        if let Ok(n) = s.parse::<u64>() {
218            return n;
219        }
220    }
221    DEFAULT_AIDER_TIMEOUT_SECS
222}
223
224/// Run `cmd` in `root` and return a signal based on exit code.
225/// Timeout: `evolve-timeout-secs:` in aider.conf.yml, else
226/// `EVOLVE_AIDER_TIMEOUT_SECS` env var, else 300s. Timeouts count as error.
227async fn run_and_signal(
228    root: &Path,
229    cmd: &str,
230    source_tag: &str,
231    timeout_secs: u64,
232) -> ParsedSignal {
233    use tokio::process::Command;
234    use tokio::time::{Duration, timeout};
235
236    let output = timeout(
237        Duration::from_secs(timeout_secs),
238        if cfg!(windows) {
239            Command::new("cmd")
240                .arg("/C")
241                .arg(cmd)
242                .current_dir(root)
243                .output()
244        } else {
245            Command::new("sh")
246                .arg("-c")
247                .arg(cmd)
248                .current_dir(root)
249                .output()
250        },
251    )
252    .await;
253
254    let (value, source) = match output {
255        Ok(Ok(o)) if o.status.success() => (1.0, format!("{source_tag}_passed")),
256        Ok(Ok(_)) => (0.0, format!("{source_tag}_failed")),
257        Ok(Err(_)) | Err(_) => (0.0, format!("{source_tag}_error")),
258    };
259    ParsedSignal {
260        kind: SignalKind::Implicit,
261        source,
262        value,
263        payload_json: None,
264    }
265}
266
267fn replace_managed_section(existing: &str, new_body: &str) -> String {
268    let block = format!("{MANAGED_START}\n{}\n{MANAGED_END}", new_body.trim_end());
269    if let (Some(start), Some(end)) = (existing.find(MANAGED_START), existing.find(MANAGED_END)) {
270        if end > start {
271            let end_full = end + MANAGED_END.len();
272            let mut out = String::new();
273            out.push_str(&existing[..start]);
274            out.push_str(&block);
275            out.push_str(&existing[end_full..]);
276            return out;
277        }
278    }
279    let mut out = String::from(existing);
280    if !out.is_empty() && !out.ends_with('\n') {
281        out.push('\n');
282    }
283    if !out.is_empty() {
284        out.push('\n');
285    }
286    out.push_str(&block);
287    out.push('\n');
288    out
289}
290
291fn strip_managed_section(existing: &str) -> String {
292    if let (Some(start), Some(end)) = (existing.find(MANAGED_START), existing.find(MANAGED_END)) {
293        if end > start {
294            let end_full = end + MANAGED_END.len();
295            let mut out = String::new();
296            out.push_str(&existing[..start]);
297            out.push_str(existing[end_full..].trim_start_matches('\n'));
298            return out;
299        }
300    }
301    existing.to_string()
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use tempfile::TempDir;
308
309    fn sample_config() -> AgentConfig {
310        AgentConfig::default_for("aider")
311    }
312
313    #[tokio::test]
314    async fn detect_recognizes_aider_conf() {
315        let tmp = TempDir::new().unwrap();
316        std::fs::write(tmp.path().join("aider.conf.yml"), "model: gpt-4\n").unwrap();
317        assert_eq!(
318            AiderAdapter::new().detect(tmp.path()),
319            AdapterDetection::Detected
320        );
321    }
322
323    #[tokio::test]
324    async fn apply_config_writes_managed_section() {
325        let tmp = TempDir::new().unwrap();
326        AiderAdapter::new()
327            .apply_config(tmp.path(), &sample_config())
328            .await
329            .unwrap();
330        let raw = std::fs::read_to_string(tmp.path().join("aider.conf.yml")).unwrap();
331        assert!(raw.contains(MANAGED_START));
332        assert!(raw.contains("Response style"));
333    }
334
335    #[tokio::test]
336    async fn install_writes_post_commit_hook_when_git_repo() {
337        let tmp = TempDir::new().unwrap();
338        std::fs::create_dir_all(tmp.path().join(".git").join("hooks")).unwrap();
339        AiderAdapter::new()
340            .install(tmp.path(), &sample_config())
341            .await
342            .unwrap();
343        let hook =
344            std::fs::read_to_string(tmp.path().join(".git").join("hooks").join("post-commit"))
345                .unwrap();
346        assert!(hook.contains(HOOK_MARKER));
347    }
348
349    #[tokio::test]
350    async fn install_is_idempotent() {
351        let tmp = TempDir::new().unwrap();
352        std::fs::create_dir_all(tmp.path().join(".git").join("hooks")).unwrap();
353        let adapter = AiderAdapter::new();
354        adapter.install(tmp.path(), &sample_config()).await.unwrap();
355        let once =
356            std::fs::read_to_string(tmp.path().join(".git").join("hooks").join("post-commit"))
357                .unwrap();
358        adapter.install(tmp.path(), &sample_config()).await.unwrap();
359        let twice =
360            std::fs::read_to_string(tmp.path().join(".git").join("hooks").join("post-commit"))
361                .unwrap();
362        assert_eq!(once, twice);
363    }
364
365    #[tokio::test]
366    async fn parse_session_with_no_root_emits_no_signals() {
367        let signals = AiderAdapter::new()
368            .parse_session(SessionLog::GitCommit {
369                sha: "abc123".into(),
370                project_root: None,
371            })
372            .await
373            .unwrap();
374        // Without a project root we can't run test/lint cmds. Emit nothing
375        // rather than a misleading 0.5 baseline.
376        assert!(signals.is_empty());
377    }
378
379    #[tokio::test]
380    async fn parse_session_runs_test_cmd_when_configured() {
381        let tmp = TempDir::new().unwrap();
382        let ok_cmd = if cfg!(windows) { "exit 0" } else { "true" };
383        std::fs::write(
384            tmp.path().join("aider.conf.yml"),
385            format!("test-cmd: {ok_cmd}\n"),
386        )
387        .unwrap();
388        let signals = AiderAdapter::new()
389            .parse_session(SessionLog::GitCommit {
390                sha: "deadbeef".into(),
391                project_root: Some(tmp.path().to_path_buf()),
392            })
393            .await
394            .unwrap();
395        assert_eq!(signals.len(), 1);
396        assert_eq!(signals[0].source, "aider_tests_passed");
397        assert_eq!(signals[0].value, 1.0);
398    }
399
400    #[tokio::test]
401    async fn parse_session_emits_failed_signal_when_test_cmd_fails() {
402        let tmp = TempDir::new().unwrap();
403        let fail_cmd = if cfg!(windows) { "exit 1" } else { "false" };
404        std::fs::write(
405            tmp.path().join("aider.conf.yml"),
406            format!("test-cmd: {fail_cmd}\n"),
407        )
408        .unwrap();
409        let signals = AiderAdapter::new()
410            .parse_session(SessionLog::GitCommit {
411                sha: "c0ffee".into(),
412                project_root: Some(tmp.path().to_path_buf()),
413            })
414            .await
415            .unwrap();
416        assert_eq!(signals.len(), 1);
417        assert_eq!(signals[0].source, "aider_tests_failed");
418        assert_eq!(signals[0].value, 0.0);
419    }
420
421    #[tokio::test]
422    async fn parse_session_with_no_test_cmd_emits_no_signals() {
423        let tmp = TempDir::new().unwrap();
424        // aider.conf.yml exists but has no test-cmd / lint-cmd.
425        std::fs::write(tmp.path().join("aider.conf.yml"), "model: gpt-4\n").unwrap();
426        let signals = AiderAdapter::new()
427            .parse_session(SessionLog::GitCommit {
428                sha: "abc".into(),
429                project_root: Some(tmp.path().to_path_buf()),
430            })
431            .await
432            .unwrap();
433        assert!(signals.is_empty());
434    }
435
436    #[tokio::test]
437    async fn forget_removes_managed_section_and_hook() {
438        let tmp = TempDir::new().unwrap();
439        std::fs::create_dir_all(tmp.path().join(".git").join("hooks")).unwrap();
440        let adapter = AiderAdapter::new();
441        adapter.install(tmp.path(), &sample_config()).await.unwrap();
442        adapter.forget(tmp.path()).await.unwrap();
443        let hook =
444            std::fs::read_to_string(tmp.path().join(".git").join("hooks").join("post-commit"))
445                .unwrap();
446        assert!(!hook.contains(HOOK_MARKER));
447        // aider.conf.yml was created by install with only our section, so forget removes it
448        assert!(!tmp.path().join("aider.conf.yml").exists());
449    }
450}