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(".vscode").is_dir() {
58 AdapterDetection::Detected
59 } else {
60 AdapterDetection::NotDetected
61 }
62 }
63
64 async fn install(&self, root: &Path, config: &AgentConfig) -> Result<(), AdapterError> {
65 self.apply_config(root, config).await
67 }
68
69 async fn apply_config(&self, root: &Path, config: &AgentConfig) -> Result<(), AdapterError> {
70 let path = Self::cursorrules_path(root);
71 let existing = if path.is_file() {
72 fs::read_to_string(&path).await?
73 } else {
74 String::new()
75 };
76 let new_section = Self::render_managed_section(config);
77 let updated = replace_managed_section(&existing, &new_section);
78 fs::write(&path, updated).await?;
79 Ok(())
80 }
81
82 async fn parse_session(&self, log: SessionLog) -> Result<Vec<ParsedSignal>, AdapterError> {
83 match log {
84 SessionLog::ProxyEvent(event) => Ok(parse_proxy_event(&event)),
85 _ => Err(AdapterError::Parse(
86 "cursor adapter expects ProxyEvent logs".into(),
87 )),
88 }
89 }
90
91 async fn forget(&self, root: &Path) -> Result<(), AdapterError> {
92 let path = Self::cursorrules_path(root);
93 if path.is_file() {
94 let raw = fs::read_to_string(&path).await?;
95 let stripped = strip_managed_section(&raw);
96 if stripped.trim().is_empty() {
97 fs::remove_file(&path).await?;
99 } else {
100 fs::write(&path, stripped).await?;
101 }
102 }
103 Ok(())
104 }
105}
106
107fn replace_managed_section(existing: &str, new_body: &str) -> String {
108 let block = format!("{MANAGED_START}\n{}\n{MANAGED_END}", new_body.trim_end());
109 if let (Some(start), Some(end)) = (existing.find(MANAGED_START), existing.find(MANAGED_END)) {
110 if end > start {
111 let end_full = end + MANAGED_END.len();
112 let mut out = String::new();
113 out.push_str(&existing[..start]);
114 out.push_str(&block);
115 out.push_str(&existing[end_full..]);
116 return out;
117 }
118 }
119 let mut out = String::from(existing);
120 if !out.is_empty() && !out.ends_with('\n') {
121 out.push('\n');
122 }
123 if !out.is_empty() {
124 out.push('\n');
125 }
126 out.push_str(&block);
127 out.push('\n');
128 out
129}
130
131fn strip_managed_section(existing: &str) -> String {
132 if let (Some(start), Some(end)) = (existing.find(MANAGED_START), existing.find(MANAGED_END)) {
133 if end > start {
134 let end_full = end + MANAGED_END.len();
135 let mut out = String::new();
136 out.push_str(&existing[..start]);
137 out.push_str(existing[end_full..].trim_start_matches('\n'));
138 return out;
139 }
140 }
141 existing.to_string()
142}
143
144fn parse_proxy_event(event: &serde_json::Value) -> Vec<ParsedSignal> {
147 let event_type = event.get("event").and_then(|v| v.as_str()).unwrap_or("");
148 match event_type {
149 "suggestion_accepted" => vec![ParsedSignal {
150 kind: SignalKind::Implicit,
151 source: "cursor_suggestion_accepted".into(),
152 value: 1.0,
153 payload_json: None,
154 }],
155 "suggestion_rejected" => vec![ParsedSignal {
156 kind: SignalKind::Implicit,
157 source: "cursor_suggestion_rejected".into(),
158 value: 0.0,
159 payload_json: None,
160 }],
161 _ => Vec::new(),
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use tempfile::TempDir;
169
170 fn sample_config() -> AgentConfig {
171 AgentConfig::default_for("cursor")
172 }
173
174 #[tokio::test]
175 async fn detect_recognizes_cursorrules() {
176 let tmp = TempDir::new().unwrap();
177 std::fs::write(tmp.path().join(".cursorrules"), "").unwrap();
178 assert_eq!(
179 CursorAdapter::new().detect(tmp.path()),
180 AdapterDetection::Detected
181 );
182 }
183
184 #[tokio::test]
185 async fn apply_config_writes_managed_section() {
186 let tmp = TempDir::new().unwrap();
187 CursorAdapter::new()
188 .apply_config(tmp.path(), &sample_config())
189 .await
190 .unwrap();
191 let raw = std::fs::read_to_string(tmp.path().join(".cursorrules")).unwrap();
192 assert!(raw.contains(MANAGED_START));
193 assert!(raw.contains("Evolve-managed rules"));
194 }
195
196 #[tokio::test]
197 async fn apply_config_preserves_user_content() {
198 let tmp = TempDir::new().unwrap();
199 let user = "# my rules\nBe concise.\n";
200 std::fs::write(tmp.path().join(".cursorrules"), user).unwrap();
201 CursorAdapter::new()
202 .apply_config(tmp.path(), &sample_config())
203 .await
204 .unwrap();
205 let raw = std::fs::read_to_string(tmp.path().join(".cursorrules")).unwrap();
206 assert!(raw.contains("Be concise."));
207 assert!(raw.contains(MANAGED_START));
208 }
209
210 #[tokio::test]
211 async fn parse_session_extracts_accept_reject() {
212 let accept = serde_json::json!({"event": "suggestion_accepted"});
213 let signals = CursorAdapter::new()
214 .parse_session(SessionLog::ProxyEvent(accept))
215 .await
216 .unwrap();
217 assert_eq!(signals[0].value, 1.0);
218
219 let reject = serde_json::json!({"event": "suggestion_rejected"});
220 let signals = CursorAdapter::new()
221 .parse_session(SessionLog::ProxyEvent(reject))
222 .await
223 .unwrap();
224 assert_eq!(signals[0].value, 0.0);
225 }
226
227 #[tokio::test]
228 async fn forget_removes_managed_section_keeps_user_content() {
229 let tmp = TempDir::new().unwrap();
230 let user_then_managed =
231 format!("# user\nrules\n\n{MANAGED_START}\nmanaged\n{MANAGED_END}\n",);
232 std::fs::write(tmp.path().join(".cursorrules"), &user_then_managed).unwrap();
233 CursorAdapter::new().forget(tmp.path()).await.unwrap();
234 let raw = std::fs::read_to_string(tmp.path().join(".cursorrules")).unwrap();
235 assert!(raw.contains("# user"));
236 assert!(!raw.contains("managed"));
237 }
238}