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 = match log {
117 SessionLog::GitCommit(s) => s,
118 _ => {
119 return Err(AdapterError::Parse(
120 "aider adapter expects GitCommit log".into(),
121 ));
122 }
123 };
124 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 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 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 assert!(!tmp.path().join("aider.conf.yml").exists());
291 }
292}