Skip to main content

toolpath_cursor/
io.rs

1//! Higher-level filesystem + DB operations over [`PathResolver`] and
2//! [`DbReader`].
3
4use 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
10/// The facade most consumers want. Wraps a [`PathResolver`] and lazily
11/// opens the SQLite database on demand.
12pub 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    /// Read `composer.composerHeaders` verbatim.
50    pub fn read_composer_headers(&self) -> Result<ComposerHeaders> {
51        self.open_db()?.read_composer_headers()
52    }
53
54    /// All composer ids known to the database, including drafts. Use
55    /// [`Self::list_composers`] when you only want sessions that have at
56    /// least one bubble on disk.
57    pub fn list_composer_ids(&self) -> Result<Vec<String>> {
58        self.open_db()?.list_composer_ids()
59    }
60
61    /// Composer headers paired to whether they have any rendered
62    /// bubbles. Empty drafts (`has_bubbles == false`) are commonly
63    /// dropped by callers that want a session list.
64    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    /// Lightweight per-composer metadata for every composer that has
79    /// at least one bubble on disk.
80    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    /// Lightweight metadata for one composer.
102    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    /// Whether the composer exists in the database (either as a
108    /// header or as bubble rows).
109    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    /// Load one session fully (composer data + bubbles + referenced
122    /// content blobs) and attach the JSONL transcript path when the
123    /// composer's workspace folder maps cleanly into
124    /// `~/.cursor/projects/<slug>/agent-transcripts/<id>/<id>.jsonl`.
125    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    /// Resolve the JSONL transcript path for a session, if the file
133    /// exists on disk. Cursor only emits a transcript for agent-mode
134    /// runs against a real on-disk workspace.
135    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/// One composer entry as returned by [`CursorIO::list_composers`].
150#[derive(Debug, Clone)]
151pub struct ComposerListing {
152    pub head: ComposerHead,
153    /// Whether `cursorDiskKV.bubbleId:<id>:*` has at least one row.
154    /// Drafts the user opened but didn't interact with are `false`.
155    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        // Pre-populate the DB.
189        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        // Place a transcript at the expected slug+UUID layout.
229        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        // No transcript file laid down.
256        assert!(s.transcript_path.is_none());
257    }
258}