diff --git a/CHANGELOG.md b/CHANGELOG.md index ee12a3a4..5e96bf6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,38 @@ 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-opencode`: the caller supplies the home directory — 2026-08-14 + +- **`toolpath-opencode`** (0.6.0): breaking. `PathResolver::new(home)` + takes the home directory as a required argument. The crate reads no + environment variable; it keeps the layout knowledge + (`/.local/share/opencode`) and the caller owns "what is home". + `OpencodeConvo::new(home)` and `ConvoIO::new(home)` take the same + argument. + + Removed: the `Default` impls on `PathResolver`, `ConvoIO`, and + `OpencodeConvo`; `PathResolver::with_home`; the `NoHomeDirectory` + error variant. + + `PathResolver::with_xdg_data_home(xdg)` sets the XDG data root; the + resolver appends `opencode` to it. The data directory resolves in + this order: `with_data_dir`, `with_xdg_data_home`, then + `/.local/share/opencode`. + + The home directory is always present, so `home_dir()`, `data_dir()`, + `db_path()`, `snapshot_root()`, `log_dir()`, `snapshot_gitdir()`, and + `ConvoIO::db_path()` return a path instead of a `Result`. + + The snapshot git repository needs a resolver. `to_view(session)` and + `derive_path(session, config)` skip it; `to_view_with_resolver` and + `derive_path_with_resolver` open it, as does the + `ConversationProvider` impl on `OpencodeConvo`. +- **`path-cli`** (unreleased): `providers::opencode_resolver` returns + `Option`. `None` means the configuration carries no home + directory, so opencode is out of reach: the harness bundle omits it, + and a command that targets opencode reports "cannot determine the home + directory". `Config` reads `$XDG_DATA_HOME` and passes it to the + resolver, so the variable keeps its behavior for CLI users. ## `toolpath-copilot`: the caller supplies the home directory — 2026-08-14 - **`toolpath-copilot`** (0.2.0): breaking. `PathResolver::new(home)` diff --git a/Cargo.lock b/Cargo.lock index 542e7179..34ac7d7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4354,7 +4354,7 @@ dependencies = [ [[package]] name = "toolpath-opencode" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 4616347e..b7a00efd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ toolpath-claude = { version = "0.13.0", path = "crates/toolpath-claude", default toolpath-gemini = { version = "0.7.0", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.7.0", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.2.0", path = "crates/toolpath-copilot" } -toolpath-opencode = { version = "0.5.0", path = "crates/toolpath-opencode" } +toolpath-opencode = { version = "0.6.0", path = "crates/toolpath-opencode" } toolpath-cursor = { version = "0.2.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" } diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 65d13d0e..71d13be0 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -1479,10 +1479,7 @@ fn write_into_opencode_db( let project_dir = std::fs::canonicalize(project_dir) .with_context(|| format!("resolve project path {}", project_dir.display()))?; - let resolver = providers::opencode_resolver(config); - let db_path = resolver - .db_path() - .map_err(|e| anyhow::anyhow!("Cannot resolve opencode db path: {}", e))?; + let db_path = providers::require_opencode_resolver(config)?.db_path(); if !db_path.exists() { anyhow::bail!( "opencode database not found at {} — has opencode been run on this machine?", @@ -2125,17 +2122,6 @@ mod tests { } } - /// A `Config` for the opencode export. The resolver reads - /// `$XDG_DATA_HOME` internally and that read wins against the home, - /// so the data root is injected too. - fn config_with_opencode_home(home: &std::path::Path) -> Config { - Config { - home: Some(home.to_path_buf()), - xdg_data_home: Some(home.join(".local/share")), - ..Config::default() - } - } - fn make_path_doc() -> toolpath::v1::Graph { let artifact_key = "agent://claude/test-session"; @@ -3112,7 +3098,7 @@ mod tests { input_path.to_string_lossy().to_string(), Some(project_dir.clone()), None, - &config_with_opencode_home(&fake_home), + &config_with_home(&fake_home), ) .expect("export opencode --project"); @@ -3382,7 +3368,7 @@ mod tests { // which adds the `ses_` prefix if not already present. let path = make_convo_path("opencode://ses_wrapper-test"); - let result = project_opencode(&path, &cwd, &config_with_opencode_home(&fake_home)); + let result = project_opencode(&path, &cwd, &config_with_home(&fake_home)); let returned_id = result.expect("project_opencode should succeed"); assert_eq!(returned_id, "ses_wrapper-test"); diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index d783f025..bfe2fcb3 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -1050,8 +1050,9 @@ fn derive_opencode( #[cfg(not(target_os = "emscripten"))] { - let manager = - toolpath_opencode::OpencodeConvo::with_resolver(providers::opencode_resolver(config)); + let manager = toolpath_opencode::OpencodeConvo::with_resolver( + providers::require_opencode_resolver(config)?, + ); let derive_one = |sid: &str| derive_opencode_session_with(&manager, sid, no_snapshot_diffs); let session_ids: Vec = match (session, all) { diff --git a/crates/path-cli/src/cmd_list.rs b/crates/path-cli/src/cmd_list.rs index 38e9f0fa..7f3ad284 100644 --- a/crates/path-cli/src/cmd_list.rs +++ b/crates/path-cli/src/cmd_list.rs @@ -795,7 +795,9 @@ fn run_opencode(project: Option, fmt: ListFormat, config: &Config) -> Re #[cfg(not(target_os = "emscripten"))] { - let manager = providers::opencode_convo(config); + let manager = toolpath_opencode::OpencodeConvo::with_resolver( + providers::require_opencode_resolver(config)?, + ); let metas = manager .io() .list_session_metadata(project.as_deref()) diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index aab577ce..88af149c 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -722,12 +722,10 @@ fn harness_status_opencode( let Some(mgr) = &bundle.opencode 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 5ceb7d21..1eb0d67e 100644 --- a/crates/path-cli/src/cmd_show.rs +++ b/crates/path-cli/src/cmd_show.rs @@ -168,7 +168,9 @@ fn derive_one(source: ShowSource, config: &Config) -> Result session, project: _, } => { - let manager = providers::opencode_convo(config); + let manager = toolpath_opencode::OpencodeConvo::with_resolver( + providers::require_opencode_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 a523b1e3..cb7ba3f0 100644 --- a/crates/path-cli/src/derive.rs +++ b/crates/path-cli/src/derive.rs @@ -246,7 +246,9 @@ pub(crate) fn derive_opencode_session( no_snapshot_diffs: bool, ) -> Result { derive_opencode_session_with( - &toolpath_opencode::OpencodeConvo::with_resolver(providers::opencode_resolver(config)), + &toolpath_opencode::OpencodeConvo::with_resolver(providers::require_opencode_resolver( + config, + )?), session, no_snapshot_diffs, ) diff --git a/crates/path-cli/src/harness.rs b/crates/path-cli/src/harness.rs index c0c0c0e2..071c33e8 100644 --- a/crates/path-cli/src/harness.rs +++ b/crates/path-cli/src/harness.rs @@ -121,7 +121,6 @@ pub(crate) fn is_not_found_copilot(err: &toolpath_copilot::ConvoError) -> bool { pub(crate) fn is_not_found_opencode(err: &toolpath_opencode::ConvoError) -> bool { use toolpath_opencode::ConvoError; matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) - || matches!(err, ConvoError::NoHomeDirectory) || matches!(err, ConvoError::OpencodeDirectoryNotFound(_)) || matches!(err, ConvoError::DatabaseNotFound(_)) } diff --git a/crates/path-cli/src/providers.rs b/crates/path-cli/src/providers.rs index f45d6416..1fd94a56 100644 --- a/crates/path-cli/src/providers.rs +++ b/crates/path-cli/src/providers.rs @@ -9,12 +9,17 @@ //! home, so the harness is out of reach. `require_*` turns that into an //! error for a command that targets one harness. //! -//! opencode, copilot, and cursor (Windows) get their directory -//! injected, not just the home. `$COPILOT_HOME` replaces the whole -//! Copilot root, so the injected directory wins against the -//! home-derived default. The opencode and cursor resolvers read -//! `$XDG_DATA_HOME` / `$APPDATA` internally, and those reads win -//! against `with_home`; the injected directory wins against both. +//! opencode takes the XDG data root as well as the home: +//! `$XDG_DATA_HOME` comes from [`Config`], and the resolver appends +//! `opencode` to it. +//! +//! copilot gets its directory injected, not just the home: +//! `$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. use crate::config::Config; #[cfg(not(target_os = "emscripten"))] @@ -90,15 +95,22 @@ pub(crate) fn copilot_strict(config: &Config) -> bool { } #[cfg(not(target_os = "emscripten"))] -pub(crate) fn opencode_convo(config: &Config) -> toolpath_opencode::OpencodeConvo { - let mut resolver = toolpath_opencode::PathResolver::new(); - if let Some(home) = config.home_dir() { - resolver = resolver.with_home(home); - } - if let Some(xdg) = &config.xdg_data_home { - resolver = resolver.with_data_dir(xdg.join("opencode")); - } - toolpath_opencode::OpencodeConvo::with_resolver(resolver) +pub(crate) fn opencode_resolver(config: &Config) -> Option { + config.home_dir().map(|home| { + let resolver = toolpath_opencode::PathResolver::new(home); + match &config.xdg_data_home { + Some(xdg) => resolver.with_xdg_data_home(xdg), + None => resolver, + } + }) +} + +/// [`opencode_resolver`] for a command that targets opencode. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn require_opencode_resolver( + config: &Config, +) -> Result { + opencode_resolver(config).ok_or_else(|| missing_home("opencode")) } #[cfg(not(target_os = "emscripten"))] @@ -147,7 +159,7 @@ pub(crate) fn harness_bundle(config: &Config) -> HarnessBundle { copilot: copilot_resolver(config).map(|r| { toolpath_copilot::CopilotConvo::with_resolver(r).with_strict(copilot_strict(config)) }), - opencode: Some(opencode_convo(config)), + opencode: opencode_resolver(config).map(toolpath_opencode::OpencodeConvo::with_resolver), cursor: Some(cursor_convo(config)), pi: Some(pi_convo(config, None)), } @@ -160,8 +172,7 @@ mod tests { // Assertions stay on paths fully determined by injected values; // resolver defaults that read the ambient environment (home - // fallbacks, `$XDG_DATA_HOME` when no directory is injected) are - // not asserted here. + // fallbacks) are not asserted here. fn config_with_home() -> Config { Config { @@ -273,19 +284,35 @@ mod tests { } #[test] - fn opencode_convo_injects_data_dir() { + fn opencode_resolver_injects_the_xdg_data_root() { let config = Config { home: Some(PathBuf::from("/home/jailed")), xdg_data_home: Some(PathBuf::from("/xdg/data")), ..Config::default() }; - let manager = opencode_convo(&config); + let resolver = opencode_resolver(&config).unwrap(); assert_eq!( - manager.resolver().db_path().unwrap(), + resolver.db_path(), PathBuf::from("/xdg/data/opencode/opencode.db") ); } + #[test] + fn opencode_resolver_roots_at_config_home() { + let resolver = opencode_resolver(&config_with_home()).unwrap(); + assert_eq!( + resolver.db_path(), + PathBuf::from("/home/jailed/.local/share/opencode/opencode.db") + ); + } + + #[test] + fn opencode_resolver_is_none_without_a_home() { + assert!(opencode_resolver(&Config::default()).is_none()); + let err = require_opencode_resolver(&Config::default()).unwrap_err(); + assert!(err.to_string().contains("home directory")); + } + #[test] fn cursor_convo_roots_at_config_home() { let manager = cursor_convo(&config_with_home()); diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index 0df0a4c1..c348ca1c 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -155,8 +155,8 @@ fn file_input_explicit_opencode_projects_and_records_exec() { // Pre-create the opencode db with the canonical schema. (Schema DDL // copied from cmd_export's existing opencode test until/unless // toolpath-opencode exposes a public bootstrap helper.) - let resolver = toolpath_opencode::PathResolver::new(); - let db_path = resolver.db_path().unwrap(); + let resolver = toolpath_opencode::PathResolver::new(home.home_dir()); + let db_path = resolver.db_path(); std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); { let conn = rusqlite::Connection::open(&db_path).unwrap(); diff --git a/crates/toolpath-opencode/Cargo.toml b/crates/toolpath-opencode/Cargo.toml index 82e90bd4..9966a7d0 100644 --- a/crates/toolpath-opencode/Cargo.toml +++ b/crates/toolpath-opencode/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-opencode" -version = "0.5.0" +version = "0.6.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-opencode/README.md b/crates/toolpath-opencode/README.md index d8357d78..fcfc2c27 100644 --- a/crates/toolpath-opencode/README.md +++ b/crates/toolpath-opencode/README.md @@ -48,7 +48,7 @@ git repositories. ```rust,no_run use toolpath_opencode::{OpencodeConvo, derive::{DeriveConfig, derive_path}}; -let manager = OpencodeConvo::new(); +let manager = OpencodeConvo::new("/Users/alex"); let session_id = "ses_24ee4deb6ffeWw7ZKWNVoOAgjD"; let convo = manager.read_session(session_id)?; let path = derive_path(&convo, &DeriveConfig::default()); diff --git a/crates/toolpath-opencode/src/derive.rs b/crates/toolpath-opencode/src/derive.rs index 1468bd2c..bca683c4 100644 --- a/crates/toolpath-opencode/src/derive.rs +++ b/crates/toolpath-opencode/src/derive.rs @@ -21,13 +21,14 @@ pub struct DeriveConfig { pub no_snapshot_diffs: bool, } -/// Derive a [`Path`] from an opencode [`Session`]. +/// Derive a [`Path`] from an opencode [`Session`]. Snapshot diffs need +/// a resolver: use [`derive_path_with_resolver`] for them. pub fn derive_path(session: &Session, config: &DeriveConfig) -> Path { - derive_path_with_resolver(session, config, &PathResolver::new()) + derive_from_view(to_view(session), session, config) } -/// Like [`derive_path`] but with a custom `PathResolver` (useful for -/// tests with a temp data directory). +/// Like [`derive_path`] but with a `PathResolver`, so the snapshot git +/// repository supplies file diffs. pub fn derive_path_with_resolver( session: &Session, config: &DeriveConfig, @@ -38,6 +39,14 @@ pub fn derive_path_with_resolver( } else { to_view_with_resolver(session, resolver) }; + derive_from_view(view, session, config) +} + +fn derive_from_view( + view: toolpath_convo::ConversationView, + session: &Session, + config: &DeriveConfig, +) -> Path { let base_uri = config.project_path.as_ref().map(|p| { if p.starts_with('/') { format!("file://{}", p) @@ -108,9 +117,7 @@ mod tests { )) .unwrap(); drop(conn); - let resolver = PathResolver::new() - .with_home(temp.path()) - .with_data_dir(&data_dir); + let resolver = PathResolver::new(temp.path()).with_data_dir(&data_dir); let mgr = OpencodeConvo::with_resolver(resolver.clone()); (temp, mgr, resolver) } diff --git a/crates/toolpath-opencode/src/error.rs b/crates/toolpath-opencode/src/error.rs index 0aa972fc..b146f2a0 100644 --- a/crates/toolpath-opencode/src/error.rs +++ b/crates/toolpath-opencode/src/error.rs @@ -17,9 +17,6 @@ pub enum ConvoError { #[error("Git error: {0}")] Git(#[from] git2::Error), - #[error("Home directory not found")] - NoHomeDirectory, - #[error("opencode directory not found at path: {0}")] OpencodeDirectoryNotFound(PathBuf), diff --git a/crates/toolpath-opencode/src/io.rs b/crates/toolpath-opencode/src/io.rs index 06c95572..4f6ef1d3 100644 --- a/crates/toolpath-opencode/src/io.rs +++ b/crates/toolpath-opencode/src/io.rs @@ -14,17 +14,11 @@ pub struct ConvoIO { resolver: PathResolver, } -impl Default for ConvoIO { - fn default() -> Self { - Self::new() - } -} - impl ConvoIO { - pub fn new() -> Self { - Self { - resolver: PathResolver::new(), - } + /// Roots at `home`, so the data directory is + /// `/.local/share/opencode`. + pub fn new>(home: P) -> Self { + Self::with_resolver(PathResolver::new(home)) } pub fn with_resolver(resolver: PathResolver) -> Self { @@ -39,12 +33,12 @@ impl ConvoIO { 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()) } /// List every project in the database. @@ -220,12 +214,20 @@ mod tests { ) .unwrap(); drop(conn); - let resolver = PathResolver::new() - .with_home(temp.path()) - .with_data_dir(&data); + let resolver = PathResolver::new(temp.path()).with_data_dir(&data); (temp, ConvoIO::with_resolver(resolver)) } + #[test] + fn new_roots_at_home() { + let temp = TempDir::new().unwrap(); + let io = ConvoIO::new(temp.path()); + assert_eq!( + io.db_path(), + temp.path().join(".local/share/opencode/opencode.db") + ); + } + #[test] fn lists_projects_and_sessions() { let (_t, io) = fixture(); diff --git a/crates/toolpath-opencode/src/paths.rs b/crates/toolpath-opencode/src/paths.rs index 6abfdd55..b8987919 100644 --- a/crates/toolpath-opencode/src/paths.rs +++ b/crates/toolpath-opencode/src/paths.rs @@ -13,7 +13,6 @@ //! exact path — moving the worktree orphans old snapshots even //! though the session IDs still resolve. -use crate::error::{ConvoError, Result}; use sha1::{Digest, Sha1}; use std::path::{Path, PathBuf}; @@ -24,64 +23,58 @@ const LOG_SUBDIR: &str = "log"; /// Builder-style resolver over the opencode data directory. #[derive(Debug, Clone)] pub struct PathResolver { - home_dir: Option, + home_dir: PathBuf, + xdg_data_home: Option, data_dir: Option, } -impl Default for PathResolver { - fn default() -> Self { - Self::new() - } -} - impl PathResolver { - pub fn new() -> Self { + pub fn new>(home: P) -> Self { Self { - home_dir: home_dir(), + home_dir: home.into(), + xdg_data_home: None, data_dir: None, } } - pub fn with_home>(mut self, home: P) -> Self { - self.home_dir = Some(home.into()); + /// Set the XDG data root. The data directory is `/opencode`, + /// and it wins against the home-derived default. + pub fn with_xdg_data_home>(mut self, xdg_data_home: P) -> Self { + self.xdg_data_home = Some(xdg_data_home.into()); self } /// Override the data directory directly (defaults to - /// `$XDG_DATA_HOME/opencode` or `~/.local/share/opencode`). + /// `/opencode` or `/.local/share/opencode`). pub fn with_data_dir>(mut self, data_dir: P) -> Self { self.data_dir = Some(data_dir.into()); self } - pub fn home_dir(&self) -> Result<&Path> { - self.home_dir.as_deref().ok_or(ConvoError::NoHomeDirectory) + pub fn home_dir(&self) -> &Path { + &self.home_dir } - pub fn data_dir(&self) -> Result { + pub fn data_dir(&self) -> PathBuf { if let Some(d) = &self.data_dir { - return Ok(d.clone()); + return d.clone(); } - // XDG_DATA_HOME fallback logic mirroring the `xdg-basedir` crate. - if let Some(xdg) = std::env::var_os("XDG_DATA_HOME") { - let p = PathBuf::from(xdg).join("opencode"); - if !p.as_os_str().is_empty() { - return Ok(p); - } + if let Some(xdg) = &self.xdg_data_home { + return xdg.join("opencode"); } - Ok(self.home_dir()?.join(".local/share/opencode")) + self.home_dir.join(".local/share/opencode") } - pub fn db_path(&self) -> Result { - Ok(self.data_dir()?.join(DB_FILE)) + pub fn db_path(&self) -> PathBuf { + self.data_dir().join(DB_FILE) } - pub fn snapshot_root(&self) -> Result { - Ok(self.data_dir()?.join(SNAPSHOT_SUBDIR)) + pub fn snapshot_root(&self) -> PathBuf { + self.data_dir().join(SNAPSHOT_SUBDIR) } - pub fn log_dir(&self) -> Result { - Ok(self.data_dir()?.join(LOG_SUBDIR)) + pub fn log_dir(&self) -> PathBuf { + self.data_dir().join(LOG_SUBDIR) } /// The bare git repository that backs snapshots for a given @@ -97,35 +90,29 @@ impl PathResolver { /// Returns the first candidate that exists. If neither exists, /// returns the current-layout path (so the caller's subsequent /// `git2::Repository::open` will produce a clean NotFound error). - pub fn snapshot_gitdir(&self, project_id: &str, worktree: &Path) -> Result { - let root = self.snapshot_root()?; + pub fn snapshot_gitdir(&self, project_id: &str, worktree: &Path) -> PathBuf { + let root = self.snapshot_root(); let worktree_hash = sha1_hex(worktree.to_string_lossy().as_bytes()); let nested = root.join(project_id).join(&worktree_hash); if nested.exists() { - return Ok(nested); + return nested; } let flat = root.join(project_id); if flat.exists() && flat.join("config").exists() { - return Ok(flat); + return flat; } - Ok(nested) + nested } pub fn exists(&self) -> bool { - self.data_dir().map(|p| p.exists()).unwrap_or(false) + self.data_dir().exists() } pub fn db_exists(&self) -> bool { - self.db_path().map(|p| p.exists()).unwrap_or(false) + self.db_path().exists() } } -fn home_dir() -> Option { - std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(PathBuf::from) -} - pub(crate) fn sha1_hex(bytes: &[u8]) -> String { let mut h = Sha1::new(); h.update(bytes); @@ -148,26 +135,44 @@ mod tests { let temp = TempDir::new().unwrap(); let data = temp.path().join(".local/share/opencode"); fs::create_dir_all(&data).unwrap(); - let resolver = PathResolver::new() - .with_home(temp.path()) - .with_data_dir(&data); + let resolver = PathResolver::new(temp.path()).with_data_dir(&data); (temp, resolver) } #[test] - fn data_dir_defaults_to_home_when_no_xdg() { + fn data_dir_defaults_to_home() { let temp = TempDir::new().unwrap(); - // SAFETY: tests that mutate env vars serialize via the lock in - // src/reader.rs tests; the direct test below doesn't mutate. - let r = PathResolver::new().with_home(temp.path()); - let d = r.data_dir().unwrap(); - assert!(d.ends_with(".local/share/opencode"), "got {:?}", d); + let r = PathResolver::new(temp.path()); + assert_eq!(r.data_dir(), temp.path().join(".local/share/opencode")); + assert_eq!(r.home_dir(), temp.path()); + } + + #[test] + fn xdg_data_home_wins_against_home_and_loses_to_the_data_dir() { + let r = PathResolver::new("/home/alex").with_xdg_data_home("/xdg/data"); + assert_eq!(r.data_dir(), PathBuf::from("/xdg/data/opencode")); + + let r = r.with_data_dir("/explicit/dir"); + assert_eq!(r.data_dir(), PathBuf::from("/explicit/dir")); } #[test] fn db_path_under_data_dir() { let (_t, r) = setup(); - assert!(r.db_path().unwrap().ends_with("opencode/opencode.db")); + assert!(r.db_path().ends_with("opencode/opencode.db")); + } + + #[test] + fn snapshot_root_and_log_dir_under_data_dir() { + let r = PathResolver::new("/home/alex"); + assert_eq!( + r.snapshot_root(), + PathBuf::from("/home/alex/.local/share/opencode/snapshot") + ); + assert_eq!( + r.log_dir(), + PathBuf::from("/home/alex/.local/share/opencode/log") + ); } #[test] @@ -175,7 +180,7 @@ mod tests { let (_t, r) = setup(); let pid = "4e82d608d080e9d92be51e24b592302df6a8cbf8"; let wt = Path::new("/Users/ben/empathic/oss/toolpath"); - let gd = r.snapshot_gitdir(pid, wt).unwrap(); + let gd = r.snapshot_gitdir(pid, wt); // sha1("/Users/ben/empathic/oss/toolpath") = bb93f39a… assert!(gd.to_string_lossy().contains(pid)); assert!( @@ -196,7 +201,7 @@ mod tests { fn exists_reflects_data_dir() { let (_t, r) = setup(); assert!(r.exists()); - let missing = PathResolver::new().with_data_dir("/never/exists"); + let missing = PathResolver::new("/never/exists"); assert!(!missing.exists()); } } diff --git a/crates/toolpath-opencode/src/provider.rs b/crates/toolpath-opencode/src/provider.rs index 9f05349f..8071f4d8 100644 --- a/crates/toolpath-opencode/src/provider.rs +++ b/crates/toolpath-opencode/src/provider.rs @@ -45,14 +45,17 @@ use toolpath_convo::{ }; /// Provider for opencode sessions. -#[derive(Default)] pub struct OpencodeConvo { io: ConvoIO, } impl OpencodeConvo { - pub fn new() -> Self { - Self { io: ConvoIO::new() } + /// Roots at `home`, so the data directory is + /// `/.local/share/opencode`. + pub fn new>(home: P) -> Self { + Self { + io: ConvoIO::new(home), + } } pub fn with_resolver(resolver: PathResolver) -> Self { @@ -152,14 +155,14 @@ pub fn native_name(category: ToolCategory, args: &Value) -> Option<&'static str> /// [`ConversationView`] shape. File mutations from the snapshot git repo /// are not populated; use [`to_view_with_resolver`] when you have one. pub fn to_view(session: &Session) -> ConversationView { - to_view_with_resolver(session, &PathResolver::new()) + Builder::new(session).build_with_resolver(None) } /// Like [`to_view`] but opens opencode's snapshot git repository via the /// resolver and pre-resolves each turn's file mutations against the /// snapshot pair. Falls back silently when the repo isn't present. pub fn to_view_with_resolver(session: &Session, resolver: &PathResolver) -> ConversationView { - Builder::new(session).build_with_resolver(resolver) + Builder::new(session).build_with_resolver(Some(resolver)) } struct Builder<'a> { @@ -195,14 +198,14 @@ impl<'a> Builder<'a> { } } - fn build_with_resolver(mut self, resolver: &PathResolver) -> ConversationView { + fn build_with_resolver(mut self, resolver: Option<&PathResolver>) -> ConversationView { let session_version = self.session.version.clone(); let session_directory = self.session.directory.to_string_lossy().to_string(); let session_project_id = self.session.project_id.clone(); - self.snapshot_repo = resolver - .snapshot_gitdir(&session_project_id, &self.session.directory) - .ok() - .and_then(|gd| git2::Repository::open(gd).ok()); + self.snapshot_repo = resolver.and_then(|r| { + let gitdir = r.snapshot_gitdir(&session_project_id, &self.session.directory); + git2::Repository::open(gitdir).ok() + }); let mut view = self.build(); @@ -729,7 +732,7 @@ impl ConversationProvider for OpencodeConvo { let s = self .read_session(conversation_id) .map_err(|e| ConvoTraitError::Provider(e.to_string()))?; - Ok(to_view(&s)) + Ok(to_view_with_resolver(&s, self.resolver())) } fn load_metadata( @@ -928,9 +931,7 @@ mod tests { )) .unwrap(); drop(conn); - let resolver = PathResolver::new() - .with_home(temp.path()) - .with_data_dir(&data); + let resolver = PathResolver::new(temp.path()).with_data_dir(&data); (temp, OpencodeConvo::with_resolver(resolver)) } diff --git a/crates/toolpath-opencode/tests/compaction_roundtrip.rs b/crates/toolpath-opencode/tests/compaction_roundtrip.rs index e3a046ad..f038e49b 100644 --- a/crates/toolpath-opencode/tests/compaction_roundtrip.rs +++ b/crates/toolpath-opencode/tests/compaction_roundtrip.rs @@ -99,9 +99,7 @@ fn setup_session() -> (TempDir, Session) { let conn = Connection::open(data.join("opencode.db")).unwrap(); conn.execute_batch(COMPACTION_SQL).unwrap(); drop(conn); - let resolver = PathResolver::new() - .with_home(temp.path()) - .with_data_dir(&data); + let resolver = PathResolver::new(temp.path()).with_data_dir(&data); let mgr = OpencodeConvo::with_resolver(resolver); let session = mgr.read_session("ses_compact").unwrap(); (temp, session) diff --git a/crates/toolpath-opencode/tests/projection_roundtrip.rs b/crates/toolpath-opencode/tests/projection_roundtrip.rs index 2f28e763..ecee9640 100644 --- a/crates/toolpath-opencode/tests/projection_roundtrip.rs +++ b/crates/toolpath-opencode/tests/projection_roundtrip.rs @@ -82,9 +82,7 @@ fn setup_session() -> (TempDir, Session) { let conn = Connection::open(data.join("opencode.db")).unwrap(); conn.execute_batch(BASIC_SQL).unwrap(); drop(conn); - let resolver = PathResolver::new() - .with_home(temp.path()) - .with_data_dir(&data); + let resolver = PathResolver::new(temp.path()).with_data_dir(&data); let mgr = OpencodeConvo::with_resolver(resolver); let session = mgr.read_session("ses_pickle").unwrap(); (temp, session) diff --git a/site/_data/crates.json b/site/_data/crates.json index 085190e4..b56f8ad4 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -65,7 +65,7 @@ }, { "name": "toolpath-opencode", - "version": "0.5.0", + "version": "0.6.0", "description": "Derive from opencode SQLite databases", "docs": "https://docs.rs/toolpath-opencode", "crate": "https://crates.io/crates/toolpath-opencode",