agm_core/agent/session_parser/
cursor.rs1use 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}