evolve_adapters/
cursor.rs1use crate::signals::{ParsedSignal, SessionLog, SignalKind};
9use crate::traits::{Adapter, AdapterDetection, AdapterError};
10use async_trait::async_trait;
11use evolve_core::agent_config::AgentConfig;
12use evolve_core::ids::AdapterId;
13use std::path::{Path, PathBuf};
14use tokio::fs;
15
16const MANAGED_START: &str = "<!-- evolve:start -->";
17const MANAGED_END: &str = "<!-- evolve:end -->";
18
19#[derive(Debug, Clone, Default)]
21pub struct CursorAdapter;
22
23impl CursorAdapter {
24 pub fn new() -> Self {
26 Self
27 }
28
29 fn cursorrules_path(root: &Path) -> PathBuf {
30 root.join(".cursorrules")
31 }
32
33 pub fn render_managed_section(config: &AgentConfig) -> String {
35 let mut out = String::new();
36 out.push_str("# Evolve-managed rules\n\n");
37 out.push_str(&config.system_prompt_prefix);
38 out.push_str("\n\n");
39 if !config.behavioral_rules.is_empty() {
40 for rule in &config.behavioral_rules {
41 out.push_str(&format!("- {rule}\n"));
42 }
43 out.push('\n');
44 }
45 out.push_str(&format!("Response style: {:?}\n", config.response_style));
46 out
47 }
48}
49
50#[async_trait]
51impl Adapter for CursorAdapter {
52 fn id(&self) -> AdapterId {
53 AdapterId::new("cursor")
54 }
55
56 fn detect(&self, root: &Path) -> AdapterDetection {
57 if root.join(".cursorrules").is_file() || root.join(".cursor").is_dir() {
60 AdapterDetection::Detected
61 } else {
62 AdapterDetection::NotDetected
63 }
64 }
65
66 async fn install(&self, root: &Path, config: &AgentConfig) -> Result<(), AdapterError> {
67 self.apply_config(root, config).await
69 }
70
71 async fn apply_config(&self, root: &Path, config: &AgentConfig) -> Result<(), AdapterError> {
72 let path = Self::cursorrules_path(root);
73 let existing = if path.is_file() {
74 fs::read_to_string(&path).await?
75 } else {
76 String::new()
77 };
78 let new_section = Self::render_managed_section(config);
79 let updated = replace_managed_section(&existing, &new_section);
80 fs::write(&path, updated).await?;
81 Ok(())
82 }
83
84 async fn parse_session(&self, log: SessionLog) -> Result<Vec<ParsedSignal>, AdapterError> {
85 match log {
86 SessionLog::ProxyEvent(event) => Ok(parse_proxy_event(&event)),
87 _ => Err(AdapterError::Parse(
88 "cursor adapter expects ProxyEvent logs".into(),
89 )),
90 }
91 }
92
93 async fn forget(&self, root: &Path) -> Result<(), AdapterError> {
94 let path = Self::cursorrules_path(root);
95 if path.is_file() {
96 let raw = fs::read_to_string(&path).await?;
97 let stripped = strip_managed_section(&raw);
98 if stripped.trim().is_empty() {
99 fs::remove_file(&path).await?;
101 } else {
102 fs::write(&path, stripped).await?;
103 }
104 }
105 Ok(())
106 }
107}
108
109fn replace_managed_section(existing: &str, new_body: &str) -> String {
110 let block = format!("{MANAGED_START}\n{}\n{MANAGED_END}", new_body.trim_end());
111 if let (Some(start), Some(end)) = (existing.find(MANAGED_START), existing.find(MANAGED_END)) {
112 if end > start {
113 let end_full = end + MANAGED_END.len();
114 let mut out = String::new();
115 out.push_str(&existing[..start]);
116 out.push_str(&block);
117 out.push_str(&existing[end_full..]);
118 return out;
119 }
120 }
121 let mut out = String::from(existing);
122 if !out.is_empty() && !out.ends_with('\n') {
123 out.push('\n');
124 }
125 if !out.is_empty() {
126 out.push('\n');
127 }
128 out.push_str(&block);
129 out.push('\n');
130 out
131}
132
133fn strip_managed_section(existing: &str) -> String {
134 if let (Some(start), Some(end)) = (existing.find(MANAGED_START), existing.find(MANAGED_END)) {
135 if end > start {
136 let end_full = end + MANAGED_END.len();
137 let mut out = String::new();
138 out.push_str(&existing[..start]);
139 out.push_str(existing[end_full..].trim_start_matches('\n'));
140 return out;
141 }
142 }
143 existing.to_string()
144}
145
146fn parse_proxy_event(event: &serde_json::Value) -> Vec<ParsedSignal> {
149 let event_type = event.get("event").and_then(|v| v.as_str()).unwrap_or("");
150 match event_type {
151 "suggestion_accepted" => vec![ParsedSignal {
152 kind: SignalKind::Implicit,
153 source: "cursor_suggestion_accepted".into(),
154 value: 1.0,
155 payload_json: None,
156 }],
157 "suggestion_rejected" => vec![ParsedSignal {
158 kind: SignalKind::Implicit,
159 source: "cursor_suggestion_rejected".into(),
160 value: 0.0,
161 payload_json: None,
162 }],
163 _ => Vec::new(),
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use tempfile::TempDir;
171
172 fn sample_config() -> AgentConfig {
173 AgentConfig::default_for("cursor")
174 }
175
176 #[tokio::test]
177 async fn detect_recognizes_cursorrules() {
178 let tmp = TempDir::new().unwrap();
179 std::fs::write(tmp.path().join(".cursorrules"), "").unwrap();
180 assert_eq!(
181 CursorAdapter::new().detect(tmp.path()),
182 AdapterDetection::Detected
183 );
184 }
185
186 #[tokio::test]
187 async fn apply_config_writes_managed_section() {
188 let tmp = TempDir::new().unwrap();
189 CursorAdapter::new()
190 .apply_config(tmp.path(), &sample_config())
191 .await
192 .unwrap();
193 let raw = std::fs::read_to_string(tmp.path().join(".cursorrules")).unwrap();
194 assert!(raw.contains(MANAGED_START));
195 assert!(raw.contains("Evolve-managed rules"));
196 }
197
198 #[tokio::test]
199 async fn apply_config_preserves_user_content() {
200 let tmp = TempDir::new().unwrap();
201 let user = "# my rules\nBe concise.\n";
202 std::fs::write(tmp.path().join(".cursorrules"), user).unwrap();
203 CursorAdapter::new()
204 .apply_config(tmp.path(), &sample_config())
205 .await
206 .unwrap();
207 let raw = std::fs::read_to_string(tmp.path().join(".cursorrules")).unwrap();
208 assert!(raw.contains("Be concise."));
209 assert!(raw.contains(MANAGED_START));
210 }
211
212 #[tokio::test]
213 async fn parse_session_extracts_accept_reject() {
214 let accept = serde_json::json!({"event": "suggestion_accepted"});
215 let signals = CursorAdapter::new()
216 .parse_session(SessionLog::ProxyEvent(accept))
217 .await
218 .unwrap();
219 assert_eq!(signals[0].value, 1.0);
220
221 let reject = serde_json::json!({"event": "suggestion_rejected"});
222 let signals = CursorAdapter::new()
223 .parse_session(SessionLog::ProxyEvent(reject))
224 .await
225 .unwrap();
226 assert_eq!(signals[0].value, 0.0);
227 }
228
229 #[tokio::test]
230 async fn forget_removes_managed_section_keeps_user_content() {
231 let tmp = TempDir::new().unwrap();
232 let user_then_managed =
233 format!("# user\nrules\n\n{MANAGED_START}\nmanaged\n{MANAGED_END}\n",);
234 std::fs::write(tmp.path().join(".cursorrules"), &user_then_managed).unwrap();
235 CursorAdapter::new().forget(tmp.path()).await.unwrap();
236 let raw = std::fs::read_to_string(tmp.path().join(".cursorrules")).unwrap();
237 assert!(raw.contains("# user"));
238 assert!(!raw.contains("managed"));
239 }
240}