1use crate::error::{CursorError, Result};
5use crate::paths::{self, PathResolver};
6use crate::reader::DbReader;
7use crate::types::{ComposerHead, ComposerHeaders, CursorSession, CursorSessionMetadata};
8use std::path::PathBuf;
9
10pub struct CursorIO {
13 resolver: PathResolver,
14}
15
16impl Default for CursorIO {
17 fn default() -> Self {
18 Self::new()
19 }
20}
21
22impl CursorIO {
23 pub fn new() -> Self {
24 Self {
25 resolver: PathResolver::new(),
26 }
27 }
28
29 pub fn with_resolver(resolver: PathResolver) -> Self {
30 Self { resolver }
31 }
32
33 pub fn resolver(&self) -> &PathResolver {
34 &self.resolver
35 }
36
37 pub fn exists(&self) -> bool {
38 self.resolver.db_exists()
39 }
40
41 pub fn db_path(&self) -> Result<PathBuf> {
42 self.resolver.db_path()
43 }
44
45 fn open_db(&self) -> Result<DbReader> {
46 DbReader::open(self.resolver.db_path()?)
47 }
48
49 pub fn read_composer_headers(&self) -> Result<ComposerHeaders> {
51 self.open_db()?.read_composer_headers()
52 }
53
54 pub fn list_composer_ids(&self) -> Result<Vec<String>> {
58 self.open_db()?.list_composer_ids()
59 }
60
61 pub fn list_composers(&self) -> Result<Vec<ComposerListing>> {
65 let db = self.open_db()?;
66 let headers = db.read_composer_headers()?;
67 let mut out = Vec::with_capacity(headers.all_composers.len());
68 for head in headers.all_composers {
69 let has = db.composer_has_bubbles(&head.composer_id).unwrap_or(false);
70 out.push(ComposerListing {
71 has_bubbles: has,
72 head,
73 });
74 }
75 Ok(out)
76 }
77
78 pub fn list_session_metadata(&self) -> Result<Vec<CursorSessionMetadata>> {
81 let db = self.open_db()?;
82 let headers = db.read_composer_headers()?;
83 let mut out = Vec::new();
84 for head in headers.all_composers {
85 if !db.composer_has_bubbles(&head.composer_id).unwrap_or(false) {
86 continue;
87 }
88 match db.load_session(&head.composer_id) {
89 Ok(s) => out.push(to_metadata(&s)),
90 Err(e) => {
91 eprintln!(
92 "Warning: skipping composer {}: {}",
93 head.composer_id, e
94 );
95 }
96 }
97 }
98 Ok(out)
99 }
100
101 pub fn read_session_metadata(&self, composer_id: &str) -> Result<CursorSessionMetadata> {
103 let s = self.read_session(composer_id)?;
104 Ok(to_metadata(&s))
105 }
106
107 pub fn composer_exists(&self, composer_id: &str) -> Result<bool> {
110 let db = match self.open_db() {
111 Ok(db) => db,
112 Err(CursorError::DatabaseNotFound(_)) => return Ok(false),
113 Err(e) => return Err(e),
114 };
115 if db.read_composer_data(composer_id)?.is_some() {
116 return Ok(true);
117 }
118 db.composer_has_bubbles(composer_id)
119 }
120
121 pub fn read_session(&self, composer_id: &str) -> Result<CursorSession> {
126 let db = self.open_db()?;
127 let mut session = db.load_session(composer_id)?;
128 session.transcript_path = self.locate_transcript(&session);
129 Ok(session)
130 }
131
132 pub fn locate_transcript(&self, session: &CursorSession) -> Option<PathBuf> {
136 let abs = session.workspace_path()?.to_string_lossy().into_owned();
137 if !abs.starts_with('/') {
138 return None;
139 }
140 let slug = paths::slug_from_abs_path(&abs);
141 let p = self
142 .resolver
143 .transcript_path(&slug, session.id())
144 .ok()?;
145 p.exists().then_some(p)
146 }
147}
148
149#[derive(Debug, Clone)]
151pub struct ComposerListing {
152 pub head: ComposerHead,
153 pub has_bubbles: bool,
156}
157
158fn to_metadata(session: &CursorSession) -> CursorSessionMetadata {
159 CursorSessionMetadata {
160 id: session.id().to_string(),
161 name: session.title(),
162 subtitle: session
163 .data
164 .subtitle
165 .clone()
166 .or_else(|| session.head.as_ref().and_then(|h| h.subtitle.clone())),
167 started_at: session.started_at(),
168 last_activity: session.last_activity(),
169 unified_mode: session.data.unified_mode.clone(),
170 workspace_path: session.workspace_path(),
171 message_count: session.bubbles.len(),
172 first_user_message: session.first_user_text(),
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use crate::reader::tests::{BASIC_FIXTURE, fixture_db};
180 use std::fs;
181 use tempfile::TempDir;
182
183 fn setup() -> (TempDir, CursorIO) {
184 let temp = TempDir::new().unwrap();
185 let user_data = temp.path().join("UserData");
186 let global = user_data.join("User").join("globalStorage");
187 fs::create_dir_all(&global).unwrap();
188 let src = fixture_db(BASIC_FIXTURE);
190 fs::copy(src.path(), global.join("state.vscdb")).unwrap();
191 let resolver = PathResolver::new()
192 .with_home(temp.path())
193 .with_anysphere_dir(temp.path().join(".cursor"))
194 .with_user_data_dir(user_data);
195 (temp, CursorIO::with_resolver(resolver))
196 }
197
198 #[test]
199 fn lists_composers_with_has_bubbles_flag() {
200 let (_t, io) = setup();
201 let listings = io.list_composers().unwrap();
202 assert_eq!(listings.len(), 1);
203 assert!(listings[0].has_bubbles);
204 assert_eq!(listings[0].head.composer_id, "c1");
205 }
206
207 #[test]
208 fn list_session_metadata_populated() {
209 let (_t, io) = setup();
210 let metas = io.list_session_metadata().unwrap();
211 assert_eq!(metas.len(), 1);
212 assert_eq!(metas[0].id, "c1");
213 assert_eq!(metas[0].message_count, 3);
214 assert_eq!(metas[0].first_user_message.as_deref(), Some("hello"));
215 assert_eq!(metas[0].workspace_path.as_deref(), Some(std::path::Path::new("/p")));
216 }
217
218 #[test]
219 fn composer_exists_true_false() {
220 let (_t, io) = setup();
221 assert!(io.composer_exists("c1").unwrap());
222 assert!(!io.composer_exists("nope").unwrap());
223 }
224
225 #[test]
226 fn read_session_locates_transcript_when_file_exists() {
227 let (temp, io) = setup();
228 let slug = paths::slug_from_abs_path("/p");
230 let transcript_dir = temp
231 .path()
232 .join(".cursor/projects")
233 .join(&slug)
234 .join("agent-transcripts/c1");
235 fs::create_dir_all(&transcript_dir).unwrap();
236 fs::write(
237 transcript_dir.join("c1.jsonl"),
238 r#"{"role":"user","message":{"content":[{"type":"text","text":"hi"}]}}"#,
239 )
240 .unwrap();
241 let s = io.read_session("c1").unwrap();
242 assert!(s.transcript_path.is_some());
243 assert!(
244 s.transcript_path
245 .as_deref()
246 .unwrap()
247 .ends_with("agent-transcripts/c1/c1.jsonl")
248 );
249 }
250
251 #[test]
252 fn read_session_with_no_transcript_returns_none() {
253 let (_t, io) = setup();
254 let s = io.read_session("c1").unwrap();
255 assert!(s.transcript_path.is_none());
257 }
258}