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        // Tighten: .vscode/ alone is too generic (every JS/TS repo has one).
58        // Require .cursorrules OR (.vscode/ AND a Cursor-specific marker).
59        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        // Writing a managed .cursorrules file is all there is to "install" for Cursor.
68        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                // If nothing but our section was there, remove the file entirely.
100                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
146/// Extract signals from a proxy-emitted event.
147/// Schema: `{ "event": "suggestion_accepted" | "suggestion_rejected", "..." }`
148fn 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}