use crate::error::{CursorError, Result};
use crate::paths::{self, PathResolver};
use crate::reader::DbReader;
use crate::types::{ComposerHead, ComposerHeaders, CursorSession, CursorSessionMetadata};
use std::path::PathBuf;
pub struct CursorIO {
resolver: PathResolver,
}
impl Default for CursorIO {
fn default() -> Self {
Self::new()
}
}
impl CursorIO {
pub fn new() -> Self {
Self {
resolver: PathResolver::new(),
}
}
pub fn with_resolver(resolver: PathResolver) -> Self {
Self { resolver }
}
pub fn resolver(&self) -> &PathResolver {
&self.resolver
}
pub fn exists(&self) -> bool {
self.resolver.db_exists()
}
pub fn db_path(&self) -> Result<PathBuf> {
self.resolver.db_path()
}
fn open_db(&self) -> Result<DbReader> {
DbReader::open(self.resolver.db_path()?)
}
pub fn read_composer_headers(&self) -> Result<ComposerHeaders> {
self.open_db()?.read_composer_headers()
}
pub fn list_composer_ids(&self) -> Result<Vec<String>> {
self.open_db()?.list_composer_ids()
}
pub fn list_composers(&self) -> Result<Vec<ComposerListing>> {
let db = self.open_db()?;
let headers = db.read_composer_headers()?;
let mut out = Vec::with_capacity(headers.all_composers.len());
for head in headers.all_composers {
let has = db.composer_has_bubbles(&head.composer_id).unwrap_or(false);
out.push(ComposerListing {
has_bubbles: has,
head,
});
}
Ok(out)
}
pub fn list_session_metadata(&self) -> Result<Vec<CursorSessionMetadata>> {
let db = self.open_db()?;
let headers = db.read_composer_headers()?;
let mut out = Vec::new();
for head in headers.all_composers {
if !db.composer_has_bubbles(&head.composer_id).unwrap_or(false) {
continue;
}
match db.load_session(&head.composer_id) {
Ok(s) => out.push(to_metadata(&s)),
Err(e) => {
eprintln!(
"Warning: skipping composer {}: {}",
head.composer_id, e
);
}
}
}
Ok(out)
}
pub fn read_session_metadata(&self, composer_id: &str) -> Result<CursorSessionMetadata> {
let s = self.read_session(composer_id)?;
Ok(to_metadata(&s))
}
pub fn composer_exists(&self, composer_id: &str) -> Result<bool> {
let db = match self.open_db() {
Ok(db) => db,
Err(CursorError::DatabaseNotFound(_)) => return Ok(false),
Err(e) => return Err(e),
};
if db.read_composer_data(composer_id)?.is_some() {
return Ok(true);
}
db.composer_has_bubbles(composer_id)
}
pub fn read_session(&self, composer_id: &str) -> Result<CursorSession> {
let db = self.open_db()?;
let mut session = db.load_session(composer_id)?;
session.transcript_path = self.locate_transcript(&session);
Ok(session)
}
pub fn locate_transcript(&self, session: &CursorSession) -> Option<PathBuf> {
let abs = session.workspace_path()?.to_string_lossy().into_owned();
if !abs.starts_with('/') {
return None;
}
let slug = paths::slug_from_abs_path(&abs);
let p = self
.resolver
.transcript_path(&slug, session.id())
.ok()?;
p.exists().then_some(p)
}
}
#[derive(Debug, Clone)]
pub struct ComposerListing {
pub head: ComposerHead,
pub has_bubbles: bool,
}
fn to_metadata(session: &CursorSession) -> CursorSessionMetadata {
CursorSessionMetadata {
id: session.id().to_string(),
name: session.title(),
subtitle: session
.data
.subtitle
.clone()
.or_else(|| session.head.as_ref().and_then(|h| h.subtitle.clone())),
started_at: session.started_at(),
last_activity: session.last_activity(),
unified_mode: session.data.unified_mode.clone(),
workspace_path: session.workspace_path(),
message_count: session.bubbles.len(),
first_user_message: session.first_user_text(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::reader::tests::{BASIC_FIXTURE, fixture_db};
use std::fs;
use tempfile::TempDir;
fn setup() -> (TempDir, CursorIO) {
let temp = TempDir::new().unwrap();
let user_data = temp.path().join("UserData");
let global = user_data.join("User").join("globalStorage");
fs::create_dir_all(&global).unwrap();
let src = fixture_db(BASIC_FIXTURE);
fs::copy(src.path(), global.join("state.vscdb")).unwrap();
let resolver = PathResolver::new()
.with_home(temp.path())
.with_anysphere_dir(temp.path().join(".cursor"))
.with_user_data_dir(user_data);
(temp, CursorIO::with_resolver(resolver))
}
#[test]
fn lists_composers_with_has_bubbles_flag() {
let (_t, io) = setup();
let listings = io.list_composers().unwrap();
assert_eq!(listings.len(), 1);
assert!(listings[0].has_bubbles);
assert_eq!(listings[0].head.composer_id, "c1");
}
#[test]
fn list_session_metadata_populated() {
let (_t, io) = setup();
let metas = io.list_session_metadata().unwrap();
assert_eq!(metas.len(), 1);
assert_eq!(metas[0].id, "c1");
assert_eq!(metas[0].message_count, 3);
assert_eq!(metas[0].first_user_message.as_deref(), Some("hello"));
assert_eq!(metas[0].workspace_path.as_deref(), Some(std::path::Path::new("/p")));
}
#[test]
fn composer_exists_true_false() {
let (_t, io) = setup();
assert!(io.composer_exists("c1").unwrap());
assert!(!io.composer_exists("nope").unwrap());
}
#[test]
fn read_session_locates_transcript_when_file_exists() {
let (temp, io) = setup();
let slug = paths::slug_from_abs_path("/p");
let transcript_dir = temp
.path()
.join(".cursor/projects")
.join(&slug)
.join("agent-transcripts/c1");
fs::create_dir_all(&transcript_dir).unwrap();
fs::write(
transcript_dir.join("c1.jsonl"),
r#"{"role":"user","message":{"content":[{"type":"text","text":"hi"}]}}"#,
)
.unwrap();
let s = io.read_session("c1").unwrap();
assert!(s.transcript_path.is_some());
assert!(
s.transcript_path
.as_deref()
.unwrap()
.ends_with("agent-transcripts/c1/c1.jsonl")
);
}
#[test]
fn read_session_with_no_transcript_returns_none() {
let (_t, io) = setup();
let s = io.read_session("c1").unwrap();
assert!(s.transcript_path.is_none());
}
}