From 4ec9e8d34dbafbae400df644395c242c53b95bda Mon Sep 17 00:00:00 2001 From: Robert Queenin <2177841+ecalifornica@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:48:20 -0400 Subject: [PATCH] refactor(cursor): PathResolver::new takes a required home argument (#185) toolpath-cursor reads no environment variable. The caller supplies the home directory and the Windows roaming-application-data root. The crate keeps the layout below them: the Anysphere tree is /.cursor, and the Electron user-data root is the per-platform default. CursorConvo::new(home) and CursorIO::new(home) take the same argument. Deleted: the Default impls on PathResolver, CursorIO, and CursorConvo; PathResolver::with_home; the private home-directory helper; the NoHomeDirectory error variant. with_anysphere_dir and with_user_data_dir stay as the full overrides. PathResolver::with_appdata(appdata) sets %APPDATA%, the Windows roaming-application-data root. The Windows default user-data directory is /Cursor. Every other platform ignores the value. The home directory is always present, so the 10 path accessors and CursorIO::db_path return a value instead of a Result. The CHANGELOG lists them. find_workspace_id and ensure_workspace_storage_entry keep their Result. path-cli: providers::cursor_resolver returns Option, harness_bundle omits Cursor when it is None, and the 5 commands that target Cursor go through providers::require_cursor_resolver. Config reads $APPDATA and passes it to the resolver on Windows. --- CHANGELOG.md | 33 ++++ Cargo.lock | 2 +- Cargo.toml | 2 +- crates/path-cli/src/cmd_export.rs | 7 +- crates/path-cli/src/cmd_import.rs | 5 +- crates/path-cli/src/cmd_list.rs | 4 +- crates/path-cli/src/cmd_share.rs | 10 +- crates/path-cli/src/cmd_show.rs | 4 +- crates/path-cli/src/derive.rs | 2 +- crates/path-cli/src/harness.rs | 1 - crates/path-cli/src/providers.rs | 51 +++--- crates/toolpath-cursor/Cargo.toml | 2 +- crates/toolpath-cursor/README.md | 2 +- .../toolpath-cursor/examples/dump_fixture.rs | 10 +- crates/toolpath-cursor/src/derive.rs | 3 +- crates/toolpath-cursor/src/error.rs | 3 - crates/toolpath-cursor/src/io.rs | 34 ++-- crates/toolpath-cursor/src/paths.rs | 157 +++++++++--------- crates/toolpath-cursor/src/provider.rs | 10 +- .../tests/real_session_sanity.rs | 14 +- site/_data/crates.json | 2 +- 21 files changed, 215 insertions(+), 143 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e96bf6d..a33d8f32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,39 @@ cache the same queries run ~4.7× faster (e.g. `length` 966 ms → drivers so the zero-file rule lives once. - The emscripten (playground) build keeps the sequential engine — no threads there. +## `toolpath-cursor`: the caller supplies the home directory — 2026-08-14 + +- **`toolpath-cursor`** (0.3.0): breaking. `PathResolver::new(home)` + takes the home directory as a required argument. The crate reads no + environment variable; it keeps the layout knowledge (`/.cursor` + and the per-platform Electron user-data root) and the caller owns + "what is home". `CursorConvo::new(home)` and `CursorIO::new(home)` + take the same argument. + + Removed: the `Default` impls on `PathResolver`, `CursorIO`, and + `CursorConvo`; `PathResolver::with_home`; the `NoHomeDirectory` error + variant. `with_anysphere_dir` and `with_user_data_dir` stay as the + full overrides. + + `PathResolver::with_appdata(appdata)` sets the Windows + roaming-application-data root: the Windows default user-data + directory is `/Cursor`. Every other platform ignores the + value. + + The home directory is always present, so `home_dir()`, + `anysphere_dir()`, `projects_dir()`, `project_transcripts_dir()`, + `transcript_path()`, `user_data_dir()`, `user_dir()`, + `global_storage_dir()`, `db_path()`, `workspace_storage_dir()`, and + `CursorIO::db_path()` return a value instead of a `Result`. + `find_workspace_id` and `ensure_workspace_storage_entry` keep their + `Result`. Their error is a failed directory read or write, not a + missing home. +- **`path-cli`** (unreleased): `providers::cursor_resolver` returns + `Option`. `None` means the configuration carries no home + directory, so Cursor is out of reach: the harness bundle omits it, and + a command that targets Cursor reports "cannot determine the home + directory". `Config` reads `$APPDATA` and passes it to the resolver on + Windows. ## `toolpath-opencode`: the caller supplies the home directory — 2026-08-14 - **`toolpath-opencode`** (0.6.0): breaking. `PathResolver::new(home)` diff --git a/Cargo.lock b/Cargo.lock index 34ac7d7f..60797d21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4285,7 +4285,7 @@ dependencies = [ [[package]] name = "toolpath-cursor" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index b7a00efd..e2dd5462 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ toolpath-gemini = { version = "0.7.0", path = "crates/toolpath-gemini", default- toolpath-codex = { version = "0.7.0", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.2.0", path = "crates/toolpath-copilot" } toolpath-opencode = { version = "0.6.0", path = "crates/toolpath-opencode" } -toolpath-cursor = { version = "0.2.0", path = "crates/toolpath-cursor" } +toolpath-cursor = { version = "0.3.0", path = "crates/toolpath-cursor" } toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 71d13be0..b2d3d8a7 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -1721,7 +1721,7 @@ fn build_cursor_session( // Cursor filters sidebar composers by `workspaceIdentifier.id`. // Reuse the existing id when present, otherwise pre-create a // workspaceStorage entry so Cursor adopts ours on next open. - let resolver = providers::cursor_resolver(config); + let resolver = providers::require_cursor_resolver(config)?; if let Ok(ensured) = resolver.ensure_workspace_storage_entry(&canonical, stable_workspace_id_for) { @@ -1758,10 +1758,7 @@ fn write_into_cursor_db( let project_dir = std::fs::canonicalize(project_dir) .with_context(|| format!("resolve project path {}", project_dir.display()))?; - let resolver = providers::cursor_resolver(config); - let db_path = resolver - .db_path() - .map_err(|e| anyhow::anyhow!("Cannot resolve Cursor state.vscdb path: {}", e))?; + let db_path = providers::require_cursor_resolver(config)?.db_path(); if !db_path.exists() { anyhow::bail!( "Cursor state.vscdb not found at {} — has Cursor.app been run on this machine?", diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index bfe2fcb3..9ca0a135 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -1165,8 +1165,9 @@ fn derive_cursor( #[cfg(not(target_os = "emscripten"))] { - let manager = - toolpath_cursor::CursorConvo::with_resolver(providers::cursor_resolver(config)); + let manager = toolpath_cursor::CursorConvo::with_resolver( + providers::require_cursor_resolver(config)?, + ); let derive_one = |sid: &str| derive_cursor_session_with(&manager, sid); let workspace_filter = project diff --git a/crates/path-cli/src/cmd_list.rs b/crates/path-cli/src/cmd_list.rs index 7f3ad284..49648cb5 100644 --- a/crates/path-cli/src/cmd_list.rs +++ b/crates/path-cli/src/cmd_list.rs @@ -887,7 +887,9 @@ fn run_cursor(project: Option, fmt: ListFormat, config: &Config) -> Resu #[cfg(not(target_os = "emscripten"))] { - let manager = providers::cursor_convo(config); + let manager = toolpath_cursor::CursorConvo::with_resolver( + providers::require_cursor_resolver(config)?, + ); let mut metas = manager .io() .list_session_metadata() diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 88af149c..31856592 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -744,12 +744,10 @@ fn harness_status_cursor(bundle: &HarnessBundle, home: Option<&std::path::Path>) let Some(mgr) = &bundle.cursor else { return HarnessStatus::unresolved(); }; - match mgr.resolver().db_path() { - Ok(p) => HarnessStatus { - path: crate::config::home_relative(&p, home), - exists: p.exists(), - }, - Err(_) => HarnessStatus::unresolved(), + let p = mgr.resolver().db_path(); + HarnessStatus { + path: crate::config::home_relative(&p, home), + exists: p.exists(), } } diff --git a/crates/path-cli/src/cmd_show.rs b/crates/path-cli/src/cmd_show.rs index 1eb0d67e..0dab4b37 100644 --- a/crates/path-cli/src/cmd_show.rs +++ b/crates/path-cli/src/cmd_show.rs @@ -185,7 +185,9 @@ fn derive_one(source: ShowSource, config: &Config) -> Result session, project: _, } => { - let manager = providers::cursor_convo(config); + let manager = toolpath_cursor::CursorConvo::with_resolver( + providers::require_cursor_resolver(config)?, + ); let s = manager .read_session(&session) .map_err(|e| anyhow::anyhow!("{}", e))?; diff --git a/crates/path-cli/src/derive.rs b/crates/path-cli/src/derive.rs index cb7ba3f0..9541d049 100644 --- a/crates/path-cli/src/derive.rs +++ b/crates/path-cli/src/derive.rs @@ -294,7 +294,7 @@ pub(crate) fn derive_opencode_session_with( #[cfg(not(target_os = "emscripten"))] pub(crate) fn derive_cursor_session(config: &Config, session: &str) -> Result { derive_cursor_session_with( - &toolpath_cursor::CursorConvo::with_resolver(providers::cursor_resolver(config)), + &toolpath_cursor::CursorConvo::with_resolver(providers::require_cursor_resolver(config)?), session, ) } diff --git a/crates/path-cli/src/harness.rs b/crates/path-cli/src/harness.rs index 071c33e8..c359eb01 100644 --- a/crates/path-cli/src/harness.rs +++ b/crates/path-cli/src/harness.rs @@ -128,7 +128,6 @@ pub(crate) fn is_not_found_opencode(err: &toolpath_opencode::ConvoError) -> bool pub(crate) fn is_not_found_cursor(err: &toolpath_cursor::CursorError) -> bool { use toolpath_cursor::CursorError; matches!(err, CursorError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) - || matches!(err, CursorError::NoHomeDirectory) || matches!(err, CursorError::CursorDataDirectoryNotFound(_)) || matches!(err, CursorError::DatabaseNotFound(_)) } diff --git a/crates/path-cli/src/providers.rs b/crates/path-cli/src/providers.rs index 1fd94a56..6dad3b46 100644 --- a/crates/path-cli/src/providers.rs +++ b/crates/path-cli/src/providers.rs @@ -17,9 +17,9 @@ //! `$COPILOT_HOME` replaces the whole Copilot root, so the injected //! directory wins against the home-derived default. //! -//! cursor (Windows) gets its directory injected, not just the home: -//! its resolver reads `$APPDATA` internally, and that read wins -//! against `with_home`. The injected directory wins against both. +//! cursor takes `$APPDATA` as an argument next to the home directory. +//! Only its Windows default user-data directory consults the value, so +//! the injection is gated to Windows. use crate::config::Config; #[cfg(not(target_os = "emscripten"))] @@ -114,18 +114,22 @@ pub(crate) fn require_opencode_resolver( } #[cfg(not(target_os = "emscripten"))] -pub(crate) fn cursor_convo(config: &Config) -> toolpath_cursor::CursorConvo { - let mut resolver = toolpath_cursor::PathResolver::new(); - if let Some(home) = config.home_dir() { - resolver = resolver.with_home(home); - } - // The resolver consults $APPDATA only on Windows; injecting it on +pub(crate) fn cursor_resolver(config: &Config) -> Option { + let resolver = toolpath_cursor::PathResolver::new(config.home_dir()?); + // The resolver applies $APPDATA only on Windows; injecting it on // other platforms would change resolution there. #[cfg(windows)] - if let Some(appdata) = &config.appdata { - resolver = resolver.with_user_data_dir(appdata.join("Cursor")); - } - toolpath_cursor::CursorConvo::with_resolver(resolver) + let resolver = match &config.appdata { + Some(appdata) => resolver.with_appdata(appdata), + None => resolver, + }; + Some(resolver) +} + +/// [`cursor_resolver`] for a command that targets Cursor. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn require_cursor_resolver(config: &Config) -> Result { + cursor_resolver(config).ok_or_else(|| missing_home("Cursor")) } /// `base` replaces the sessions directory: `--base` wins over the @@ -160,7 +164,7 @@ pub(crate) fn harness_bundle(config: &Config) -> HarnessBundle { toolpath_copilot::CopilotConvo::with_resolver(r).with_strict(copilot_strict(config)) }), opencode: opencode_resolver(config).map(toolpath_opencode::OpencodeConvo::with_resolver), - cursor: Some(cursor_convo(config)), + cursor: cursor_resolver(config).map(toolpath_cursor::CursorConvo::with_resolver), pi: Some(pi_convo(config, None)), } } @@ -314,14 +318,21 @@ mod tests { } #[test] - fn cursor_convo_roots_at_config_home() { - let manager = cursor_convo(&config_with_home()); + fn cursor_resolver_roots_at_config_home() { + let resolver = cursor_resolver(&config_with_home()).unwrap(); assert_eq!( - manager.resolver().anysphere_dir().unwrap(), + resolver.anysphere_dir(), PathBuf::from("/home/jailed/.cursor") ); } + #[test] + fn cursor_resolver_is_none_without_a_home() { + assert!(cursor_resolver(&Config::default()).is_none()); + let err = require_cursor_resolver(&Config::default()).unwrap_err(); + assert!(err.to_string().contains("home directory")); + } + #[test] fn claude_resolver_falls_back_to_config_userprofile() { let config = Config { @@ -337,15 +348,15 @@ mod tests { #[cfg(windows)] #[test] - fn cursor_convo_injects_user_data_dir_from_appdata() { + fn cursor_resolver_injects_appdata() { let config = Config { home: Some(PathBuf::from("/home/jailed")), appdata: Some(PathBuf::from("/appdata/roaming")), ..Config::default() }; - let manager = cursor_convo(&config); + let resolver = cursor_resolver(&config).unwrap(); assert_eq!( - manager.resolver().db_path().unwrap(), + resolver.db_path(), PathBuf::from("/appdata/roaming/Cursor/User/globalStorage/state.vscdb") ); } diff --git a/crates/toolpath-cursor/Cargo.toml b/crates/toolpath-cursor/Cargo.toml index ca09aa92..b02cf9cc 100644 --- a/crates/toolpath-cursor/Cargo.toml +++ b/crates/toolpath-cursor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cursor" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-cursor/README.md b/crates/toolpath-cursor/README.md index 04ab2f9b..34472813 100644 --- a/crates/toolpath-cursor/README.md +++ b/crates/toolpath-cursor/README.md @@ -61,7 +61,7 @@ IR has no `Turn.extra` slot. They round-trip to nothing. ```rust,no_run use toolpath_cursor::{CursorConvo, derive::{DeriveConfig, derive_path}}; -let manager = CursorConvo::new(); +let manager = CursorConvo::new("/Users/alex"); let composer_id = "724686cd-875e-47da-a90b-dbc3e523efb8"; let session = manager.read_session(composer_id)?; let path = derive_path(&session, &DeriveConfig::default()); diff --git a/crates/toolpath-cursor/examples/dump_fixture.rs b/crates/toolpath-cursor/examples/dump_fixture.rs index 6fc8ee73..18c3a517 100644 --- a/crates/toolpath-cursor/examples/dump_fixture.rs +++ b/crates/toolpath-cursor/examples/dump_fixture.rs @@ -76,7 +76,7 @@ fn capture_from_db( composer_override: Option, bubble_limit: Option, ) -> CursorSession { - let mgr = CursorConvo::new(); + let mgr = CursorConvo::new(home_dir().expect("$HOME (or $USERPROFILE) must be set")); let ids = mgr.io().list_composer_ids().expect("list composer ids"); let chosen_id = composer_override.unwrap_or_else(|| { let mut chosen: Option<(String, usize)> = None; @@ -145,6 +145,14 @@ fn referenced_blob_hashes(session: &CursorSession) -> std::collections::HashSet< needed } +/// The home directory this example reads Cursor state under. The +/// library takes it as an argument, so the caller supplies it. +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + // ── Mode 2: from a cursor-agent CLI JSONL transcript ────────────────── fn capture_from_jsonl(path: &str) -> CursorSession { diff --git a/crates/toolpath-cursor/src/derive.rs b/crates/toolpath-cursor/src/derive.rs index 8642f0c3..779fcb7b 100644 --- a/crates/toolpath-cursor/src/derive.rs +++ b/crates/toolpath-cursor/src/derive.rs @@ -65,8 +65,7 @@ mod tests { fs::create_dir_all(&global).unwrap(); let src = fixture_db(BASIC_FIXTURE); fs::copy(src.path(), global.join("state.vscdb")).unwrap(); - let resolver = crate::PathResolver::new() - .with_home(temp.path()) + let resolver = crate::PathResolver::new(temp.path()) .with_anysphere_dir(temp.path().join(".cursor")) .with_user_data_dir(user_data); (temp, CursorConvo::with_resolver(resolver)) diff --git a/crates/toolpath-cursor/src/error.rs b/crates/toolpath-cursor/src/error.rs index 751578dc..53134557 100644 --- a/crates/toolpath-cursor/src/error.rs +++ b/crates/toolpath-cursor/src/error.rs @@ -14,9 +14,6 @@ pub enum CursorError { #[error("JSON parsing error: {0}")] Json(#[from] serde_json::Error), - #[error("Home directory not found")] - NoHomeDirectory, - #[error("Cursor data directory not found at path: {0}")] CursorDataDirectoryNotFound(PathBuf), diff --git a/crates/toolpath-cursor/src/io.rs b/crates/toolpath-cursor/src/io.rs index 9b5affeb..f253061d 100644 --- a/crates/toolpath-cursor/src/io.rs +++ b/crates/toolpath-cursor/src/io.rs @@ -13,17 +13,11 @@ pub struct CursorIO { resolver: PathResolver, } -impl Default for CursorIO { - fn default() -> Self { - Self::new() - } -} - impl CursorIO { - pub fn new() -> Self { - Self { - resolver: PathResolver::new(), - } + /// Reads Cursor state under `home`, so the Anysphere directory is + /// `/.cursor`. + pub fn new>(home: P) -> Self { + Self::with_resolver(PathResolver::new(home)) } pub fn with_resolver(resolver: PathResolver) -> Self { @@ -38,12 +32,12 @@ impl CursorIO { self.resolver.db_exists() } - pub fn db_path(&self) -> Result { + pub fn db_path(&self) -> PathBuf { self.resolver.db_path() } fn open_db(&self) -> Result { - DbReader::open(self.resolver.db_path()?) + DbReader::open(self.resolver.db_path()) } /// Read `composer.composerHeaders` verbatim. @@ -135,7 +129,7 @@ impl CursorIO { return None; } let slug = paths::slug_from_abs_path(&abs); - let p = self.resolver.transcript_path(&slug, session.id()).ok()?; + let p = self.resolver.transcript_path(&slug, session.id()); p.exists().then_some(p) } } @@ -182,13 +176,23 @@ mod tests { // Pre-populate the DB. let src = fixture_db(BASIC_FIXTURE); fs::copy(src.path(), global.join("state.vscdb")).unwrap(); - let resolver = PathResolver::new() - .with_home(temp.path()) + let resolver = PathResolver::new(temp.path()) .with_anysphere_dir(temp.path().join(".cursor")) .with_user_data_dir(user_data); (temp, CursorIO::with_resolver(resolver)) } + #[test] + fn new_roots_at_home() { + let temp = TempDir::new().unwrap(); + let io = CursorIO::new(temp.path()); + assert_eq!( + io.db_path(), + io.resolver().user_dir().join("globalStorage/state.vscdb") + ); + assert_eq!(io.resolver().home_dir(), temp.path()); + } + #[test] fn lists_composers_with_has_bubbles_flag() { let (_t, io) = setup(); diff --git a/crates/toolpath-cursor/src/paths.rs b/crates/toolpath-cursor/src/paths.rs index 47d63e35..bb30ec84 100644 --- a/crates/toolpath-cursor/src/paths.rs +++ b/crates/toolpath-cursor/src/paths.rs @@ -11,7 +11,7 @@ //! at `/User/globalStorage/state.vscdb`. The JSONL //! transcripts are useful for fast project-keyed listing but lossy. -use crate::error::{CursorError, Result}; +use crate::error::Result; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -48,34 +48,29 @@ const DB_FILE: &str = "state.vscdb"; /// Builder-style resolver over Cursor's data directories. #[derive(Debug, Clone)] pub struct PathResolver { - home_dir: Option, + home_dir: PathBuf, /// Override for `~/.cursor/`. anysphere_dir: Option, /// Override for the Electron user-data root /// (`~/Library/Application Support/Cursor/` on macOS). user_data_dir: Option, -} - -impl Default for PathResolver { - fn default() -> Self { - Self::new() - } + /// The Windows roaming-application-data root (`%APPDATA%`). Only + /// the Windows default user-data directory consults it. + appdata: Option, } impl PathResolver { - pub fn new() -> Self { + /// A resolver rooted at `home`, so the Anysphere directory is + /// `/.cursor`. + pub fn new>(home: P) -> Self { Self { - home_dir: home_dir(), + home_dir: home.into(), anysphere_dir: None, user_data_dir: None, + appdata: None, } } - pub fn with_home>(mut self, home: P) -> Self { - self.home_dir = Some(home.into()); - self - } - /// Override `~/.cursor/` directly. pub fn with_anysphere_dir>(mut self, dir: P) -> Self { self.anysphere_dir = Some(dir.into()); @@ -89,61 +84,67 @@ impl PathResolver { self } - pub fn home_dir(&self) -> Result<&Path> { - self.home_dir.as_deref().ok_or(CursorError::NoHomeDirectory) + /// Set the Windows roaming-application-data root (`%APPDATA%`). + /// The Windows default user-data directory is `/Cursor`. + /// Every other platform ignores the value. + pub fn with_appdata>(mut self, appdata: P) -> Self { + self.appdata = Some(appdata.into()); + self + } + + pub fn home_dir(&self) -> &Path { + &self.home_dir } /// Path to `~/.cursor/`. - pub fn anysphere_dir(&self) -> Result { - if let Some(d) = &self.anysphere_dir { - return Ok(d.clone()); + pub fn anysphere_dir(&self) -> PathBuf { + match &self.anysphere_dir { + Some(d) => d.clone(), + None => self.home_dir.join(ANYSPHERE_SUBDIR), } - Ok(self.home_dir()?.join(ANYSPHERE_SUBDIR)) } /// Path to `~/.cursor/projects/`. - pub fn projects_dir(&self) -> Result { - Ok(self.anysphere_dir()?.join(PROJECTS_SUBDIR)) + pub fn projects_dir(&self) -> PathBuf { + self.anysphere_dir().join(PROJECTS_SUBDIR) } /// Path to the agent-transcripts folder for a project slug. - pub fn project_transcripts_dir(&self, slug: &str) -> Result { - Ok(self - .projects_dir()? + pub fn project_transcripts_dir(&self, slug: &str) -> PathBuf { + self.projects_dir() .join(slug) - .join(AGENT_TRANSCRIPTS_SUBDIR)) + .join(AGENT_TRANSCRIPTS_SUBDIR) } /// Path to the JSONL transcript file for a composer in a project. - pub fn transcript_path(&self, slug: &str, composer_id: &str) -> Result { - Ok(self - .project_transcripts_dir(slug)? + pub fn transcript_path(&self, slug: &str, composer_id: &str) -> PathBuf { + self.project_transcripts_dir(slug) .join(composer_id) - .join(format!("{composer_id}.jsonl"))) + .join(format!("{composer_id}.jsonl")) } /// Path to the Electron user-data root. - pub fn user_data_dir(&self) -> Result { - if let Some(d) = &self.user_data_dir { - return Ok(d.clone()); + pub fn user_data_dir(&self) -> PathBuf { + match &self.user_data_dir { + Some(d) => d.clone(), + None => default_user_data_dir(&self.home_dir, self.appdata.as_deref()), } - Ok(default_user_data_dir(self.home_dir()?)) } /// Path to `/User/`. - pub fn user_dir(&self) -> Result { - Ok(self.user_data_dir()?.join(USER_SUBDIR)) + pub fn user_dir(&self) -> PathBuf { + self.user_data_dir().join(USER_SUBDIR) } /// Path to `/User/globalStorage/`. - pub fn global_storage_dir(&self) -> Result { - Ok(self.user_dir()?.join(GLOBAL_STORAGE_SUBDIR)) + pub fn global_storage_dir(&self) -> PathBuf { + self.user_dir().join(GLOBAL_STORAGE_SUBDIR) } /// Path to the primary cross-workspace SQLite database /// (`/User/globalStorage/state.vscdb`). - pub fn db_path(&self) -> Result { - Ok(self.global_storage_dir()?.join(DB_FILE)) + pub fn db_path(&self) -> PathBuf { + self.global_storage_dir().join(DB_FILE) } /// Path to `/User/workspaceStorage/`. Cursor stores @@ -152,8 +153,8 @@ impl PathResolver { /// hash Cursor computes from the folder URI). Each subdir /// contains a `workspace.json` with the canonical `folder` URI /// that subdir is bound to. - pub fn workspace_storage_dir(&self) -> Result { - Ok(self.user_dir()?.join(WORKSPACE_STORAGE_SUBDIR)) + pub fn workspace_storage_dir(&self) -> PathBuf { + self.user_dir().join(WORKSPACE_STORAGE_SUBDIR) } /// Look up Cursor's workspace id for a given folder, if it has @@ -165,10 +166,7 @@ impl PathResolver { /// the caller can decide whether to synthesize one via /// [`Self::ensure_workspace_storage_entry`]. pub fn find_workspace_id(&self, folder: &Path) -> Result> { - let storage_root = match self.workspace_storage_dir() { - Ok(p) => p, - Err(_) => return Ok(None), - }; + let storage_root = self.workspace_storage_dir(); if !storage_root.exists() { return Ok(None); } @@ -223,7 +221,7 @@ impl PathResolver { return Ok(EnsuredWorkspaceId { id, created: false }); } let id = synthesize_id(folder); - let dir = self.workspace_storage_dir()?.join(&id); + let dir = self.workspace_storage_dir().join(&id); std::fs::create_dir_all(&dir)?; let canonical = std::fs::canonicalize(folder).unwrap_or_else(|_| folder.to_path_buf()); let folder_uri = format!("file://{}", canonical.to_string_lossy()); @@ -237,12 +235,12 @@ impl PathResolver { /// Whether Cursor's user-data tree exists. pub fn exists(&self) -> bool { - self.user_data_dir().map(|p| p.exists()).unwrap_or(false) + self.user_data_dir().exists() } /// Whether the primary global SQLite database exists. pub fn db_exists(&self) -> bool { - self.db_path().map(|p| p.exists()).unwrap_or(false) + self.db_path().exists() } } @@ -257,33 +255,26 @@ pub fn slug_from_abs_path(abs: &str) -> String { abs.trim_start_matches('/').replace('/', "-") } -fn home_dir() -> Option { - std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(PathBuf::from) -} - #[cfg(target_os = "macos")] -fn default_user_data_dir(home: &Path) -> PathBuf { +fn default_user_data_dir(home: &Path, _appdata: Option<&Path>) -> PathBuf { home.join("Library/Application Support/Cursor") } #[cfg(target_os = "linux")] -fn default_user_data_dir(home: &Path) -> PathBuf { +fn default_user_data_dir(home: &Path, _appdata: Option<&Path>) -> PathBuf { home.join(".config/Cursor") } #[cfg(target_os = "windows")] -fn default_user_data_dir(home: &Path) -> PathBuf { - if let Some(appdata) = std::env::var_os("APPDATA") { - PathBuf::from(appdata).join("Cursor") - } else { - home.join("AppData/Roaming/Cursor") +fn default_user_data_dir(home: &Path, appdata: Option<&Path>) -> PathBuf { + match appdata { + Some(appdata) => appdata.join("Cursor"), + None => home.join("AppData/Roaming/Cursor"), } } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] -fn default_user_data_dir(home: &Path) -> PathBuf { +fn default_user_data_dir(home: &Path, _appdata: Option<&Path>) -> PathBuf { home.join(".config/Cursor") } @@ -294,8 +285,7 @@ mod tests { fn setup() -> (TempDir, PathResolver) { let temp = TempDir::new().unwrap(); - let resolver = PathResolver::new() - .with_home(temp.path()) + let resolver = PathResolver::new(temp.path()) .with_anysphere_dir(temp.path().join(".cursor")) .with_user_data_dir(temp.path().join("UserData")); (temp, resolver) @@ -304,8 +294,9 @@ mod tests { #[test] fn anysphere_dir_defaults_to_home_dotcursor() { let temp = TempDir::new().unwrap(); - let r = PathResolver::new().with_home(temp.path()); - assert_eq!(r.anysphere_dir().unwrap(), temp.path().join(".cursor")); + let r = PathResolver::new(temp.path()); + assert_eq!(r.anysphere_dir(), temp.path().join(".cursor")); + assert_eq!(r.home_dir(), temp.path()); } #[test] @@ -313,16 +304,35 @@ mod tests { let (_t, r) = setup(); assert!( r.db_path() - .unwrap() .ends_with("UserData/User/globalStorage/state.vscdb") ); } + /// The user-data override wins against the home default. + #[test] + fn user_data_dir_override_wins_against_home() { + let r = PathResolver::new("/custom/home").with_user_data_dir("/custom/user-data"); + assert_eq!(r.user_data_dir(), PathBuf::from("/custom/user-data")); + } + + /// `%APPDATA%` reaches the Windows default user-data directory. + /// Every other platform keeps its own default. + #[cfg(target_os = "windows")] + #[test] + fn appdata_feeds_the_windows_default_user_data_dir() { + let r = PathResolver::new("/custom/home").with_appdata("/appdata/roaming"); + assert_eq!(r.user_data_dir(), PathBuf::from("/appdata/roaming/Cursor")); + assert_eq!( + PathResolver::new("/custom/home").user_data_dir(), + PathBuf::from("/custom/home/AppData/Roaming/Cursor") + ); + } + #[test] fn transcript_path_uses_double_uuid() { let (_t, r) = setup(); let uuid = "724686cd-875e-47da-a90b-dbc3e523efb8"; - let p = r.transcript_path("my-project", uuid).unwrap(); + let p = r.transcript_path("my-project", uuid); assert!(p.ends_with(format!("agent-transcripts/{uuid}/{uuid}.jsonl"))); } @@ -338,9 +348,9 @@ mod tests { #[test] fn exists_reflects_user_data_dir() { let (_t, r) = setup(); - std::fs::create_dir_all(r.user_data_dir().unwrap()).unwrap(); + std::fs::create_dir_all(r.user_data_dir()).unwrap(); assert!(r.exists()); - let missing = PathResolver::new().with_user_data_dir("/never/exists"); + let missing = PathResolver::new("/never/exists"); assert!(!missing.exists()); } @@ -352,7 +362,7 @@ mod tests { let canonical = std::fs::canonicalize(&folder).unwrap(); let folder_uri = format!("file://{}", canonical.to_string_lossy()); - let storage = r.workspace_storage_dir().unwrap(); + let storage = r.workspace_storage_dir(); let ws_dir = storage.join("deadbeefdeadbeefdeadbeefdeadbeef"); std::fs::create_dir_all(&ws_dir).unwrap(); std::fs::write( @@ -384,7 +394,6 @@ mod tests { // Manifest is written and points back at our folder. let manifest_path = r .workspace_storage_dir() - .unwrap() .join(&ensured.id) .join("workspace.json"); let raw = std::fs::read_to_string(&manifest_path).unwrap(); diff --git a/crates/toolpath-cursor/src/provider.rs b/crates/toolpath-cursor/src/provider.rs index d1276f39..74b9d3ac 100644 --- a/crates/toolpath-cursor/src/provider.rs +++ b/crates/toolpath-cursor/src/provider.rs @@ -49,15 +49,16 @@ use toolpath_convo::{ pub const PROVIDER_ID: &str = "cursor"; /// Provider for Cursor sessions. -#[derive(Default)] pub struct CursorConvo { io: CursorIO, } impl CursorConvo { - pub fn new() -> Self { + /// Reads Cursor state under `home`, so the Anysphere directory is + /// `/.cursor`. + pub fn new>(home: P) -> Self { Self { - io: CursorIO::new(), + io: CursorIO::new(home), } } @@ -733,8 +734,7 @@ mod tests { 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()) + let resolver = PathResolver::new(temp.path()) .with_anysphere_dir(temp.path().join(".cursor")) .with_user_data_dir(user_data); (temp, CursorConvo::with_resolver(resolver)) diff --git a/crates/toolpath-cursor/tests/real_session_sanity.rs b/crates/toolpath-cursor/tests/real_session_sanity.rs index 8be996bf..014b0d1d 100644 --- a/crates/toolpath-cursor/tests/real_session_sanity.rs +++ b/crates/toolpath-cursor/tests/real_session_sanity.rs @@ -15,7 +15,11 @@ use toolpath_cursor::{CursorConvo, DeriveConfig, derive_path, session_to_view}; #[test] fn real_cursor_db_round_trips_when_present() { - let mgr = CursorConvo::new(); + let Some(home) = home_dir() else { + eprintln!("note: no home directory; skipping live test"); + return; + }; + let mgr = CursorConvo::new(home); if !mgr.io().exists() { eprintln!("note: no Cursor user-data directory; skipping live test"); return; @@ -64,3 +68,11 @@ fn real_cursor_db_round_trips_when_present() { } eprintln!("note: validated {sessions_checked} live composers"); } + +/// The home directory this test reads Cursor state under. The library +/// takes it as an argument, so the caller supplies it. +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(std::path::PathBuf::from) +} diff --git a/site/_data/crates.json b/site/_data/crates.json index b56f8ad4..088c8b96 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -81,7 +81,7 @@ }, { "name": "toolpath-cursor", - "version": "0.2.0", + "version": "0.3.0", "description": "Derive Toolpath provenance documents from Cursor (IDE) composers", "docs": "https://docs.rs/toolpath-cursor", "crate": "https://crates.io/crates/toolpath-cursor",