1use 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#[derive(Debug, Clone, Default)]
20pub struct AiderAdapter;
21
22impl AiderAdapter {
23 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 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 self.apply_config(root, config).await?;
75
76 let hooks_dir = root.join(".git").join("hooks");
78 if !hooks_dir.is_dir() {
79 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 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 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 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 timeout_secs: Option<u64>,
180}
181
182const DEFAULT_AIDER_TIMEOUT_SECS: u64 = 300;
183
184async 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
224async 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 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 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 assert!(!tmp.path().join("aider.conf.yml").exists());
449 }
450}