Skip to main content

evolve_adapters/
cursor.rs

1//! Cursor adapter.
2//!
3//! Cursor doesn't expose a Stop hook, so for v1 we rely on:
4//! - `.cursorrules` file for configuration (managed-section bracketed).
5//! - A proxy-based signal-capture fallback (see `evolve-proxy`); adapter
6//!   consumes proxy-emitted events via `parse_session(SessionLog::ProxyEvent)`.
7
8use 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/// Cursor integration.
20#[derive(Debug, Clone, Default)]
21pub struct CursorAdapter;
22
23impl CursorAdapter {
24    /// Construct.
25    pub fn new() -> Self {
26        Self
27    }
28
29    fn cursorrules_path(root: &Path) -> PathBuf {
30        root.join(".cursorrules")
31    }
32
33    /// Render a config into the plain-text snippet for `.cursorrules`.
34    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        // Writing a managed .cursorrules file is all there is to "install" for Cursor.
66        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                // If nothing but our section was there, remove the file entirely.
98                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
144/// Extract signals from a proxy-emitted event.
145/// Schema: `{ "event": "suggestion_accepted" | "suggestion_rejected", "..." }`
146fn 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}