Skip to main content

agm_core/agent/session_parser/
cursor.rs

1use std::path::PathBuf;
2
3use chrono::DateTime;
4use rootcause::option_ext::OptionExt as _;
5use rootcause::prelude::ResultExt as _;
6use rootcause::report;
7use serde::Deserialize;
8
9use crate::agent::Agent;
10use crate::agent::session::Session;
11
12pub fn parse(meta_hex: &str, workspace_dir: PathBuf) -> rootcause::Result<Session> {
13    let meta_json = decode_hex_string(meta_hex)
14        .context("failed to decode Cursor meta payload".to_owned())
15        .attach(format!("meta_hex={meta_hex}"))?;
16    let doc = serde_json::from_str::<CursorMeta>(&meta_json)
17        .context("failed to parse Cursor session metadata".to_owned())
18        .attach(format!("meta_json={meta_json}"))?;
19
20    let created_at = DateTime::from_timestamp_millis(doc.created_at)
21        .map(|datetime| datetime.to_utc())
22        .context("Cursor createdAt is out of range".to_owned())
23        .attach(format!("session_id={}", doc.agent_id))
24        .attach(format!("created_at_ms={}", doc.created_at))?;
25
26    Ok(Session::new(
27        Agent::Cursor,
28        doc.agent_id,
29        workspace_dir,
30        doc.name,
31        created_at,
32    ))
33}
34
35pub(crate) fn decode_hex_string(raw: &str) -> rootcause::Result<String> {
36    let hex = raw.trim();
37
38    if !hex.len().is_multiple_of(2) {
39        return Err(report!("hex string has odd length").attach(format!("len={}", hex.len())));
40    }
41
42    let mut bytes = Vec::with_capacity(hex.len() / 2);
43    for pair in hex.as_bytes().chunks_exact(2) {
44        let pair = std::str::from_utf8(pair).context("hex chunk is not utf8".to_owned())?;
45        let byte = u8::from_str_radix(pair, 16).context("invalid hex byte".to_owned())?;
46        bytes.push(byte);
47    }
48
49    Ok(String::from_utf8(bytes).context("decoded hex string is not utf8".to_owned())?)
50}
51
52pub fn extract_cursor_workspace_from_strings(
53    strings_output: &str,
54    known_workspaces: &[PathBuf],
55    ignored_roots: &[PathBuf],
56) -> Option<PathBuf> {
57    let mut known_matches: Vec<PathBuf> = known_workspaces
58        .iter()
59        .filter(|workspace| workspace.to_str().is_some_and(|value| strings_output.contains(value)))
60        .cloned()
61        .collect();
62    known_matches.sort_by_key(|workspace| std::cmp::Reverse(workspace.components().count()));
63    if let Some(workspace) = known_matches.into_iter().next() {
64        return Some(workspace);
65    }
66
67    for line in strings_output.lines() {
68        for candidate in extract_absolute_path_candidates(line) {
69            let Some(existing_path) = longest_existing_path(&candidate) else {
70                continue;
71            };
72            let workspace_dir = if existing_path.is_dir() {
73                existing_path
74            } else if let Some(parent) = existing_path.parent() {
75                parent.to_path_buf()
76            } else {
77                continue;
78            };
79            if ignored_roots.iter().any(|root| workspace_dir.starts_with(root)) {
80                continue;
81            }
82            return Some(workspace_dir);
83        }
84    }
85
86    None
87}
88
89#[derive(Debug, Deserialize)]
90struct CursorMeta {
91    #[serde(rename = "agentId")]
92    agent_id: String,
93    name: Option<String>,
94    #[serde(rename = "createdAt")]
95    created_at: i64,
96}
97
98fn extract_absolute_path_candidates(line: &str) -> Vec<String> {
99    let mut candidates = Vec::new();
100    candidates.extend(extract_prefixed_candidates(line, "file:///"));
101    candidates.extend(extract_prefixed_candidates(line, "/"));
102    candidates
103}
104
105fn extract_prefixed_candidates(line: &str, prefix: &str) -> Vec<String> {
106    let mut candidates = Vec::new();
107    let mut start = 0;
108    while let Some(offset) = line[start..].find(prefix) {
109        let absolute_start = start.saturating_add(offset);
110        let suffix = &line[absolute_start..];
111        let candidate: String = suffix.chars().take_while(|ch| is_path_char(*ch)).collect();
112        if !candidate.is_empty() {
113            candidates.push(candidate);
114        }
115        start = absolute_start.saturating_add(prefix.len());
116    }
117    candidates
118}
119
120const fn is_path_char(ch: char) -> bool {
121    ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '~')
122}
123
124fn longest_existing_path(candidate: &str) -> Option<PathBuf> {
125    let normalized = candidate.strip_prefix("file://").unwrap_or(candidate);
126    let mut path = PathBuf::from(normalized);
127
128    while !path.exists() {
129        if !path.pop() {
130            return None;
131        }
132    }
133
134    Some(path)
135}
136
137#[cfg(test)]
138mod tests {
139
140    use tempfile::tempdir;
141
142    use super::*;
143
144    #[test]
145    fn test_decodes_cursor_meta_hex_payload() {
146        assert2::assert!(let Ok(decoded) = decode_hex_string("7b226e616d65223a225361666520526562617365227d"));
147        pretty_assertions::assert_eq!(decoded, "{\"name\":\"Safe Rebase\"}");
148    }
149
150    #[test]
151    fn test_parses_cursor_session_from_meta_json() {
152        let tempdir = tempdir().unwrap();
153        let workspace = tempdir.path().join("workspace");
154        std::fs::create_dir_all(&workspace).unwrap();
155
156        let meta_hex = "7b226167656e744964223a2266626364393632362d623065642d343739632d623838372d376132633264313531376636222c226e616d65223a225361666520526562617365222c22637265617465644174223a313737343837373733383031337d";
157        assert2::assert!(let Ok(session) = parse(meta_hex, workspace.clone()));
158        pretty_assertions::assert_eq!(session.agent, Agent::Cursor);
159        pretty_assertions::assert_eq!(session.workspace, workspace);
160        pretty_assertions::assert_eq!(session.name, "Safe Rebase");
161    }
162
163    #[test]
164    fn test_extracts_cursor_workspace_from_known_workspaces_first() {
165        let tempdir = tempdir().unwrap();
166        let workspace = tempdir.path().join("work").join("dotfiles");
167        std::fs::create_dir_all(&workspace).unwrap();
168
169        let strings_output = format!("file://{}/README.md\n{}\n", workspace.display(), workspace.display());
170        let extracted = extract_cursor_workspace_from_strings(&strings_output, std::slice::from_ref(&workspace), &[]);
171        pretty_assertions::assert_eq!(extracted, Some(workspace));
172    }
173
174    #[test]
175    fn test_extracts_cursor_workspace_from_generic_path_candidates() {
176        let tempdir = tempdir().unwrap();
177        let workspace = tempdir.path().join("work").join("repo");
178        let ignored = tempdir.path().join("home").join(".cursor");
179        std::fs::create_dir_all(workspace.join("src")).unwrap();
180        std::fs::create_dir_all(&ignored).unwrap();
181
182        let strings_output = format!("garbage file://{}/src/main.rs trailing", workspace.display());
183        let extracted = extract_cursor_workspace_from_strings(&strings_output, &[], &[ignored]);
184        pretty_assertions::assert_eq!(extracted, Some(workspace.join("src")));
185    }
186}