diff --git a/CHANGELOG.md b/CHANGELOG.md index def8ba6c..44ef6d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,27 @@ cache the same queries run ~4.7× faster (e.g. `length` 966 ms → - The emscripten (playground) build keeps the sequential engine — no threads there. +## `toolpath-gemini`: the caller supplies the home directory — 2026-08-13 + +- **`toolpath-gemini`** (0.7.0): breaking. `PathResolver::new(home)` + takes the home directory as a required argument. The crate reads no + environment variable; it keeps the layout knowledge (`/.gemini`) + and the caller owns "what is home". `GeminiConvo::new(home)` and + `ConvoIO::new(home)` take the same argument. + + Removed: the `Default` impls on `PathResolver`, `ConvoIO`, and + `GeminiConvo`; `PathResolver::with_home`; the `NoHomeDirectory` error + variant. `with_gemini_dir` stays as the full override. + + The home directory is always present, so `home_dir()`, `gemini_dir()`, + `projects_file()`, `tmp_dir()`, and `ConvoIO::gemini_dir_path()` + return a path instead of a `Result`. +- **`path-cli`** (unreleased): `providers::gemini_resolver` returns + `Option`. `None` means the configuration carries no home + directory, so Gemini is out of reach: the harness bundle omits it, and + a command that targets Gemini reports "cannot determine the home + directory". + ## Configured share remotes for `path share` — 2026-08-12 - **`path-cli`** (0.17.0): `path share` now resolves a default share diff --git a/Cargo.lock b/Cargo.lock index 8d2d610f..b97f08a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4308,7 +4308,7 @@ dependencies = [ [[package]] name = "toolpath-gemini" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 1325339c..d09167e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ toolpath = { version = "0.7.0", path = "crates/toolpath" } toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } toolpath-claude = { version = "0.12.2", path = "crates/toolpath-claude", default-features = false } -toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false } +toolpath-gemini = { version = "0.7.0", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.6.1", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } toolpath-opencode = { version = "0.5.0", path = "crates/toolpath-opencode" } diff --git a/crates/path-cli/src/cache.rs b/crates/path-cli/src/cache.rs index 2b2432e4..c8a7979b 100644 --- a/crates/path-cli/src/cache.rs +++ b/crates/path-cli/src/cache.rs @@ -9,7 +9,7 @@ use anyhow::{Context, Result, anyhow, bail}; use std::path::PathBuf; use toolpath::v1::Graph; -use crate::config::config_dir; +use crate::config::Config; /// An entry surfaced by `list_cached`. #[derive(Debug, Clone)] @@ -21,16 +21,16 @@ pub(crate) struct CacheEntry { } /// The cache directory: `$CONFIG_DIR/documents/`. -pub(crate) fn cache_dir() -> Result { - Ok(config_dir()?.join(crate::config::DOCUMENTS_DIR_NAME)) +pub(crate) fn cache_dir(config: &Config) -> Result { + Ok(config.config_dir()?.join(crate::config::DOCUMENTS_DIR_NAME)) } /// Path for a given cache id (does not check existence). -pub(crate) fn cache_path(id: &str) -> Result { +pub(crate) fn cache_path(config: &Config, id: &str) -> Result { if id.is_empty() || id.contains('/') || id.contains('\\') || id.ends_with(".json") { bail!("invalid cache id: {id:?}"); } - Ok(cache_dir()?.join(format!("{id}.json"))) + Ok(cache_dir(config)?.join(format!("{id}.json"))) } /// Write a toolpath document to the cache under `id`. Errors if the @@ -39,10 +39,10 @@ pub(crate) fn cache_path(id: &str) -> Result { /// Uses `O_CREAT | O_EXCL` (`create_new`) when `force == false` so the /// exists-check and the write are atomic — two concurrent `path import` /// invocations racing the same id can't silently stomp each other. -pub(crate) fn write_cached(id: &str, doc: &Graph, force: bool) -> Result { +pub(crate) fn write_cached(config: &Config, id: &str, doc: &Graph, force: bool) -> Result { use std::io::Write; - let dir = cache_dir()?; + let dir = cache_dir(config)?; std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; #[cfg(unix)] { @@ -50,7 +50,7 @@ pub(crate) fn write_cached(id: &str, doc: &Graph, force: bool) -> Result Result` string to a filesystem path. A ref is either a /// bare cache id (looks up `$CACHE_DIR/.json`) or a file path /// (contains `/` or `\\`, or ends with `.json`). -pub(crate) fn cache_ref(s: &str) -> Result { +pub(crate) fn cache_ref(config: &Config, s: &str) -> Result { if s.contains('/') || s.contains('\\') || s.ends_with(".json") { let p = PathBuf::from(s); if !p.exists() { @@ -98,7 +98,7 @@ pub(crate) fn cache_ref(s: &str) -> Result { } return Ok(p); } - let p = cache_path(s)?; + let p = cache_path(config, s)?; if !p.exists() { bail!( "cache entry {s} not found at {}; run `path cache ls` to see what's cached", @@ -108,8 +108,8 @@ pub(crate) fn cache_ref(s: &str) -> Result { Ok(p) } -pub(crate) fn list_cached() -> Result> { - let dir = cache_dir()?; +pub(crate) fn list_cached(config: &Config) -> Result> { + let dir = cache_dir(config)?; if !dir.exists() { return Ok(Vec::new()); } @@ -136,8 +136,8 @@ pub(crate) fn list_cached() -> Result> { Ok(out) } -pub(crate) fn remove_cached(id: &str) -> Result<()> { - let path = cache_path(id)?; +pub(crate) fn remove_cached(config: &Config, id: &str) -> Result<()> { + let path = cache_path(config, id)?; if !path.exists() { return Err(anyhow!("cache entry {id} not found")); } @@ -173,19 +173,16 @@ pub(crate) fn pathbase_cache_id(owner: &str, repo: &str, id: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK}; - fn with_cfg R, R>(f: F) -> R { + /// A `Config` whose cache lands in a fresh tempdir. The `TempDir` + /// is returned with it: dropping it removes the directory. + fn config_in_tempdir() -> (Config, tempfile::TempDir) { let temp = tempfile::tempdir().unwrap(); - let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - unsafe { - std::env::set_var(CONFIG_DIR_ENV, temp.path()); - } - let result = f(temp.path()); - unsafe { - std::env::remove_var(CONFIG_DIR_ENV); - } - result + let config = Config { + toolpath_config_dir: Some(temp.path().to_path_buf()), + ..Config::default() + }; + (config, temp) } fn sample_doc() -> Graph { @@ -194,100 +191,94 @@ mod tests { #[test] fn write_and_read_cache_entry() { - with_cfg(|_| { - let doc = sample_doc(); - let p = write_cached("claude-abc", &doc, false).unwrap(); - assert!(p.exists()); - assert_eq!(p.file_name().unwrap(), "claude-abc.json"); - }); + let (config, _temp) = config_in_tempdir(); + let doc = sample_doc(); + let p = write_cached(&config, "claude-abc", &doc, false).unwrap(); + assert!(p.exists()); + assert_eq!(p.file_name().unwrap(), "claude-abc.json"); } #[test] fn write_errors_if_exists_without_force() { - with_cfg(|_| { - let doc = sample_doc(); - write_cached("claude-abc", &doc, false).unwrap(); - let err = write_cached("claude-abc", &doc, false).unwrap_err(); - assert!(err.to_string().contains("already exists")); - }); + let (config, _temp) = config_in_tempdir(); + let doc = sample_doc(); + write_cached(&config, "claude-abc", &doc, false).unwrap(); + let err = write_cached(&config, "claude-abc", &doc, false).unwrap_err(); + assert!(err.to_string().contains("already exists")); } #[test] fn write_force_overwrites() { - with_cfg(|_| { - let doc = sample_doc(); - write_cached("claude-abc", &doc, false).unwrap(); - write_cached("claude-abc", &doc, true).unwrap(); - }); + let (config, _temp) = config_in_tempdir(); + let doc = sample_doc(); + write_cached(&config, "claude-abc", &doc, false).unwrap(); + write_cached(&config, "claude-abc", &doc, true).unwrap(); } #[test] fn cache_ref_finds_existing_cache_entry() { - with_cfg(|_| { - let doc = sample_doc(); - let p = write_cached("claude-abc", &doc, false).unwrap(); - let resolved = cache_ref("claude-abc").unwrap(); - assert_eq!(resolved, p); - }); + let (config, _temp) = config_in_tempdir(); + let doc = sample_doc(); + let p = write_cached(&config, "claude-abc", &doc, false).unwrap(); + let resolved = cache_ref(&config, "claude-abc").unwrap(); + assert_eq!(resolved, p); } #[test] fn cache_ref_returns_file_path_unchanged() { + let (config, _temp) = config_in_tempdir(); let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write(tmp.path(), "{}").unwrap(); - let resolved = cache_ref(tmp.path().to_str().unwrap()).unwrap(); + let resolved = cache_ref(&config, tmp.path().to_str().unwrap()).unwrap(); assert_eq!(resolved, tmp.path()); } #[test] fn cache_ref_errors_on_missing_id() { - with_cfg(|_| { - let err = cache_ref("does-not-exist").unwrap_err(); - assert!(err.to_string().contains("not found")); - }); + let (config, _temp) = config_in_tempdir(); + let err = cache_ref(&config, "does-not-exist").unwrap_err(); + assert!(err.to_string().contains("not found")); } #[test] fn cache_path_rejects_slashes_and_json_suffix() { - assert!(cache_path("foo/bar").is_err()); - assert!(cache_path("foo.json").is_err()); - assert!(cache_path("").is_err()); + let (config, _temp) = config_in_tempdir(); + assert!(cache_path(&config, "foo/bar").is_err()); + assert!(cache_path(&config, "foo.json").is_err()); + assert!(cache_path(&config, "").is_err()); } #[test] fn list_empty_when_dir_missing() { - with_cfg(|_| { - assert!(list_cached().unwrap().is_empty()); - }); + let (config, _temp) = config_in_tempdir(); + assert!(list_cached(&config).unwrap().is_empty()); } #[test] fn list_and_remove_roundtrip() { - with_cfg(|_| { - let doc = sample_doc(); - write_cached("a", &doc, false).unwrap(); - write_cached("b", &doc, false).unwrap(); - let entries = list_cached().unwrap(); - assert_eq!(entries.len(), 2); - - remove_cached("a").unwrap(); - let entries = list_cached().unwrap(); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].id, "b"); - - assert!(remove_cached("a").is_err()); - }); + let (config, _temp) = config_in_tempdir(); + let doc = sample_doc(); + write_cached(&config, "a", &doc, false).unwrap(); + write_cached(&config, "b", &doc, false).unwrap(); + let entries = list_cached(&config).unwrap(); + assert_eq!(entries.len(), 2); + + remove_cached(&config, "a").unwrap(); + let entries = list_cached(&config).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].id, "b"); + + assert!(remove_cached(&config, "a").is_err()); } #[cfg(unix)] #[test] fn writes_file_with_0600() { use std::os::unix::fs::PermissionsExt; - with_cfg(|_| { - let p = write_cached("claude-abc", &sample_doc(), false).unwrap(); - let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600); - }); + let (config, _temp) = config_in_tempdir(); + let p = write_cached(&config, "claude-abc", &sample_doc(), false).unwrap(); + let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); } #[test] @@ -306,7 +297,8 @@ mod tests { #[test] fn make_id_result_survives_cache_path() { // Regression: make_id output must be accepted by cache_path. + let (config, _temp) = config_in_tempdir(); let id = make_id("pathbase", "trc_01H.json"); - assert!(cache_path(&id).is_ok()); + assert!(cache_path(&config, &id).is_ok()); } } diff --git a/crates/path-cli/src/cmd_auth.rs b/crates/path-cli/src/cmd_auth.rs index 621dbc4a..51972a84 100644 --- a/crates/path-cli/src/cmd_auth.rs +++ b/crates/path-cli/src/cmd_auth.rs @@ -6,6 +6,7 @@ use crate::cmd_pathbase::{ StoredSession, api_logout, api_me, api_redeem, clear_session, credentials_path, load_session, prompt_line, resolve_url, store_session, }; +use crate::config::Config; #[derive(Subcommand, Debug)] pub enum AuthOp { @@ -27,18 +28,23 @@ pub enum AuthOp { Whoami, } -pub fn run(op: AuthOp) -> Result<()> { - let path = credentials_path()?; +pub fn run(op: AuthOp, config: &Config) -> Result<()> { + let path = credentials_path(config)?; match op { - AuthOp::Login { url, code } => login(&path, url, code), + AuthOp::Login { url, code } => login(config, &path, url, code), AuthOp::Logout => logout(&path), AuthOp::Status => status(&path), AuthOp::Whoami => whoami(&path), } } -fn login(path: &Path, url: Option, code_arg: Option) -> Result<()> { - let base_url = resolve_url(url); +fn login( + config: &Config, + path: &Path, + url: Option, + code_arg: Option, +) -> Result<()> { + let base_url = resolve_url(config, url); let auth_url = format!("{base_url}/auth/cli"); let code = match code_arg { diff --git a/crates/path-cli/src/cmd_cache.rs b/crates/path-cli/src/cmd_cache.rs index 88c1b7f4..07607027 100644 --- a/crates/path-cli/src/cmd_cache.rs +++ b/crates/path-cli/src/cmd_cache.rs @@ -10,10 +10,11 @@ use clap::Subcommand; use std::path::PathBuf; use crate::cache::{list_cached, remove_cached}; +use crate::config::Config; #[cfg(not(target_os = "emscripten"))] use crate::{ artifact::{ArtifactRef, ArtifactType}, - harness::HarnessBundle, + providers, sync::{SyncObserver, SyncOutcome, sync_bundle}, }; @@ -43,20 +44,20 @@ pub enum CacheOp { }, } -pub fn run(op: CacheOp) -> Result<()> { +pub fn run(op: CacheOp, config: &Config) -> Result<()> { match op { - CacheOp::Ls => run_ls(), - CacheOp::Rm { id } => run_rm(&id), + CacheOp::Ls => run_ls(config), + CacheOp::Rm { id } => run_rm(&id, config), #[cfg(not(target_os = "emscripten"))] CacheOp::Sync { types, project_under, - } => run_sync(types, project_under), + } => run_sync(types, project_under, config), } } -fn run_ls() -> Result<()> { - let entries = list_cached()?; +fn run_ls(config: &Config) -> Result<()> { + let entries = list_cached(config)?; if entries.is_empty() { eprintln!("No cached documents. Run `path import ` to create one."); return Ok(()); @@ -67,12 +68,15 @@ fn run_ls() -> Result<()> { Ok(()) } -fn run_rm(id: &str) -> Result<()> { - remove_cached(id)?; +fn run_rm(id: &str, config: &Config) -> Result<()> { + remove_cached(config, id)?; // The artifact is still real — downgrade its manifest record to // "known, not cached" so the next sync can re-materialize it. #[cfg(not(target_os = "emscripten"))] - if let Err(e) = crate::sync::evict_cache_id(id) { + if let Err(e) = config + .config_dir() + .and_then(|dir| crate::sync::evict_cache_id(&dir, id)) + { eprintln!("warning: sync manifest not updated: {e}"); } eprintln!("Removed {id}"); @@ -80,11 +84,16 @@ fn run_rm(id: &str) -> Result<()> { } #[cfg(not(target_os = "emscripten"))] -fn run_sync(types: Vec, project_under: Option) -> Result<()> { +fn run_sync( + types: Vec, + project_under: Option, + config: &Config, +) -> Result<()> { let explicit = !types.is_empty(); let types = resolve_types(&types); - let bundle = HarnessBundle::from_environment(); + let bundle = providers::harness_bundle(config); let outcomes = sync_bundle( + config, &bundle, &types, project_under.as_deref(), diff --git a/crates/path-cli/src/cmd_derive.rs b/crates/path-cli/src/cmd_derive.rs index 85e4a84e..dc0b9445 100644 --- a/crates/path-cli/src/cmd_derive.rs +++ b/crates/path-cli/src/cmd_derive.rs @@ -7,13 +7,15 @@ use anyhow::Result; +use crate::config::Config; + pub use crate::cmd_import::ImportSource as DeriveSource; -pub fn run(source: DeriveSource, pretty: bool) -> Result<()> { +pub fn run(source: DeriveSource, pretty: bool, config: &Config) -> Result<()> { let args = crate::cmd_import::ImportArgs { source, force: false, no_cache: true, }; - crate::cmd_import::run(args, pretty) + crate::cmd_import::run(args, pretty, config) } diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index b95a67f0..57ea641a 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -24,6 +24,9 @@ use std::path::PathBuf; #[cfg(not(target_os = "emscripten"))] use crate::cache::cache_ref; +use crate::config::Config; +#[cfg(not(target_os = "emscripten"))] +use crate::providers; use crate::remote::RepoSpec; #[derive(Subcommand, Debug)] @@ -202,44 +205,44 @@ pub enum ExportTarget { }, } -pub fn run(target: ExportTarget) -> Result<()> { +pub fn run(target: ExportTarget, config: &Config) -> Result<()> { match target { ExportTarget::Claude { input, project, output, force, - } => run_claude(input, project, output, force), + } => run_claude(input, project, output, force, config), ExportTarget::Gemini { input, project, output, - } => run_gemini(input, project, output), + } => run_gemini(input, project, output, config), ExportTarget::Pi { input, project, output, - } => run_pi(input, project, output), + } => run_pi(input, project, output, config), ExportTarget::Codex { input, project, output, - } => run_codex(input, project, output), + } => run_codex(input, project, output, config), ExportTarget::Opencode { input, project, output, - } => run_opencode(input, project, output), + } => run_opencode(input, project, output, config), ExportTarget::Copilot { input, project, output, - } => run_copilot(input, project, output), + } => run_copilot(input, project, output, config), ExportTarget::Cursor { input, project, output, - } => run_cursor(input, project, output), + } => run_cursor(input, project, output, config), ExportTarget::Pathbase { input, url, @@ -247,14 +250,17 @@ pub fn run(target: ExportTarget) -> Result<()> { repo, name, public, - } => run_pathbase(PathbaseExportArgs { - input, - url, - anon, - repo, - name, - public, - }), + } => run_pathbase( + PathbaseExportArgs { + input, + url, + anon, + repo, + name, + public, + }, + config, + ), } } @@ -307,15 +313,16 @@ pub(crate) enum ClaudeProjection { pub(crate) fn project_claude( path: &toolpath::v1::Path, project_dir: &std::path::Path, + config: &Config, ) -> Result { let conv = build_claude_conversation(path)?; - if claude_session_file(&conv.session_id, project_dir)?.is_some() { + if claude_session_file(&conv.session_id, project_dir, config)?.is_some() { return Ok(ClaudeProjection::AlreadyLocal { session_id: conv.session_id, }); } let jsonl = serialize_jsonl(&conv)?; - write_into_claude_project(&conv, &jsonl, project_dir, false)?; + write_into_claude_project(&conv, &jsonl, project_dir, false, config)?; Ok(ClaudeProjection::Written { session_id: conv.session_id, }) @@ -324,10 +331,14 @@ pub(crate) fn project_claude( /// Path of the session file for `session_id` under `project_dir`'s Claude /// project directory, if it exists. #[cfg(not(target_os = "emscripten"))] -fn claude_session_file(session_id: &str, project_dir: &std::path::Path) -> Result> { +fn claude_session_file( + session_id: &str, + project_dir: &std::path::Path, + config: &Config, +) -> Result> { let project_dir = std::fs::canonicalize(project_dir) .with_context(|| format!("resolve project path {}", project_dir.display()))?; - let resolver = toolpath_claude::PathResolver::new(); + let resolver = providers::claude_resolver(config); let claude_project_dir = resolver .project_dir(&project_dir.to_string_lossy()) .map_err(|e| anyhow::anyhow!("Cannot resolve Claude project dir: {}", e))?; @@ -341,6 +352,7 @@ fn claude_session_file(session_id: &str, project_dir: &std::path::Path) -> Resul pub(crate) fn project_gemini( path: &toolpath::v1::Path, project_dir: &std::path::Path, + config: &Config, ) -> Result { use toolpath_convo::ConversationProjector; let project_dir = std::fs::canonicalize(project_dir) @@ -358,7 +370,7 @@ pub(crate) fn project_gemini( if conv.session_uuid.is_empty() { anyhow::bail!("Projected conversation has no session UUID"); } - write_into_gemini_project(&conv, &project_path)?; + write_into_gemini_project(&conv, &project_path, config)?; Ok(conv.session_uuid) } @@ -367,6 +379,7 @@ pub(crate) fn project_gemini( pub(crate) fn project_codex( path: &toolpath::v1::Path, project_dir: &std::path::Path, + config: &Config, ) -> Result { use toolpath_convo::ConversationProjector; let project_dir = std::fs::canonicalize(project_dir) @@ -381,7 +394,7 @@ pub(crate) fn project_codex( if session.id.is_empty() { anyhow::bail!("Projected session has no id"); } - write_into_codex_project(&session)?; + write_into_codex_project(&session, config)?; Ok(session.id) } @@ -420,27 +433,33 @@ pub(crate) fn build_copilot_session( pub(crate) fn project_copilot( path: &toolpath::v1::Path, project_dir: &std::path::Path, + config: &Config, ) -> Result { let session = build_copilot_session(path, project_dir)?; - write_into_copilot_project(&session)?; + write_into_copilot_project(&session, config)?; Ok(session.id) } /// `path p export copilot` — project a document into a Copilot session on /// disk (`--project`), to a file (`--output`), or to stdout (neither). -fn run_copilot(input: String, project: Option, output: Option) -> Result<()> { +fn run_copilot( + input: String, + project: Option, + output: Option, + config: &Config, +) -> Result<()> { #[cfg(target_os = "emscripten")] { - let _ = (input, project, output); + let _ = (input, project, output, config); anyhow::bail!("'path export copilot' requires a native environment"); } #[cfg(not(target_os = "emscripten"))] { - let path = load_path_doc(&input)?; + let path = load_path_doc(config, &input)?; match (project, output) { (Some(project_dir), None) => { - let id = project_copilot(&path, &project_dir)?; + let id = project_copilot(&path, &project_dir, config)?; eprintln!(); eprintln!("Resume with:"); eprintln!(" copilot --resume {id}"); @@ -475,8 +494,8 @@ fn run_copilot(input: String, project: Option, output: Option) } #[cfg(not(target_os = "emscripten"))] -fn write_into_copilot_project(session: &toolpath_copilot::Session) -> Result<()> { - let resolver = toolpath_copilot::PathResolver::new(); +fn write_into_copilot_project(session: &toolpath_copilot::Session, config: &Config) -> Result<()> { + let resolver = providers::copilot_resolver(config); let state_dir = resolver .session_state_dir() .map_err(|e| anyhow::anyhow!("Cannot resolve ~/.copilot/session-state: {}", e))?; @@ -601,10 +620,11 @@ fn copilot_first_user_message(session: &toolpath_copilot::Session) -> String { pub(crate) fn project_opencode( path: &toolpath::v1::Path, project_dir: &std::path::Path, + config: &Config, ) -> Result { let session = build_opencode_session(path, Some(project_dir))?; let id = session.id.clone(); - write_into_opencode_db(&session, project_dir)?; + write_into_opencode_db(&session, project_dir, config)?; Ok(id) } @@ -614,6 +634,7 @@ pub(crate) fn project_opencode( pub(crate) fn project_pi( path: &toolpath::v1::Path, project_dir: &std::path::Path, + config: &Config, ) -> Result { use toolpath_convo::ConversationProjector; let project_dir = std::fs::canonicalize(project_dir) @@ -628,7 +649,7 @@ pub(crate) fn project_pi( if session.header.id.is_empty() { anyhow::bail!("Projected session has no id"); } - write_into_pi_project(&session, &cwd_str)?; + write_into_pi_project(&session, &cwd_str, config)?; Ok(session.header.id) } @@ -637,23 +658,24 @@ fn run_claude( project: Option, output: Option, force: bool, + config: &Config, ) -> Result<()> { #[cfg(target_os = "emscripten")] { - let _ = (input, project, output, force); + let _ = (input, project, output, force, config); anyhow::bail!("'path export claude' requires a native environment"); } #[cfg(not(target_os = "emscripten"))] { - let path = load_path_doc(&input)?; + let path = load_path_doc(config, &input)?; let conversation = build_claude_conversation(&path)?; let jsonl = serialize_jsonl(&conversation)?; match (project, output) { (Some(project_dir), None) => { let out_path = - write_into_claude_project(&conversation, &jsonl, &project_dir, force)?; + write_into_claude_project(&conversation, &jsonl, &project_dir, force, config)?; let session_id = &conversation.session_id; eprintln!( "Exported session {} ({} entries) → {}", @@ -681,8 +703,8 @@ fn run_claude( } #[cfg(not(target_os = "emscripten"))] -fn load_path_doc(input: &str) -> Result { - let file = cache_ref(input)?; +fn load_path_doc(config: &Config, input: &str) -> Result { + let file = cache_ref(config, input)?; let json = std::fs::read_to_string(&file) .with_context(|| format!("Failed to read {}", file.display()))?; let doc = toolpath::v1::Graph::from_json(&json) @@ -726,12 +748,13 @@ fn write_into_claude_project( jsonl: &str, project_dir: &std::path::Path, force: bool, + config: &Config, ) -> Result { let project_dir = std::fs::canonicalize(project_dir) .with_context(|| format!("resolve project path {}", project_dir.display()))?; let project_path = project_dir.to_string_lossy(); - let resolver = toolpath_claude::PathResolver::new(); + let resolver = providers::claude_resolver(config); let claude_project_dir = resolver .project_dir(&project_path) .map_err(|e| anyhow::anyhow!("Cannot resolve Claude project dir: {}", e))?; @@ -756,10 +779,15 @@ fn write_into_claude_project( // ── Gemini ──────────────────────────────────────────────────────────── -fn run_gemini(input: String, project: Option, output: Option) -> Result<()> { +fn run_gemini( + input: String, + project: Option, + output: Option, + config: &Config, +) -> Result<()> { #[cfg(target_os = "emscripten")] { - let _ = (input, project, output); + let _ = (input, project, output, config); anyhow::bail!("'path export gemini' requires a native environment"); } @@ -776,10 +804,10 @@ fn run_gemini(input: String, project: Option, output: Option) }; let project_path = project_dir.to_string_lossy().to_string(); - let conversation = build_gemini_conversation(&input, &project_path)?; + let conversation = build_gemini_conversation(config, &input, &project_path)?; match (project, output) { - (Some(_), None) => write_into_gemini_project(&conversation, &project_path)?, + (Some(_), None) => write_into_gemini_project(&conversation, &project_path, config)?, (None, Some(out_path)) => write_to_output_path(&conversation, &out_path)?, (None, None) => write_to_stdout(&conversation)?, (Some(_), Some(_)) => unreachable!("clap enforces conflicts_with"), @@ -790,12 +818,13 @@ fn run_gemini(input: String, project: Option, output: Option) #[cfg(not(target_os = "emscripten"))] fn build_gemini_conversation( + config: &Config, input: &str, project_path: &str, ) -> Result { use toolpath_convo::ConversationProjector; - let path = load_path_doc(input)?; + let path = load_path_doc(config, input)?; let view = toolpath_convo::extract_conversation(&path); // The projector bakes `projectHash` and `directories` into the @@ -821,8 +850,9 @@ fn build_gemini_conversation( fn write_into_gemini_project( conversation: &toolpath_gemini::types::Conversation, project_path: &str, + config: &Config, ) -> Result<()> { - let resolver = toolpath_gemini::PathResolver::new(); + let resolver = providers::require_gemini_resolver(config)?; let chats_dir = resolver .chats_dir(project_path) .map_err(|e| anyhow::anyhow!("Cannot resolve Gemini chats dir: {}", e))?; @@ -980,10 +1010,15 @@ fn gemini_main_stem(convo: &toolpath_gemini::types::Conversation) -> String { // ── Pi ──────────────────────────────────────────────────────────────── -fn run_pi(input: String, project: Option, output: Option) -> Result<()> { +fn run_pi( + input: String, + project: Option, + output: Option, + config: &Config, +) -> Result<()> { #[cfg(target_os = "emscripten")] { - let _ = (input, project, output); + let _ = (input, project, output, config); anyhow::bail!("'path export pi' requires a native environment"); } @@ -999,10 +1034,10 @@ fn run_pi(input: String, project: Option, output: Option) -> R }; let cwd_str = project_dir.to_string_lossy().to_string(); - let session = build_pi_session(&input, &cwd_str)?; + let session = build_pi_session(config, &input, &cwd_str)?; match (project, output) { - (Some(_), None) => write_into_pi_project(&session, &cwd_str)?, + (Some(_), None) => write_into_pi_project(&session, &cwd_str, config)?, (None, Some(out_path)) => write_pi_to_output_path(&session, &out_path)?, (None, None) => write_pi_to_stdout(&session)?, (Some(_), Some(_)) => unreachable!("clap enforces conflicts_with"), @@ -1012,10 +1047,10 @@ fn run_pi(input: String, project: Option, output: Option) -> R } #[cfg(not(target_os = "emscripten"))] -fn build_pi_session(input: &str, cwd: &str) -> Result { +fn build_pi_session(config: &Config, input: &str, cwd: &str) -> Result { use toolpath_convo::ConversationProjector; - let path = load_path_doc(input)?; + let path = load_path_doc(config, input)?; let view = toolpath_convo::extract_conversation(&path); let projector = toolpath_pi::project::PiProjector::new().with_cwd(cwd.to_string()); @@ -1032,8 +1067,12 @@ fn build_pi_session(input: &str, cwd: &str) -> Result { /// `--project` mode: write the resume-ready layout under /// `~/.pi/agent/sessions/----/.jsonl`. #[cfg(not(target_os = "emscripten"))] -fn write_into_pi_project(session: &toolpath_pi::PiSession, cwd: &str) -> Result<()> { - let resolver = toolpath_pi::PathResolver::new(); +fn write_into_pi_project( + session: &toolpath_pi::PiSession, + cwd: &str, + config: &Config, +) -> Result<()> { + let resolver = providers::pi_resolver(config); let project_dir = resolver.project_dir(cwd); std::fs::create_dir_all(&project_dir) .with_context(|| format!("create {}", project_dir.display()))?; @@ -1120,10 +1159,15 @@ fn serialize_pi_jsonl(session: &toolpath_pi::PiSession) -> Result { // ── Codex ───────────────────────────────────────────────────────────── -fn run_codex(input: String, project: Option, output: Option) -> Result<()> { +fn run_codex( + input: String, + project: Option, + output: Option, + config: &Config, +) -> Result<()> { #[cfg(target_os = "emscripten")] { - let _ = (input, project, output); + let _ = (input, project, output, config); anyhow::bail!("'path export codex' requires a native environment"); } @@ -1140,10 +1184,10 @@ fn run_codex(input: String, project: Option, output: Option) - }; let cwd_str = project_dir.to_string_lossy().to_string(); - let session = build_codex_session(&input, &cwd_str)?; + let session = build_codex_session(config, &input, &cwd_str)?; match (project, output) { - (Some(_), None) => write_into_codex_project(&session)?, + (Some(_), None) => write_into_codex_project(&session, config)?, (None, Some(out_path)) => write_codex_to_output_path(&session, &out_path)?, (None, None) => write_codex_to_stdout(&session)?, (Some(_), Some(_)) => unreachable!("clap enforces conflicts_with"), @@ -1153,10 +1197,10 @@ fn run_codex(input: String, project: Option, output: Option) - } #[cfg(not(target_os = "emscripten"))] -fn build_codex_session(input: &str, cwd: &str) -> Result { +fn build_codex_session(config: &Config, input: &str, cwd: &str) -> Result { use toolpath_convo::ConversationProjector; - let path = load_path_doc(input)?; + let path = load_path_doc(config, input)?; let view = toolpath_convo::extract_conversation(&path); let projector = toolpath_codex::project::CodexProjector::new().with_cwd(cwd.to_string()); @@ -1175,9 +1219,9 @@ fn build_codex_session(input: &str, cwd: &str) -> Result Result<()> { +fn write_into_codex_project(session: &toolpath_codex::Session, config: &Config) -> Result<()> { let session_ts = codex_session_timestamp(session)?; - let resolver = toolpath_codex::PathResolver::new(); + let resolver = providers::codex_resolver(config); let sessions_root = resolver .sessions_root() .map_err(|e| anyhow::anyhow!("Cannot resolve Codex sessions dir: {}", e))?; @@ -1384,20 +1428,25 @@ fn serialize_codex_jsonl(session: &toolpath_codex::Session) -> Result { // ── Opencode ────────────────────────────────────────────────────────── -fn run_opencode(input: String, project: Option, output: Option) -> Result<()> { +fn run_opencode( + input: String, + project: Option, + output: Option, + config: &Config, +) -> Result<()> { #[cfg(target_os = "emscripten")] { - let _ = (input, project, output); + let _ = (input, project, output, config); anyhow::bail!("'path export opencode' requires a native environment"); } #[cfg(not(target_os = "emscripten"))] { - let path = load_path_doc(&input)?; + let path = load_path_doc(config, &input)?; match (project, output) { (Some(project_dir), None) => { let session = build_opencode_session(&path, Some(&project_dir))?; - write_into_opencode_db(&session, &project_dir)?; + write_into_opencode_db(&session, &project_dir, config)?; } (None, Some(out_path)) => { let session = build_opencode_session(&path, None)?; @@ -1437,13 +1486,12 @@ fn build_opencode_session( fn write_into_opencode_db( session: &toolpath_opencode::Session, project_dir: &std::path::Path, + config: &Config, ) -> Result<()> { - use toolpath_opencode::PathResolver; - let project_dir = std::fs::canonicalize(project_dir) .with_context(|| format!("resolve project path {}", project_dir.display()))?; - let resolver = PathResolver::new(); + let resolver = providers::opencode_resolver(config); let db_path = resolver .db_path() .map_err(|e| anyhow::anyhow!("Cannot resolve opencode db path: {}", e))?; @@ -1625,28 +1673,33 @@ fn write_opencode_to_stdout(session: &toolpath_opencode::Session) -> Result<()> // ── Cursor ──────────────────────────────────────────────────────────── -fn run_cursor(input: String, project: Option, output: Option) -> Result<()> { +fn run_cursor( + input: String, + project: Option, + output: Option, + config: &Config, +) -> Result<()> { #[cfg(target_os = "emscripten")] { - let _ = (input, project, output); + let _ = (input, project, output, config); anyhow::bail!("'path export cursor' requires a native environment"); } #[cfg(not(target_os = "emscripten"))] { - let path = load_path_doc(&input)?; + let path = load_path_doc(config, &input)?; match (project, output) { (Some(project_dir), None) => { - let session = build_cursor_session(&path, Some(&project_dir))?; - write_into_cursor_db(&session, &project_dir)?; + let session = build_cursor_session(&path, Some(&project_dir), config)?; + write_into_cursor_db(&session, &project_dir, config)?; } (None, Some(out_path)) => { - let session = build_cursor_session(&path, None)?; + let session = build_cursor_session(&path, None, config)?; write_cursor_to_output_path(&session, &out_path)?; } (None, None) => { let cwd = std::env::current_dir().ok(); - let session = build_cursor_session(&path, cwd.as_deref())?; + let session = build_cursor_session(&path, cwd.as_deref(), config)?; write_cursor_to_stdout(&session)?; } (Some(_), Some(_)) => unreachable!("clap enforces conflicts_with"), @@ -1659,10 +1712,11 @@ fn run_cursor(input: String, project: Option, output: Option) pub(crate) fn project_cursor( path: &toolpath::v1::Path, project_dir: &std::path::Path, + config: &Config, ) -> Result { - let session = build_cursor_session(path, Some(project_dir))?; + let session = build_cursor_session(path, Some(project_dir), config)?; let id = session.data.composer_id.clone(); - write_into_cursor_db(&session, project_dir)?; + write_into_cursor_db(&session, project_dir, config)?; Ok(id) } @@ -1670,9 +1724,10 @@ pub(crate) fn project_cursor( fn build_cursor_session( path: &toolpath::v1::Path, project_dir: Option<&std::path::Path>, + config: &Config, ) -> Result { use toolpath_convo::ConversationProjector; - use toolpath_cursor::{CursorProjector, PathResolver}; + use toolpath_cursor::CursorProjector; let view = toolpath_convo::extract_conversation(path); let mut projector = CursorProjector::new(); @@ -1681,7 +1736,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 = PathResolver::new(); + let resolver = providers::cursor_resolver(config); if let Ok(ensured) = resolver.ensure_workspace_storage_entry(&canonical, stable_workspace_id_for) { @@ -1713,13 +1768,12 @@ fn stable_workspace_id_for(folder: &std::path::Path) -> String { fn write_into_cursor_db( session: &toolpath_cursor::CursorSession, project_dir: &std::path::Path, + config: &Config, ) -> Result<()> { - use toolpath_cursor::PathResolver; - let project_dir = std::fs::canonicalize(project_dir) .with_context(|| format!("resolve project path {}", project_dir.display()))?; - let resolver = PathResolver::new(); + let resolver = providers::cursor_resolver(config); let db_path = resolver .db_path() .map_err(|e| anyhow::anyhow!("Cannot resolve Cursor state.vscdb path: {}", e))?; @@ -1895,10 +1949,10 @@ fn write_cursor_to_stdout(session: &toolpath_cursor::CursorSession) -> Result<() // ── Pathbase ────────────────────────────────────────────────────────── -fn run_pathbase(args: PathbaseExportArgs) -> Result<()> { +fn run_pathbase(args: PathbaseExportArgs, config: &Config) -> Result<()> { #[cfg(target_os = "emscripten")] { - let _ = args; + let _ = (args, config); anyhow::bail!("'path export pathbase' requires a native environment with network access"); } @@ -1906,7 +1960,7 @@ fn run_pathbase(args: PathbaseExportArgs) -> Result<()> { { use crate::cmd_pathbase::preflight_auth; - let file = cache_ref(&args.input)?; + let file = cache_ref(config, &args.input)?; let body = std::fs::read_to_string(&file) .with_context(|| format!("Failed to read {}", file.display()))?; let upload = PathbaseUploadArgs { @@ -1916,9 +1970,9 @@ fn run_pathbase(args: PathbaseExportArgs) -> Result<()> { name: args.name, public: args.public, }; - let base_url = resolve_upload_base_url(&upload); + let base_url = resolve_upload_base_url(config, &upload); let needs_auth = upload.repo.is_some() || upload.public || upload.name.is_some(); - let auth = preflight_auth(&base_url, upload.anon, needs_auth)?; + let auth = preflight_auth(config, &base_url, upload.anon, needs_auth)?; let summary_source = file.display().to_string(); run_pathbase_inner(auth, base_url, upload, &body, &summary_source) } @@ -1928,18 +1982,18 @@ fn run_pathbase(args: PathbaseExportArgs) -> Result<()> { /// or the default. Mirrors the order used inside `run_pathbase_inner` so /// `cmd_share`'s pre-flight resolution agrees with the eventual upload. #[cfg(not(target_os = "emscripten"))] -pub(crate) fn resolve_upload_base_url(args: &PathbaseUploadArgs) -> String { +pub(crate) fn resolve_upload_base_url(config: &Config, args: &PathbaseUploadArgs) -> String { use crate::cmd_pathbase::{credentials_path, load_session, resolve_url}; if let Some(u) = &args.url { - return resolve_url(Some(u.clone())); + return resolve_url(config, Some(u.clone())); } - if let Ok(path) = credentials_path() + if let Ok(path) = credentials_path(config) && let Ok(Some(s)) = load_session(&path) { return s.url; } - resolve_url(None) + resolve_url(config, None) } #[cfg(not(target_os = "emscripten"))] @@ -2074,6 +2128,26 @@ mod tests { use std::collections::HashMap; use toolpath::v1::{ArtifactChange, PathIdentity, Step, StepIdentity, StructuralChange}; + /// A `Config` rooted at a test's temp home. The export path builds + /// every resolver from it, so no test needs to touch `$HOME`. + fn config_with_home(home: &std::path::Path) -> Config { + Config { + home: Some(home.to_path_buf()), + ..Config::default() + } + } + + /// 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"; @@ -2156,6 +2230,7 @@ mod tests { None, Some(output_path.clone()), false, + &Config::default(), ) .unwrap(); @@ -2199,8 +2274,14 @@ mod tests { }; std::fs::write(&input_path, serde_json::to_string(&multi).unwrap()).unwrap(); - let err = - run_claude(input_path.to_string_lossy().to_string(), None, None, false).unwrap_err(); + let err = run_claude( + input_path.to_string_lossy().to_string(), + None, + None, + false, + &Config::default(), + ) + .unwrap_err(); assert!(err.to_string().contains("single-path graph")); } @@ -2209,8 +2290,14 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let input_path = temp.path().join("input.json"); std::fs::write(&input_path, "not json").unwrap(); - let err = - run_claude(input_path.to_string_lossy().to_string(), None, None, false).unwrap_err(); + let err = run_claude( + input_path.to_string_lossy().to_string(), + None, + None, + false, + &Config::default(), + ) + .unwrap_err(); assert!(err.to_string().contains("parse") || err.to_string().contains("Failed")); } @@ -2274,30 +2361,17 @@ mod tests { let input_path = temp.path().join("doc.json"); std::fs::write(&input_path, serde_json::to_string(&doc).unwrap()).unwrap(); - // Override HOME so `PathResolver::new()` lands in the temp dir. - let _g = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let prior_home = std::env::var_os("HOME"); - unsafe { - std::env::set_var("HOME", &fake_home); - } - let result = run_gemini( + run_gemini( input_path.to_string_lossy().to_string(), Some(project_dir.clone()), None, - ); - unsafe { - match prior_home { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - } - result.expect("export gemini"); + &config_with_home(&fake_home), + ) + .expect("export gemini"); // The file landed at chats/session-*.json (flat, prefixed). let canon_project = std::fs::canonicalize(&project_dir).unwrap(); - let resolver = PathResolver::new().with_home(&fake_home); + let resolver = PathResolver::new(&fake_home); let chats_dir = resolver.chats_dir(canon_project.to_str().unwrap()).unwrap(); let session_files: Vec = std::fs::read_dir(&chats_dir) @@ -2366,6 +2440,7 @@ mod tests { input_path.to_string_lossy().to_string(), Some(project), None, + &Config::default(), ) .expect_err("should reject multi-path graph"); assert!(err.to_string().contains("single-path graph")); @@ -2430,6 +2505,7 @@ mod tests { input_path.to_string_lossy().to_string(), None, Some(out_path.clone()), + &Config::default(), ) .expect("export gemini --output"); @@ -2532,25 +2608,13 @@ mod tests { let input_path = temp.path().join("doc.json"); std::fs::write(&input_path, graph.to_json().unwrap()).unwrap(); - let _g = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let prior_home = std::env::var_os("HOME"); - unsafe { - std::env::set_var("HOME", &fake_home); - } - let result = run_pi( + run_pi( input_path.to_string_lossy().to_string(), Some(project_dir.clone()), None, - ); - unsafe { - match prior_home { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - } - result.expect("export pi"); + &config_with_home(&fake_home), + ) + .expect("export pi"); let canon_project = std::fs::canonicalize(&project_dir).unwrap(); let resolver = PathResolver::new().with_home(&fake_home); @@ -2607,6 +2671,7 @@ mod tests { input_path.to_string_lossy().to_string(), Some(project), None, + &Config::default(), ) .expect_err("should reject empty graph"); assert!(err.to_string().contains("single-path")); @@ -2662,6 +2727,7 @@ mod tests { input_path.to_string_lossy().to_string(), None, Some(out_path.clone()), + &Config::default(), ) .expect("export pi --output"); @@ -2727,6 +2793,7 @@ mod tests { input_path.to_string_lossy().to_string(), None, Some(out_path.clone()), + &Config::default(), ) .expect("export codex --output"); @@ -2753,8 +2820,8 @@ mod tests { #[test] fn codex_writes_into_dated_sessions_dir_with_project() { // `--project DIR` mode writes to - // `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`. The - // resolver's HOME-based default is overridden via $HOME. + // `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` under the + // injected home. use toolpath_codex::PathResolver; let temp = tempfile::tempdir().unwrap(); @@ -2804,25 +2871,13 @@ mod tests { let input_path = temp.path().join("doc.json"); std::fs::write(&input_path, graph.to_json().unwrap()).unwrap(); - let _g = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let prior_home = std::env::var_os("HOME"); - unsafe { - std::env::set_var("HOME", &fake_home); - } - let result = run_codex( + run_codex( input_path.to_string_lossy().to_string(), Some(project_dir.clone()), None, - ); - unsafe { - match prior_home { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - } - result.expect("export codex --project"); + &config_with_home(&fake_home), + ) + .expect("export codex --project"); let resolver = PathResolver::new().with_home(&fake_home); let dated_dir = resolver @@ -2880,6 +2935,7 @@ mod tests { input_path.to_string_lossy().to_string(), Some(project), None, + &Config::default(), ) .expect_err("should reject empty graph"); assert!(err.to_string().contains("single-path")); @@ -2898,27 +2954,25 @@ mod tests { ) .unwrap(); - let _g = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - unsafe { - std::env::set_var(crate::config::CONFIG_DIR_ENV, temp.path()); - } - let err = run_pathbase(PathbaseExportArgs { - input: input_path.to_string_lossy().to_string(), - url: Some("http://127.0.0.1:1".to_string()), - anon: false, - repo: Some(RepoSpec { - owner: "alex".to_string(), - name: "pathstash".to_string(), - }), - name: None, - public: false, - }) + let config = Config { + toolpath_config_dir: Some(temp.path().to_path_buf()), + ..Config::default() + }; + let err = run_pathbase( + PathbaseExportArgs { + input: input_path.to_string_lossy().to_string(), + url: Some("http://127.0.0.1:1".to_string()), + anon: false, + repo: Some(RepoSpec { + owner: "alex".to_string(), + name: "pathstash".to_string(), + }), + name: None, + public: false, + }, + &config, + ) .unwrap_err(); - unsafe { - std::env::remove_var(crate::config::CONFIG_DIR_ENV); - } assert!( err.to_string().contains("Not logged in"), "expected `Not logged in` error, got: {err}" @@ -3006,6 +3060,7 @@ mod tests { input_path.to_string_lossy().to_string(), None, Some(out_path.clone()), + &Config::default(), ) .unwrap(); @@ -3070,31 +3125,13 @@ mod tests { ) .unwrap(); - let _g = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let prev_home = std::env::var_os("HOME"); - let prev_xdg = std::env::var_os("XDG_DATA_HOME"); - unsafe { - std::env::set_var("HOME", &fake_home); - std::env::remove_var("XDG_DATA_HOME"); - } - let result = run_opencode( + run_opencode( input_path.to_string_lossy().to_string(), Some(project_dir.clone()), None, - ); - unsafe { - match prev_home { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - match prev_xdg { - Some(v) => std::env::set_var("XDG_DATA_HOME", v), - None => std::env::remove_var("XDG_DATA_HOME"), - } - } - result.expect("export opencode --project"); + &config_with_opencode_home(&fake_home), + ) + .expect("export opencode --project"); let conn = rusqlite::Connection::open(data_dir.join("opencode.db")).unwrap(); let session_count: i64 = conn @@ -3121,7 +3158,13 @@ mod tests { }); std::fs::write(&input_path, empty_graph.to_string()).unwrap(); - let err = run_opencode(input_path.to_string_lossy().to_string(), None, None).unwrap_err(); + let err = run_opencode( + input_path.to_string_lossy().to_string(), + None, + None, + &Config::default(), + ) + .unwrap_err(); assert!(err.to_string().contains("single-path")); } @@ -3182,20 +3225,7 @@ mod tests { let session_id = "claude-wrapper-test-session"; let path = make_convo_path(&format!("claude-code://{}", session_id)); - let _g = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let prior_home = std::env::var_os("HOME"); - unsafe { - std::env::set_var("HOME", &fake_home); - } - let result = project_claude(&path, &cwd); - unsafe { - match prior_home { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - } + let result = project_claude(&path, &cwd, &config_with_home(&fake_home)); let returned_id = match result.expect("project_claude should succeed") { ClaudeProjection::Written { session_id } => session_id, @@ -3221,30 +3251,18 @@ mod tests { let session_id = "claude-clobber-test-session"; let path = make_convo_path(&format!("claude-code://{}", session_id)); - let _g = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let prior_home = std::env::var_os("HOME"); - unsafe { - std::env::set_var("HOME", &fake_home); - } - let first = project_claude(&path, &cwd); + let config = config_with_home(&fake_home); + let first = project_claude(&path, &cwd, &config); // Simulate local divergence: the session gained content after the // first projection. - let session_file = claude_session_file(session_id, &cwd) + let session_file = claude_session_file(session_id, &cwd, &config) .unwrap() .expect("first projection must have written the session file"); let mut contents = std::fs::read_to_string(&session_file).unwrap(); contents.push_str("{\"local\":\"divergence\"}\n"); std::fs::write(&session_file, &contents).unwrap(); - let second = project_claude(&path, &cwd); - unsafe { - match prior_home { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - } + let second = project_claude(&path, &cwd, &config); assert!(matches!( first.expect("first projection should succeed"), @@ -3276,22 +3294,10 @@ mod tests { std::fs::write(&input_path, serde_json::to_string(&doc).unwrap()).unwrap(); let input = input_path.to_string_lossy().to_string(); - let _g = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let prior_home = std::env::var_os("HOME"); - unsafe { - std::env::set_var("HOME", &fake_home); - } - let first = run_claude(input.clone(), Some(cwd.clone()), None, false); - let second = run_claude(input.clone(), Some(cwd.clone()), None, false); - let forced = run_claude(input, Some(cwd.clone()), None, true); - unsafe { - match prior_home { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - } + let config = config_with_home(&fake_home); + let first = run_claude(input.clone(), Some(cwd.clone()), None, false, &config); + let second = run_claude(input.clone(), Some(cwd.clone()), None, false, &config); + let forced = run_claude(input, Some(cwd.clone()), None, true, &config); first.expect("first export should succeed"); let err = second.expect_err("re-export without --force must fail"); @@ -3313,20 +3319,7 @@ mod tests { let session_uuid = "11111111-2222-3333-4444-aaaaaaaaaaaa"; let path = make_convo_path(&format!("gemini-cli://{}", session_uuid)); - let _g = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let prior_home = std::env::var_os("HOME"); - unsafe { - std::env::set_var("HOME", &fake_home); - } - let result = project_gemini(&path, &cwd); - unsafe { - match prior_home { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - } + let result = project_gemini(&path, &cwd, &config_with_home(&fake_home)); let returned_id = result.expect("project_gemini should succeed"); assert_eq!(returned_id, session_uuid); @@ -3346,20 +3339,7 @@ mod tests { let session_uuid = "019dabc6-cccc-dddd-eeee-ffffffffffff"; let path = make_convo_path(&format!("codex://{}", session_uuid)); - let _g = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let prior_home = std::env::var_os("HOME"); - unsafe { - std::env::set_var("HOME", &fake_home); - } - let result = project_codex(&path, &cwd); - unsafe { - match prior_home { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - } + let result = project_codex(&path, &cwd, &config_with_home(&fake_home)); let returned_id = result.expect("project_codex should succeed"); assert_eq!(returned_id, session_uuid); @@ -3419,26 +3399,7 @@ mod tests { // which adds the `ses_` prefix if not already present. let path = make_convo_path("opencode://ses_wrapper-test"); - let _g = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let prior_home = std::env::var_os("HOME"); - let prior_xdg = std::env::var_os("XDG_DATA_HOME"); - unsafe { - std::env::set_var("HOME", &fake_home); - std::env::remove_var("XDG_DATA_HOME"); - } - let result = project_opencode(&path, &cwd); - unsafe { - match prior_home { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - match prior_xdg { - Some(v) => std::env::set_var("XDG_DATA_HOME", v), - None => std::env::remove_var("XDG_DATA_HOME"), - } - } + let result = project_opencode(&path, &cwd, &config_with_opencode_home(&fake_home)); let returned_id = result.expect("project_opencode should succeed"); assert_eq!(returned_id, "ses_wrapper-test"); @@ -3465,20 +3426,7 @@ mod tests { let session_id = "pi-wrapper-test-session"; let path = make_convo_path(&format!("pi://{}", session_id)); - let _g = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let prior_home = std::env::var_os("HOME"); - unsafe { - std::env::set_var("HOME", &fake_home); - } - let result = project_pi(&path, &cwd); - unsafe { - match prior_home { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - } + let result = project_pi(&path, &cwd, &config_with_home(&fake_home)); let returned_id = result.expect("project_pi should succeed"); assert_eq!(returned_id, session_id); diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 7d210beb..ffe4bd5e 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -19,12 +19,14 @@ use crate::artifact::{ArtifactRef, ArtifactType}; #[cfg(not(target_os = "emscripten"))] use crate::cache::make_id; use crate::cache::write_cached; +use crate::config::Config; use crate::derive::{ DerivedDoc, derive_claude_session_with, derive_codex_session_with, derive_copilot_session_with, derive_gemini_session_with, derive_pi_session_with, }; #[cfg(not(target_os = "emscripten"))] use crate::derive::{derive_cursor_session_with, derive_opencode_session_with, doc_inner_id}; +use crate::providers; #[derive(Subcommand, Debug)] pub enum ImportSource { @@ -199,12 +201,19 @@ pub struct ImportArgs { pub no_cache: bool, } -pub fn run(args: ImportArgs, pretty: bool) -> Result<()> { - let docs = derive(args.source)?; - emit(&docs, args.force, args.no_cache, pretty) +pub fn run(args: ImportArgs, pretty: bool, config: &Config) -> Result<()> { + let docs = derive(args.source, config)?; + emit(&docs, args.force, args.no_cache, pretty, config) } -fn emit(docs: &[DerivedDoc], force: bool, no_cache: bool, pretty: bool) -> Result<()> { +#[cfg_attr(target_os = "emscripten", expect(unused_variables))] +fn emit( + docs: &[DerivedDoc], + force: bool, + no_cache: bool, + pretty: bool, + config: &Config, +) -> Result<()> { if docs.is_empty() { anyhow::bail!("no documents produced"); } @@ -223,20 +232,23 @@ fn emit(docs: &[DerivedDoc], force: bool, no_cache: bool, pretty: bool) -> Resul #[cfg(not(target_os = "emscripten"))] if !force && let Some(stub) = &d.provenance - && crate::sync::record_is_current(stub, &d.cache_id) + && crate::sync::record_is_current(config, stub, &d.cache_id) { - println!("{}", crate::cache::cache_path(&d.cache_id)?.display()); + println!( + "{}", + crate::cache::cache_path(config, &d.cache_id)?.display() + ); eprintln!( "{} is already up to date (pass --force to re-derive)", d.cache_id ); continue; } - let path = write_cached(&d.cache_id, &d.doc, force)?; + let path = write_cached(config, &d.cache_id, &d.doc, force)?; println!("{}", path.display()); #[cfg(not(target_os = "emscripten"))] if let Some(stub) = &d.provenance - && let Err(e) = crate::sync::record_artifact(stub, &d.cache_id) + && let Err(e) = crate::sync::record_artifact(config, stub, &d.cache_id) { eprintln!("warning: sync manifest not updated: {e}"); } @@ -255,7 +267,7 @@ fn doc_summary(doc: &Graph) -> String { } } -fn derive(source: ImportSource) -> Result> { +fn derive(source: ImportSource, config: &Config) -> Result> { match source { ImportSource::Git { repo, @@ -275,32 +287,32 @@ fn derive(source: ImportSource) -> Result> { project, session, all, - } => derive_claude(project, session, all), + } => derive_claude(project, session, all, config), ImportSource::Gemini { project, session, all, - } => derive_gemini(project, session, all), - ImportSource::Codex { session, all } => derive_codex(session, all), - ImportSource::Copilot { session, all } => derive_copilot(session, all), + } => derive_gemini(project, session, all, config), + ImportSource::Codex { session, all } => derive_codex(session, all, config), + ImportSource::Copilot { session, all } => derive_copilot(session, all, config), ImportSource::Opencode { session, all, project, no_snapshot_diffs, - } => derive_opencode(session, all, project, no_snapshot_diffs), + } => derive_opencode(session, all, project, no_snapshot_diffs, config), ImportSource::Cursor { session, all, project, - } => derive_cursor(session, all, project), + } => derive_cursor(session, all, project, config), ImportSource::Pi { project, session, all, base, - } => derive_pi(project, session, all, base), - ImportSource::Pathbase { target, url } => derive_pathbase(target, url), + } => derive_pi(project, session, all, base, config), + ImportSource::Pathbase { target, url } => derive_pathbase(target, url, config), } } @@ -424,8 +436,9 @@ fn derive_claude( project: Option, session: Option, all: bool, + config: &Config, ) -> Result> { - let manager = toolpath_claude::ClaudeConvo::new(); + let manager = toolpath_claude::ClaudeConvo::with_resolver(providers::claude_resolver(config)); derive_claude_with_manager(&manager, project, session, all) } @@ -621,8 +634,10 @@ fn derive_gemini( project: Option, session: Option, all: bool, + config: &Config, ) -> Result> { - let manager = toolpath_gemini::GeminiConvo::new(); + let manager = + toolpath_gemini::GeminiConvo::with_resolver(providers::require_gemini_resolver(config)?); derive_gemini_with_manager(&manager, project, session, all) } @@ -808,8 +823,8 @@ fn pick_gemini_global( Ok(Some(parse_project_session(&selected))) } -fn derive_codex(session: Option, all: bool) -> Result> { - let manager = toolpath_codex::CodexConvo::new(); +fn derive_codex(session: Option, all: bool, config: &Config) -> Result> { + let manager = toolpath_codex::CodexConvo::with_resolver(providers::codex_resolver(config)); let session_ids: Vec = match (session, all) { (Some(s), _) => vec![s], @@ -909,8 +924,9 @@ fn pick_codex(manager: &toolpath_codex::CodexConvo) -> Result Ok(Some(parse_single_id(&selected))) } -fn derive_copilot(session: Option, all: bool) -> Result> { - let manager = toolpath_copilot::CopilotConvo::new(); +fn derive_copilot(session: Option, all: bool, config: &Config) -> Result> { + let manager = + toolpath_copilot::CopilotConvo::with_resolver(providers::copilot_resolver(config)); let session_ids: Vec = match (session, all) { (Some(s), _) => vec![s], @@ -1017,10 +1033,11 @@ fn derive_opencode( all: bool, project: Option, no_snapshot_diffs: bool, + config: &Config, ) -> Result> { #[cfg(target_os = "emscripten")] { - let _ = (session, all, project, no_snapshot_diffs); + let _ = (session, all, project, no_snapshot_diffs, config); anyhow::bail!( "'path import opencode' requires a native environment (SQLite + git2 not available under wasm)" ); @@ -1028,7 +1045,8 @@ fn derive_opencode( #[cfg(not(target_os = "emscripten"))] { - let manager = toolpath_opencode::OpencodeConvo::new(); + let manager = + toolpath_opencode::OpencodeConvo::with_resolver(providers::opencode_resolver(config)); let derive_one = |sid: &str| derive_opencode_session_with(&manager, sid, no_snapshot_diffs); let session_ids: Vec = match (session, all) { @@ -1129,10 +1147,11 @@ fn derive_cursor( session: Option, all: bool, project: Option, + config: &Config, ) -> Result> { #[cfg(target_os = "emscripten")] { - let _ = (session, all, project); + let _ = (session, all, project, config); anyhow::bail!( "'path import cursor' requires a native environment (SQLite not available under wasm)" ); @@ -1140,7 +1159,8 @@ fn derive_cursor( #[cfg(not(target_os = "emscripten"))] { - let manager = toolpath_cursor::CursorConvo::new(); + let manager = + toolpath_cursor::CursorConvo::with_resolver(providers::cursor_resolver(config)); let derive_one = |sid: &str| derive_cursor_session_with(&manager, sid); let workspace_filter = project @@ -1281,13 +1301,13 @@ fn derive_pi( session: Option, all: bool, base: Option, + config: &Config, ) -> Result> { - let manager = if let Some(path) = base { - let resolver = toolpath_pi::PathResolver::new().with_sessions_dir(&path); - toolpath_pi::PiConvo::with_resolver(resolver) - } else { - toolpath_pi::PiConvo::new() - }; + let mut resolver = providers::pi_resolver(config); + if let Some(path) = base { + resolver = resolver.with_sessions_dir(&path); + } + let manager = toolpath_pi::PiConvo::with_resolver(resolver); derive_pi_with_manager(&manager, project, session, all) } @@ -1515,16 +1535,21 @@ fn parse_rfc3339(s: &str) -> Option> { .map(|t| t.with_timezone(&chrono::Utc)) } -fn derive_pathbase(target: String, url_flag: Option) -> Result> { +fn derive_pathbase( + target: String, + url_flag: Option, + config: &Config, +) -> Result> { #[cfg(target_os = "emscripten")] { - let _ = (target, url_flag); + let _ = (target, url_flag, config); anyhow::bail!("'path import pathbase' requires a native environment with network access"); } #[cfg(not(target_os = "emscripten"))] { Ok(vec![crate::derive::pathbase_fetch_to_doc( + config, &target, url_flag.as_deref(), )?]) diff --git a/crates/path-cli/src/cmd_incept.rs b/crates/path-cli/src/cmd_incept.rs index 6edcc7db..fce7fca4 100644 --- a/crates/path-cli/src/cmd_incept.rs +++ b/crates/path-cli/src/cmd_incept.rs @@ -6,6 +6,8 @@ use anyhow::Result; use clap::Subcommand; use std::path::PathBuf; +use crate::config::Config; + #[derive(Subcommand, Debug)] pub enum InceptTarget { /// Incept a Toolpath document into a Claude Code session layout. @@ -45,7 +47,7 @@ pub enum InceptTarget { }, } -pub fn run(target: InceptTarget) -> Result<()> { +pub fn run(target: InceptTarget, config: &Config) -> Result<()> { match target { InceptTarget::Claude { input, @@ -54,12 +56,15 @@ pub fn run(target: InceptTarget) -> Result<()> { } => { let input = resolve_input(input)?; let (project, output) = default_project(project, output); - crate::cmd_export::run(crate::cmd_export::ExportTarget::Claude { - input, - project, - output, - force: false, - }) + crate::cmd_export::run( + crate::cmd_export::ExportTarget::Claude { + input, + project, + output, + force: false, + }, + config, + ) } InceptTarget::Cursor { input, @@ -68,11 +73,14 @@ pub fn run(target: InceptTarget) -> Result<()> { } => { let input = resolve_input(input)?; let (project, output) = default_project(project, output); - crate::cmd_export::run(crate::cmd_export::ExportTarget::Cursor { - input, - project, - output, - }) + crate::cmd_export::run( + crate::cmd_export::ExportTarget::Cursor { + input, + project, + output, + }, + config, + ) } } } diff --git a/crates/path-cli/src/cmd_list.rs b/crates/path-cli/src/cmd_list.rs index a2583138..0962cad4 100644 --- a/crates/path-cli/src/cmd_list.rs +++ b/crates/path-cli/src/cmd_list.rs @@ -1,6 +1,9 @@ #[cfg(not(target_os = "emscripten"))] use anyhow::Context; use anyhow::Result; + +use crate::config::Config; +use crate::providers; use clap::{Subcommand, ValueEnum}; use std::io::IsTerminal; use std::path::PathBuf; @@ -98,18 +101,23 @@ pub fn resolve_format(format: Option, json_flag: bool) -> ListFormat } } -pub fn run(source: ListSource, format: Option, json_flag: bool) -> Result<()> { +pub fn run( + source: ListSource, + format: Option, + json_flag: bool, + config: &Config, +) -> Result<()> { let fmt = resolve_format(format, json_flag); match source { ListSource::Git { repo, remote } => run_git(repo, remote, fmt), ListSource::Github { repo } => run_github(repo, fmt), - ListSource::Claude { project } => run_claude(project, fmt), - ListSource::Gemini { project } => run_gemini(project, fmt), - ListSource::Codex {} => run_codex(fmt), - ListSource::Copilot {} => run_copilot(fmt), - ListSource::Opencode { project } => run_opencode(project, fmt), - ListSource::Cursor { project } => run_cursor(project, fmt), - ListSource::Pi { project, base } => run_pi(project, base, fmt), + ListSource::Claude { project } => run_claude(project, fmt, config), + ListSource::Gemini { project } => run_gemini(project, fmt, config), + ListSource::Codex {} => run_codex(fmt, config), + ListSource::Copilot {} => run_copilot(fmt, config), + ListSource::Opencode { project } => run_opencode(project, fmt, config), + ListSource::Cursor { project } => run_cursor(project, fmt, config), + ListSource::Pi { project, base } => run_pi(project, base, fmt, config), } } @@ -270,8 +278,8 @@ fn run_github(repo: String, fmt: ListFormat) -> Result<()> { // ── Claude ────────────────────────────────────────────────────────────────── -fn run_claude(project: Option, fmt: ListFormat) -> Result<()> { - let manager = toolpath_claude::ClaudeConvo::new(); +fn run_claude(project: Option, fmt: ListFormat, config: &Config) -> Result<()> { + let manager = providers::claude_convo(config); match (project, fmt) { // TSV/JSON without --project: emit sessions across every project so @@ -436,8 +444,9 @@ fn emit_claude_tsv(m: &toolpath_claude::ConversationMetadata) { // ── Gemini ────────────────────────────────────────────────────────────────── -fn run_gemini(project: Option, fmt: ListFormat) -> Result<()> { - let manager = toolpath_gemini::GeminiConvo::new(); +fn run_gemini(project: Option, fmt: ListFormat, config: &Config) -> Result<()> { + let manager = + toolpath_gemini::GeminiConvo::with_resolver(providers::require_gemini_resolver(config)?); match (project, fmt) { (None, ListFormat::Tsv) => list_gemini_sessions_all(&manager, ListFormat::Tsv), @@ -610,8 +619,8 @@ fn emit_gemini_tsv(m: &toolpath_gemini::ConversationMetadata) { // ── Codex ─────────────────────────────────────────────────────────────────── -fn run_codex(fmt: ListFormat) -> Result<()> { - let manager = toolpath_codex::CodexConvo::new(); +fn run_codex(fmt: ListFormat, config: &Config) -> Result<()> { + let manager = providers::codex_convo(config); let sessions = manager .list_sessions() .map_err(|e| anyhow::anyhow!("{}", e))?; @@ -694,8 +703,8 @@ fn run_codex(fmt: ListFormat) -> Result<()> { // ── Copilot (preview) ───────────────────────────────────────────────────────── -fn run_copilot(fmt: ListFormat) -> Result<()> { - let manager = toolpath_copilot::CopilotConvo::new(); +fn run_copilot(fmt: ListFormat, config: &Config) -> Result<()> { + let manager = providers::copilot_convo(config); let sessions = manager .list_sessions() .map_err(|e| anyhow::anyhow!("{}", e))?; @@ -769,10 +778,10 @@ fn run_copilot(fmt: ListFormat) -> Result<()> { // ── opencode ──────────────────────────────────────────────────────────────── -fn run_opencode(project: Option, fmt: ListFormat) -> Result<()> { +fn run_opencode(project: Option, fmt: ListFormat, config: &Config) -> Result<()> { #[cfg(target_os = "emscripten")] { - let _ = (project, fmt); + let _ = (project, fmt, config); anyhow::bail!( "'path list opencode' requires a native environment (SQLite + git2 not available under wasm)" ); @@ -780,7 +789,7 @@ fn run_opencode(project: Option, fmt: ListFormat) -> Result<()> { #[cfg(not(target_os = "emscripten"))] { - let manager = toolpath_opencode::OpencodeConvo::new(); + let manager = providers::opencode_convo(config); let metas = manager .io() .list_session_metadata(project.as_deref()) @@ -859,10 +868,10 @@ fn run_opencode(project: Option, fmt: ListFormat) -> Result<()> { // ── Cursor ────────────────────────────────────────────────────────────────── -fn run_cursor(project: Option, fmt: ListFormat) -> Result<()> { +fn run_cursor(project: Option, fmt: ListFormat, config: &Config) -> Result<()> { #[cfg(target_os = "emscripten")] { - let _ = (project, fmt); + let _ = (project, fmt, config); anyhow::bail!( "'path list cursor' requires a native environment (SQLite not available under wasm)" ); @@ -870,7 +879,7 @@ fn run_cursor(project: Option, fmt: ListFormat) -> Result<()> { #[cfg(not(target_os = "emscripten"))] { - let manager = toolpath_cursor::CursorConvo::new(); + let manager = providers::cursor_convo(config); let mut metas = manager .io() .list_session_metadata() @@ -974,13 +983,13 @@ fn run_cursor(project: Option, fmt: ListFormat) -> Result<()> { // ── Pi ────────────────────────────────────────────────────────────────────── -fn run_pi(project: Option, base: Option, fmt: ListFormat) -> Result<()> { - let manager = if let Some(path) = base { - let resolver = toolpath_pi::PathResolver::new().with_sessions_dir(&path); - toolpath_pi::PiConvo::with_resolver(resolver) - } else { - toolpath_pi::PiConvo::new() - }; +fn run_pi( + project: Option, + base: Option, + fmt: ListFormat, + config: &Config, +) -> Result<()> { + let manager = providers::pi_convo(config, base.as_deref()); match (project, fmt) { (None, ListFormat::Tsv) => list_pi_sessions_all(&manager, ListFormat::Tsv), @@ -1474,7 +1483,7 @@ mod tests { ]}"#, ) .unwrap(); - let resolver = toolpath_gemini::PathResolver::new().with_gemini_dir(&gemini); + let resolver = toolpath_gemini::PathResolver::new(temp.path()).with_gemini_dir(&gemini); (temp, toolpath_gemini::GeminiConvo::with_resolver(resolver)) } @@ -1525,7 +1534,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let gemini = temp.path().join(".gemini"); std::fs::create_dir_all(gemini.join("tmp/empty")).unwrap(); - let resolver = toolpath_gemini::PathResolver::new().with_gemini_dir(&gemini); + let resolver = toolpath_gemini::PathResolver::new(temp.path()).with_gemini_dir(&gemini); let mgr = toolpath_gemini::GeminiConvo::with_resolver(resolver); let result = list_gemini_sessions(&mgr, "/nowhere", ListFormat::Pretty); assert!(result.is_ok()); diff --git a/crates/path-cli/src/cmd_p.rs b/crates/path-cli/src/cmd_p.rs index 3e079da6..2d731ddd 100644 --- a/crates/path-cli/src/cmd_p.rs +++ b/crates/path-cli/src/cmd_p.rs @@ -11,6 +11,8 @@ use anyhow::Result; use clap::Subcommand; use std::path::PathBuf; +use crate::config::Config; + #[derive(Subcommand, Debug)] pub enum PCommand { /// List available sources (branches, projects, sessions) @@ -93,22 +95,22 @@ pub enum PCommand { }, } -pub fn run(command: PCommand, pretty: bool) -> Result<()> { +pub fn run(command: PCommand, pretty: bool, config: &Config) -> Result<()> { match command { PCommand::List { source, format, json, - } => crate::cmd_list::run(source, format, json), - PCommand::Import { args } => crate::cmd_import::run(args, pretty), - PCommand::Export { target } => crate::cmd_export::run(target), - PCommand::Cache { op } => crate::cmd_cache::run(op), + } => crate::cmd_list::run(source, format, json, config), + PCommand::Import { args } => crate::cmd_import::run(args, pretty, config), + PCommand::Export { target } => crate::cmd_export::run(target, config), + PCommand::Cache { op } => crate::cmd_cache::run(op, config), PCommand::Render { format } => crate::cmd_render::run(format), PCommand::Merge { inputs, title } => crate::cmd_merge::run(inputs, title, pretty), PCommand::Validate { input } => crate::cmd_validate::run(input), - PCommand::Derive { source } => crate::cmd_derive::run(source, pretty), - PCommand::Project { target } => crate::cmd_project::run(target), - PCommand::Incept { target } => crate::cmd_incept::run(target), + PCommand::Derive { source } => crate::cmd_derive::run(source, pretty, config), + PCommand::Project { target } => crate::cmd_project::run(target, config), + PCommand::Incept { target } => crate::cmd_incept::run(target, config), PCommand::Track { op } => crate::cmd_track::run(op, pretty), PCommand::Query { op } => crate::cmd_p_query::run(op, pretty), } diff --git a/crates/path-cli/src/cmd_pathbase.rs b/crates/path-cli/src/cmd_pathbase.rs index bfff3f7b..94259aa5 100644 --- a/crates/path-cli/src/cmd_pathbase.rs +++ b/crates/path-cli/src/cmd_pathbase.rs @@ -13,8 +13,7 @@ use anyhow::{Context, Result, anyhow, bail}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -pub(crate) use crate::config::PATHBASE_URL_ENV; -use crate::config::config_dir; +use crate::config::Config; pub(crate) const DEFAULT_URL: &str = "https://pathbase.dev"; @@ -61,11 +60,19 @@ pub(crate) struct CreatedGraph { // ── URL + prompt helpers ──────────────────────────────────────────────── -pub(crate) fn resolve_url(cli_url: Option) -> String { +/// Strip a trailing `/` so a server URL concatenates and compares +/// uniformly. +pub(crate) fn normalize_url(raw: &str) -> String { + raw.trim_end_matches('/').to_string() +} + +/// The Pathbase server for this invocation: the `--url` flag, then +/// `$PATHBASE_URL` via [`Config`], then [`DEFAULT_URL`]. +pub(crate) fn resolve_url(config: &Config, cli_url: Option) -> String { let raw = cli_url - .or_else(|| std::env::var(PATHBASE_URL_ENV).ok()) + .or_else(|| config.pathbase_url.clone()) .unwrap_or_else(|| DEFAULT_URL.to_string()); - raw.trim_end_matches('/').to_string() + normalize_url(&raw) } /// Extract `scheme://host[:port]` from a URL, dropping any path/query. @@ -260,11 +267,16 @@ pub(crate) enum AuthMode { /// `host_of(base_url) != host_of(stored.url)` triggers an advisory warning /// before the credentials probe so the user sees the mismatch even if /// `api_me` happens to succeed. -pub(crate) fn preflight_auth(base_url: &str, anon: bool, needs_auth: bool) -> Result { +pub(crate) fn preflight_auth( + config: &Config, + base_url: &str, + anon: bool, + needs_auth: bool, +) -> Result { if anon { return Ok(AuthMode::Anon); } - let stored = load_session(&credentials_path()?)?; + let stored = load_session(&credentials_path(config)?)?; let go_anon = stored.is_none() && !needs_auth; if go_anon { @@ -653,8 +665,10 @@ pub(crate) fn graphs_download( // ── File storage ──────────────────────────────────────────────────────── -pub(crate) fn credentials_path() -> Result { - Ok(config_dir()?.join(crate::config::CREDENTIALS_FILE_NAME)) +pub(crate) fn credentials_path(config: &Config) -> Result { + Ok(config + .config_dir()? + .join(crate::config::CREDENTIALS_FILE_NAME)) } pub(crate) fn store_session(path: &Path, s: &StoredSession) -> Result<()> { @@ -715,12 +729,43 @@ pub(crate) mod tests { } } + fn config_with_url(url: Option<&str>) -> Config { + Config { + pathbase_url: url.map(str::to_string), + ..Config::default() + } + } + + fn config_with_dir(dir: &std::path::Path) -> Config { + Config { + toolpath_config_dir: Some(dir.to_path_buf()), + ..Config::default() + } + } + #[test] fn resolve_url_prefers_cli_flag() { - let got = resolve_url(Some("https://example.com/".into())); + let config = config_with_url(Some("https://from-env.example")); + let got = resolve_url(&config, Some("https://example.com/".into())); assert_eq!(got, "https://example.com"); } + #[test] + fn resolve_url_falls_back_to_config_then_default() { + let config = config_with_url(Some("https://from-env.example/")); + assert_eq!(resolve_url(&config, None), "https://from-env.example"); + assert_eq!(resolve_url(&config_with_url(None), None), DEFAULT_URL); + } + + #[test] + fn credentials_path_sits_under_the_config_dir() { + let config = config_with_dir(std::path::Path::new("/tmp/cfg-root")); + assert_eq!( + credentials_path(&config).unwrap(), + std::path::PathBuf::from("/tmp/cfg-root/credentials.json") + ); + } + #[test] fn host_of_strips_path() { assert_eq!(host_of("https://pathbase.dev"), "https://pathbase.dev"); @@ -1093,8 +1138,8 @@ pub(crate) mod tests { // // The preflight is the gate that decides authed-vs-anon BEFORE the // share picker runs, so a credential rejection shouldn't make the - // user pick a session and *then* fail. These tests use - // TOOLPATH_CONFIG_DIR + a tempdir-credentials file to drive the + // user pick a session and *then* fail. These tests inject a `Config` + // carrying a tempdir config dir + a credentials file to drive the // logged-in path through the same MockServer used elsewhere. fn write_credentials(dir: &std::path::Path, url: &str) { @@ -1119,13 +1164,18 @@ pub(crate) mod tests { ) } - /// Cleared TOOLPATH_CONFIG_DIR + no `--anon` + no auth-requiring flags + /// Empty config dir + no `--anon` + no auth-requiring flags /// → preflight returns Anon with the "not logged in" notice. #[test] fn preflight_anon_when_logged_out_and_no_auth_flags() { let cfg = tempfile::tempdir().unwrap(); - let _g = EnvGuard::set("TOOLPATH_CONFIG_DIR", cfg.path().to_str().unwrap()); - let mode = preflight_auth("https://pathbase.dev", false, false).unwrap(); + let mode = preflight_auth( + &config_with_dir(cfg.path()), + "https://pathbase.dev", + false, + false, + ) + .unwrap(); assert!(matches!(mode, AuthMode::Anon)); } @@ -1137,10 +1187,9 @@ pub(crate) mod tests { Box::leak(me_response_body("alice").into_boxed_str()), ); let cfg = tempfile::tempdir().unwrap(); - let _g = EnvGuard::set("TOOLPATH_CONFIG_DIR", cfg.path().to_str().unwrap()); write_credentials(cfg.path(), &server.base()); let base = server.base(); - let mode = preflight_auth(&base, false, false).unwrap(); + let mode = preflight_auth(&config_with_dir(cfg.path()), &base, false, false).unwrap(); match mode { AuthMode::Authed { username, .. } => assert_eq!(username, "alice"), AuthMode::Anon => panic!("expected Authed, got Anon"), @@ -1153,10 +1202,9 @@ pub(crate) mod tests { fn preflight_falls_back_to_anon_on_401_without_auth_flags() { let server = MockServer::start("HTTP/1.1 401 Unauthorized", "{}"); let cfg = tempfile::tempdir().unwrap(); - let _g = EnvGuard::set("TOOLPATH_CONFIG_DIR", cfg.path().to_str().unwrap()); write_credentials(cfg.path(), &server.base()); let base = server.base(); - let mode = preflight_auth(&base, false, false).unwrap(); + let mode = preflight_auth(&config_with_dir(cfg.path()), &base, false, false).unwrap(); assert!(matches!(mode, AuthMode::Anon)); } @@ -1166,10 +1214,9 @@ pub(crate) mod tests { fn preflight_propagates_401_when_auth_required() { let server = MockServer::start("HTTP/1.1 401 Unauthorized", "{}"); let cfg = tempfile::tempdir().unwrap(); - let _g = EnvGuard::set("TOOLPATH_CONFIG_DIR", cfg.path().to_str().unwrap()); write_credentials(cfg.path(), &server.base()); let base = server.base(); - let err = preflight_auth(&base, false, true).unwrap_err(); + let err = preflight_auth(&config_with_dir(cfg.path()), &base, false, true).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("--repo"), "expected mention of --repo: {msg}"); } @@ -1180,56 +1227,14 @@ pub(crate) mod tests { // Even with valid credentials in place, --anon returns Anon without // calling api_me (no MockServer needed — would 404). let cfg = tempfile::tempdir().unwrap(); - let _g = EnvGuard::set("TOOLPATH_CONFIG_DIR", cfg.path().to_str().unwrap()); write_credentials(cfg.path(), "https://pathbase.dev"); - let mode = preflight_auth("https://pathbase.dev", true, false).unwrap(); + let mode = preflight_auth( + &config_with_dir(cfg.path()), + "https://pathbase.dev", + true, + false, + ) + .unwrap(); assert!(matches!(mode, AuthMode::Anon)); } - - /// Test-helper guard for `std::env::set_var`. Process env is shared - /// across all `cargo test` threads, so concurrent tests that mutate or - /// read *any* env var would race — `std::env::set_var`/`var_os` are not - /// thread-safe. `EnvGuard` serializes against every other env-touching - /// test in the crate via the *shared* [`crate::config::TEST_ENV_LOCK`] - /// (held for the guard's lifetime), not a private lock: these tests set - /// `TOOLPATH_CONFIG_DIR`, which `cmd_resume`/`cmd_cache`/`cmd_export` - /// also read/write under that same lock. A separate mutex here would - /// only exclude EnvGuard users from each other while still racing those - /// modules. Drop restores the prior value. - struct EnvGuard { - key: String, - prior: Option, - _lock: std::sync::MutexGuard<'static, ()>, - } - impl EnvGuard { - fn set(key: &str, val: &str) -> Self { - // PoisonError on a previously-panicked test still gives us a - // valid lock — recover the inner guard and proceed. - let lock = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let prior = std::env::var_os(key); - // SAFETY: TEST_ENV_LOCK serializes this against every other - // env-touching test in the crate, so no concurrent - // set_var/var_os on the shared environ can occur. - unsafe { - std::env::set_var(key, val); - } - Self { - key: key.to_string(), - prior, - _lock: lock, - } - } - } - impl Drop for EnvGuard { - fn drop(&mut self) { - unsafe { - match &self.prior { - Some(v) => std::env::set_var(&self.key, v), - None => std::env::remove_var(&self.key), - } - } - } - } } diff --git a/crates/path-cli/src/cmd_project.rs b/crates/path-cli/src/cmd_project.rs index f96db7a8..e8a2900b 100644 --- a/crates/path-cli/src/cmd_project.rs +++ b/crates/path-cli/src/cmd_project.rs @@ -10,6 +10,8 @@ use anyhow::Result; use clap::Subcommand; use std::path::PathBuf; +use crate::config::Config; + #[derive(Subcommand, Debug)] pub enum ProjectTarget { /// Project a toolpath document into Claude JSONL format @@ -24,15 +26,16 @@ pub enum ProjectTarget { }, } -pub fn run(target: ProjectTarget) -> Result<()> { +pub fn run(target: ProjectTarget, config: &Config) -> Result<()> { match target { - ProjectTarget::Claude { input, output } => { - crate::cmd_export::run(crate::cmd_export::ExportTarget::Claude { + ProjectTarget::Claude { input, output } => crate::cmd_export::run( + crate::cmd_export::ExportTarget::Claude { input, project: None, output, force: false, - }) - } + }, + config, + ), } } diff --git a/crates/path-cli/src/cmd_query.rs b/crates/path-cli/src/cmd_query.rs index 5fc26c80..95f31a90 100644 --- a/crates/path-cli/src/cmd_query.rs +++ b/crates/path-cli/src/cmd_query.rs @@ -11,6 +11,7 @@ use clap::Parser; use std::io::IsTerminal; use std::path::PathBuf; +use crate::config::Config; use crate::query::Scope; /// Each array element is a Toolpath step (`step`/`change`/`meta` verbatim) @@ -99,10 +100,10 @@ Examples: path query -r '.[].cache_id' | sort -u # raw ids, pipeable to xargs/grep path query -r '.[0].change[].structural.text' # read a turn's text, unescaped"; -pub fn run(args: QueryArgs, pretty: bool) -> Result<()> { +pub fn run(args: QueryArgs, pretty: bool, config: &Config) -> Result<()> { #[cfg(not(target_os = "emscripten"))] if !args.no_sync { - sync_query_scope(&args); + sync_query_scope(&args, config); } let scope = Scope { @@ -118,20 +119,33 @@ pub fn run(args: QueryArgs, pretty: bool) -> Result<()> { // pretty on a TTY or when the global `--pretty` flag is set. let compact = args.compact || (!pretty && !std::io::stdout().is_terminal()); - crate::query::run(&scope, &args.filter, compact, args.raw) + crate::query::run( + config, + &scope, + &args.filter, + compact, + args.raw, + config.toolpath_query_explain.as_deref(), + ) } /// Freshen the slice of the cache this query will read, before reading /// it. Quiet unless something was actually ingested; a sync failure /// degrades to querying the cache as-is. #[cfg(not(target_os = "emscripten"))] -fn sync_query_scope(args: &QueryArgs) { +fn sync_query_scope(args: &QueryArgs, config: &Config) { let types = sync_types_for(args.source.as_deref(), &args.ids, &args.input); if types.is_empty() { return; } - let bundle = crate::harness::HarnessBundle::from_environment(); - match crate::sync::sync_bundle(&bundle, &types, args.project_under.as_deref(), &mut ()) { + let bundle = crate::providers::harness_bundle(config); + match crate::sync::sync_bundle( + config, + &bundle, + &types, + args.project_under.as_deref(), + &mut (), + ) { Ok(outcomes) => { for (t, o) in outcomes { if o.new + o.updated + o.failed > 0 { diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 4b0b6e3d..21eed44d 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -44,6 +44,7 @@ use anyhow::{Context, Result}; use clap::Args; use std::path::PathBuf; +use crate::config::Config; use crate::harness::Harness; #[derive(Args, Debug)] @@ -82,14 +83,14 @@ pub struct ResumeArgs { pub url: Option, } -pub fn run(args: ResumeArgs) -> Result<()> { - run_with_strategy(args, &RealExec) +pub fn run(args: ResumeArgs, config: &Config) -> Result<()> { + run_with_strategy(args, &RealExec, config) } /// Internal entry point that the integration tests call with a /// `RecordingExec` strategy. Production callers use [`run`]. -pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<()> { - let (graph, source_harness) = resolve_input(&args)?; +pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy, config: &Config) -> Result<()> { + let (graph, source_harness) = resolve_input(&args, config)?; let path = ensure_path_with_agent(&graph)?; let cwd = match args.cwd.as_ref() { @@ -110,7 +111,7 @@ pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<() } ); - let session_id = project_into_harness(path, target, &cwd)?; + let session_id = project_into_harness(path, target, &cwd, config)?; let (binary, argv) = invocation_for(target, &session_id, &cwd); exec_harness(&binary, &argv, &cwd, exec) } @@ -196,7 +197,10 @@ pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> { /// Resolve the user-supplied `` argument into a parsed `Graph` /// plus the source harness inferred from its single inline path (if /// any). See spec § "Input resolution" for the order. -pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option)> { +pub(crate) fn resolve_input( + args: &ResumeArgs, + config: &Config, +) -> Result<(Graph, Option)> { let raw = args.input.as_str(); enum Shape<'a> { @@ -227,7 +231,7 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option let cache_id = crate::cache::pathbase_cache_id(&ref_.owner, &ref_.repo, &ref_.id); if !args.force && !args.no_cache - && let Ok(cache_path) = crate::cache::cache_path(&cache_id) + && let Ok(cache_path) = crate::cache::cache_path(config, &cache_id) && cache_path.exists() { let json = std::fs::read_to_string(&cache_path) @@ -236,12 +240,12 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option Graph::from_json(&json) .map_err(|e| anyhow::anyhow!("cached toolpath document is invalid: {}", e))? } else { - let derived = crate::derive::pathbase_fetch_to_doc(u, args.url.as_deref())?; + let derived = crate::derive::pathbase_fetch_to_doc(config, u, args.url.as_deref())?; if !args.no_cache { // force=true here: we either short-circuited above // (cache miss) or the user explicitly passed --force, // and either way we want the new bytes to land. - crate::cache::write_cached(&derived.cache_id, &derived.doc, true)?; + crate::cache::write_cached(config, &derived.cache_id, &derived.doc, true)?; eprintln!("Resolved {} → {}", raw, derived.cache_id); } derived.doc @@ -253,7 +257,7 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))? } Shape::CacheId(id) => { - let file = crate::cache::cache_ref(id).map_err(|e| { + let file = crate::cache::cache_ref(config, id).map_err(|e| { anyhow::anyhow!( "couldn't resolve `{}` as a URL, file path, or cache id: {}", raw, @@ -458,9 +462,10 @@ pub(crate) fn project_into_harness( path: &TPath, harness: Harness, cwd: &std::path::Path, + config: &Config, ) -> Result { match harness { - Harness::Claude => match crate::cmd_export::project_claude(path, cwd)? { + Harness::Claude => match crate::cmd_export::project_claude(path, cwd, config)? { crate::cmd_export::ClaudeProjection::Written { session_id } => Ok(session_id), crate::cmd_export::ClaudeProjection::AlreadyLocal { session_id } => { eprintln!( @@ -469,12 +474,12 @@ pub(crate) fn project_into_harness( Ok(session_id) } }, - Harness::Gemini => crate::cmd_export::project_gemini(path, cwd), - Harness::Codex => crate::cmd_export::project_codex(path, cwd), - Harness::Copilot => crate::cmd_export::project_copilot(path, cwd), - Harness::Opencode => crate::cmd_export::project_opencode(path, cwd), - Harness::Cursor => crate::cmd_export::project_cursor(path, cwd), - Harness::Pi => crate::cmd_export::project_pi(path, cwd), + Harness::Gemini => crate::cmd_export::project_gemini(path, cwd, config), + Harness::Codex => crate::cmd_export::project_codex(path, cwd, config), + Harness::Copilot => crate::cmd_export::project_copilot(path, cwd, config), + Harness::Opencode => crate::cmd_export::project_opencode(path, cwd, config), + Harness::Cursor => crate::cmd_export::project_cursor(path, cwd, config), + Harness::Pi => crate::cmd_export::project_pi(path, cwd, config), } } @@ -588,12 +593,23 @@ fn looks_like_pathbase_shorthand(s: &str) -> bool { mod tests { use super::*; + /// A `Config` rooted at `home`, so the projectors write inside the + /// test's tempdir. + fn config_with_home(home: &std::path::Path) -> Config { + Config { + home: Some(home.to_path_buf()), + ..Config::default() + } + } + #[test] fn run_with_strategy_records_invocation_for_file_input_with_explicit_harness() { + // The `$PATH` guard mutates process-global state; the lock + // serializes it against the other env-mutating tests. let _env = crate::config::TEST_ENV_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); - let _home = scoped_home_for_resume(); + let home = tempfile::tempdir().unwrap(); let _path_guard = ScopedPathForResume::with_binaries(&["claude"]); let cwd = tempfile::tempdir().unwrap(); let doc_file = cwd.path().join("doc.json"); @@ -618,7 +634,7 @@ mod tests { }; let recorder = RecordingExec::default(); - run_with_strategy(args, &recorder).unwrap(); + run_with_strategy(args, &recorder, &config_with_home(home.path())).unwrap(); let cap = recorder.captured(); assert_eq!(cap.binary, "claude"); @@ -752,16 +768,13 @@ mod tests { force: false, url: None, }; - let (g, harness) = resolve_input(&args).unwrap(); + let (g, harness) = resolve_input(&args, &Config::default()).unwrap(); let _path = ensure_path_with_agent(&g).unwrap(); assert_eq!(harness, Some(Harness::Claude)); } #[test] fn resolve_input_url_dispatches_to_pathbase_fetch() { - let _env = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); use crate::cmd_pathbase::tests::MockServer; let body = { let mut path = make_path_with_actor("agent:codex"); @@ -786,7 +799,12 @@ mod tests { force: false, url: None, }; - let (g, harness) = resolve_input(&args).unwrap(); + let cfg_dir = tempfile::tempdir().unwrap(); + let config = Config { + toolpath_config_dir: Some(cfg_dir.path().to_path_buf()), + ..Config::default() + }; + let (g, harness) = resolve_input(&args, &config).unwrap(); let _ = ensure_path_with_agent(&g).unwrap(); assert_eq!(harness, Some(Harness::Codex)); } @@ -799,17 +817,9 @@ mod tests { // input at a 500-erroring mock server (so any network round-trip // would surface as an error), and confirm resolve_input still // returns the cached graph. - let _env = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - - // Pin TOOLPATH_CONFIG_DIR to a tempdir so we don't pollute the - // user's real cache. + // The `Config` pins the config dir to a tempdir, so the test + // never touches the user's real cache. let cfg_dir = tempfile::tempdir().unwrap(); - let prev_cfg = std::env::var_os("TOOLPATH_CONFIG_DIR"); - unsafe { - std::env::set_var("TOOLPATH_CONFIG_DIR", cfg_dir.path()); - } // Seed the cache with a codex-source graph. Cache id keys on the // graph UUID since Pathbase 1.1+ addresses graphs by UUID. @@ -847,26 +857,18 @@ mod tests { force: false, url: None, }; - let result = resolve_input(&args); - - // Restore env before asserting so a panic doesn't poison sibling tests. - unsafe { - match prev_cfg { - Some(v) => std::env::set_var("TOOLPATH_CONFIG_DIR", v), - None => std::env::remove_var("TOOLPATH_CONFIG_DIR"), - } - } - - let (g, harness) = result.expect("resolve_input should reuse cache without refetching"); + let config = Config { + toolpath_config_dir: Some(cfg_dir.path().to_path_buf()), + ..Config::default() + }; + let (g, harness) = resolve_input(&args, &config) + .expect("resolve_input should reuse cache without refetching"); let _ = ensure_path_with_agent(&g).unwrap(); assert_eq!(harness, Some(Harness::Codex)); } #[test] fn resolve_input_unresolvable_errors_clearly() { - let _env = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); let args = ResumeArgs { input: "definitely/not/a/real/cache/id".to_string(), cwd: None, @@ -875,7 +877,7 @@ mod tests { force: false, url: None, }; - let err = resolve_input(&args).unwrap_err(); + let err = resolve_input(&args, &Config::default()).unwrap_err(); let s = err.to_string(); assert!(s.contains("couldn't resolve"), "actual: {s}"); } @@ -980,14 +982,12 @@ mod tests { #[test] fn project_into_harness_claude_round_trip() { - let _env = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - let _home = scoped_home_for_resume(); + let home = tempfile::tempdir().unwrap(); let cwd = tempfile::tempdir().unwrap(); let path = make_convo_path_for_resume("claude-code://resume-test-session"); - let session_id = project_into_harness(&path, Harness::Claude, cwd.path()).unwrap(); + let config = config_with_home(home.path()); + let session_id = project_into_harness(&path, Harness::Claude, cwd.path(), &config).unwrap(); assert!(!session_id.is_empty()); } @@ -1034,10 +1034,6 @@ mod tests { } } - fn scoped_home_for_resume() -> ScopedHomeForResume { - ScopedHomeForResume::new() - } - struct ScopedPathForResume { _bin_dir: tempfile::TempDir, prev: Option, @@ -1075,33 +1071,6 @@ mod tests { } } - struct ScopedHomeForResume { - _td: tempfile::TempDir, - prev: Option, - } - - impl ScopedHomeForResume { - fn new() -> Self { - let td = tempfile::tempdir().unwrap(); - let prev = std::env::var_os("HOME"); - unsafe { - std::env::set_var("HOME", td.path()); - } - Self { _td: td, prev } - } - } - - impl Drop for ScopedHomeForResume { - fn drop(&mut self) { - unsafe { - match &self.prev { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - } - } - } - #[test] fn exec_strategy_recording_captures_invocation() { let recorder = RecordingExec::default(); diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 63cb7ae8..7b85c6c3 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -9,10 +9,12 @@ use clap::Args; use std::path::PathBuf; use crate::artifact::ArtifactType; +use crate::config::Config; use crate::harness::{ Harness, HarnessBundle, is_not_found_claude, is_not_found_codex, is_not_found_copilot, is_not_found_cursor, is_not_found_gemini, is_not_found_opencode, is_not_found_pi, }; +use crate::providers; use crate::remote::RepoSpec; #[derive(Args, Debug)] @@ -481,7 +483,7 @@ fn collect_cursor( } } -pub fn run(args: ShareArgs) -> Result<()> { +pub fn run(args: ShareArgs, config: &Config) -> Result<()> { let harness = args.harness.map(|h| h.artifact_type()); if args.session.is_some() && harness.is_none() { @@ -498,23 +500,24 @@ pub fn run(args: ShareArgs) -> Result<()> { name: args.name.clone(), public: args.public, }; - let base_url = crate::cmd_export::resolve_upload_base_url(&upload_args); + let base_url = crate::cmd_export::resolve_upload_base_url(config, &upload_args); let needs_auth = upload_args.repo.is_some() || upload_args.public || upload_args.name.is_some(); if let (Some(h), Some(session)) = (harness, &args.session) { // Explicit-args: validate creds before derive so a credential // failure doesn't waste the derive/cache work. - let auth = crate::cmd_pathbase::preflight_auth(&base_url, upload_args.anon, needs_auth)?; - return share_explicit(h, session.as_str(), &args, auth, base_url); + let auth = + crate::cmd_pathbase::preflight_auth(config, &base_url, upload_args.anon, needs_auth)?; + return share_explicit(h, session.as_str(), &args, auth, base_url, config); } let cwd = std::env::current_dir()?; - let bundle = HarnessBundle::from_environment(); + let bundle = providers::harness_bundle(config); let project_filter = args.project.as_deref(); let rows = gather_artifacts(&bundle, &cwd, harness, project_filter); if rows.is_empty() { - return bail_no_sessions(&bundle, project_filter); + return bail_no_sessions(&bundle, project_filter, config); } if !crate::fuzzy::available() { @@ -532,7 +535,8 @@ pub fn run(args: ShareArgs) -> Result<()> { // making the user pick a session. If preflight returns Anon (either // explicit --anon, no creds + no auth flags, or auth probe failed // and fell back), the picker still fires with that knowledge baked in. - let auth = crate::cmd_pathbase::preflight_auth(&base_url, upload_args.anon, needs_auth)?; + let auth = + crate::cmd_pathbase::preflight_auth(config, &base_url, upload_args.anon, needs_auth)?; let lines: Vec = rows.iter().map(format_picker_row).collect(); let header = format!("share an agent session (Enter = upload to {base_url})"); @@ -584,12 +588,13 @@ pub fn run(args: ShareArgs) -> Result<()> { // is opaque and doesn't help the user verify they picked the right // thing. `{:?}` adds the surrounding quotes per the spec. eprintln!("Picked {} session {:?}", h.name(), title); - share_explicit(h, &session, &explicit, auth, base_url) + share_explicit(h, &session, &explicit, auth, base_url, config) } fn bail_no_sessions( bundle: &HarnessBundle, project_filter: Option<&std::path::Path>, + config: &Config, ) -> Result<()> { if let Some(p) = project_filter { anyhow::bail!( @@ -601,35 +606,32 @@ fn bail_no_sessions( let mut summary = String::from("No agent sessions found.\n"); // Pad harness names so the path column lines up: "opencode:" is the // longest at 9 chars (8 + colon). - let home = crate::config::home_dir(); + let home = config.home_dir().map(std::path::PathBuf::as_path); summary.push_str(&format_status_line( "claude", - &harness_status_claude(bundle, home.as_deref()), + &harness_status_claude(bundle, home), )); summary.push_str(&format_status_line( "gemini", - &harness_status_gemini(bundle, home.as_deref()), + &harness_status_gemini(bundle, home), )); summary.push_str(&format_status_line( "codex", - &harness_status_codex(bundle, home.as_deref()), + &harness_status_codex(bundle, home), )); summary.push_str(&format_status_line( "copilot", - &harness_status_copilot(bundle, home.as_deref()), + &harness_status_copilot(bundle, home), )); summary.push_str(&format_status_line( "opencode", - &harness_status_opencode(bundle, home.as_deref()), + &harness_status_opencode(bundle, home), )); summary.push_str(&format_status_line( "cursor", - &harness_status_cursor(bundle, home.as_deref()), - )); - summary.push_str(&format_status_line( - "pi", - &harness_status_pi(bundle, home.as_deref()), + &harness_status_cursor(bundle, home), )); + summary.push_str(&format_status_line("pi", &harness_status_pi(bundle, home))); eprint!("{summary}"); anyhow::bail!("no shareable sessions"); } @@ -686,12 +688,10 @@ fn harness_status_gemini(bundle: &HarnessBundle, home: Option<&std::path::Path>) let Some(mgr) = &bundle.gemini else { return HarnessStatus::unresolved(); }; - match mgr.resolver().tmp_dir() { - Ok(p) => HarnessStatus { - path: crate::config::home_relative(&p, home), - exists: p.exists(), - }, - Err(_) => HarnessStatus::unresolved(), + let p = mgr.resolver().tmp_dir(); + HarnessStatus { + path: crate::config::home_relative(&p, home), + exists: p.exists(), } } @@ -767,6 +767,7 @@ fn share_explicit( args: &ShareArgs, auth: crate::cmd_pathbase::AuthMode, base_url: String, + config: &Config, ) -> Result<()> { let project = match (harness.path_keyed(), args.project.as_ref()) { (true, Some(p)) => Some(p.to_string_lossy().into_owned()), @@ -782,13 +783,14 @@ fn share_explicit( // — a derive would reproduce it byte-for-byte anyway. if !args.no_cache && let Some(cache_id) = crate::sync::fresh_cache_id( - &HarnessBundle::from_environment(), + config, + &providers::harness_bundle(config), harness, project.as_deref(), session, ) { - let doc_path = crate::cache::cache_path(&cache_id)?; + let doc_path = crate::cache::cache_path(config, &cache_id)?; let body = std::fs::read_to_string(&doc_path) .with_context(|| format!("Failed to read {}", doc_path.display()))?; eprintln!( @@ -800,7 +802,7 @@ fn share_explicit( .ok() .and_then(|doc| doc_session_dir(&doc)) }); - let dest = resolve_destination(args, &auth, base_url, session_dir)?; + let dest = resolve_destination(args, &auth, base_url, session_dir, config)?; let summary = format!("{} session {}", harness.name(), cache_id); let upload = crate::cmd_export::PathbaseUploadArgs { url: args.url.clone(), @@ -812,7 +814,7 @@ fn share_explicit( return crate::cmd_export::run_pathbase_inner(auth, dest.base_url, upload, &body, &summary); } - let derived = derive_session(harness, project.as_deref(), session)?; + let derived = derive_session(harness, project.as_deref(), session, config)?; let summary = format!("{} session {}", harness.name(), derived.cache_id); if !args.no_cache { @@ -823,9 +825,9 @@ fn share_explicit( // the upload uses the fresh body, not the cache. Always // overwrite so cache and upload agree (use `--no-cache` to skip // the cache write entirely). - let path = crate::cache::write_cached(&derived.cache_id, &derived.doc, true)?; + let path = crate::cache::write_cached(config, &derived.cache_id, &derived.doc, true)?; if let Some(stub) = &derived.provenance - && let Err(e) = crate::sync::record_artifact(stub, &derived.cache_id) + && let Err(e) = crate::sync::record_artifact(config, stub, &derived.cache_id) { eprintln!("warning: sync manifest not updated: {e}"); } @@ -841,7 +843,7 @@ fn share_explicit( .as_deref() .map(PathBuf::from) .or_else(|| doc_session_dir(&derived.doc)); - let dest = resolve_destination(args, &auth, base_url, session_dir)?; + let dest = resolve_destination(args, &auth, base_url, session_dir, config)?; let body = derived.doc.to_json()?; let upload = crate::cmd_export::PathbaseUploadArgs { url: args.url.clone(), @@ -889,6 +891,7 @@ fn resolve_destination( auth: &crate::cmd_pathbase::AuthMode, base_url: String, session_dir: Option, + config: &Config, ) -> Result { if args.repo.is_some() || args.anon { return Ok(ShareDestination { @@ -902,7 +905,7 @@ fn resolve_destination( base_url, }); }; - let Some(found) = crate::share_config::resolve_remote(&dir)? else { + let Some(found) = crate::share_config::resolve_remote(config, &dir)? else { return Ok(ShareDestination { repo: None, base_url, @@ -993,21 +996,22 @@ fn derive_session( harness: ArtifactType, project: Option<&str>, session: &str, + config: &Config, ) -> Result { match harness { ArtifactType::Claude => { - crate::derive::derive_claude_session(project.expect("path_keyed"), session) + crate::derive::derive_claude_session(config, project.expect("path_keyed"), session) } ArtifactType::Gemini => { - crate::derive::derive_gemini_session(project.expect("path_keyed"), session) + crate::derive::derive_gemini_session(config, project.expect("path_keyed"), session) } - ArtifactType::Copilot => crate::derive::derive_copilot_session(session), + ArtifactType::Copilot => crate::derive::derive_copilot_session(config, session), ArtifactType::Pi => { - crate::derive::derive_pi_session(project.expect("path_keyed"), session, None) + crate::derive::derive_pi_session(config, project.expect("path_keyed"), session, None) } - ArtifactType::Codex => crate::derive::derive_codex_session(session), - ArtifactType::Opencode => crate::derive::derive_opencode_session(session, false), - ArtifactType::Cursor => crate::derive::derive_cursor_session(session), + ArtifactType::Codex => crate::derive::derive_codex_session(config, session), + ArtifactType::Opencode => crate::derive::derive_opencode_session(config, session, false), + ArtifactType::Cursor => crate::derive::derive_cursor_session(config, session), ArtifactType::Git => { anyhow::bail!("share only handles agent sessions; git artifacts go through `p import`") } @@ -1450,6 +1454,7 @@ mod tests { &authed(), DEFAULT_BASE.to_string(), Some(PathBuf::from("/anywhere")), + &Config::default(), ) .unwrap(); let repo = dest.repo.unwrap(); @@ -1466,6 +1471,7 @@ mod tests { &crate::cmd_pathbase::AuthMode::Anon, DEFAULT_BASE.to_string(), Some(PathBuf::from("/anywhere")), + &Config::default(), ) .unwrap(); assert!(dest.repo.is_none()); @@ -1479,20 +1485,18 @@ mod tests { &crate::cmd_pathbase::AuthMode::Anon, DEFAULT_BASE.to_string(), None, + &Config::default(), ) .unwrap(); assert!(dest.repo.is_none()); } - /// One env-scoped run covering the remote forms: bare `owner/name` - /// (auth required when logged out, resolves when authed, base URL + /// One run covering the remote forms: bare `owner/name` (auth + /// required when logged out, resolves when authed, base URL /// untouched) and full URL (base URL replaced, `--url` flag wins, /// logged-out hint names the remote's server). #[test] fn destination_applies_configured_remote() { - let _g = crate::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); let cfg = TempDir::new().unwrap(); let bare = cfg.path().join("bare-proj"); let url = cfg.path().join("url-proj"); @@ -1508,32 +1512,37 @@ mod tests { ), ) .unwrap(); - unsafe { - std::env::set_var(crate::config::CONFIG_DIR_ENV, cfg.path()); - } + let config = Config { + toolpath_config_dir: Some(cfg.path().to_path_buf()), + ..Config::default() + }; let bare_unauthed = resolve_destination( &share_args(), &crate::cmd_pathbase::AuthMode::Anon, DEFAULT_BASE.to_string(), Some(bare.clone()), + &config, ); let bare_authed = resolve_destination( &share_args(), &authed(), DEFAULT_BASE.to_string(), Some(bare), + &config, ); let url_unauthed = resolve_destination( &share_args(), &crate::cmd_pathbase::AuthMode::Anon, DEFAULT_BASE.to_string(), Some(url.clone()), + &config, ); let url_authed = resolve_destination( &share_args(), &authed(), DEFAULT_BASE.to_string(), Some(url.clone()), + &config, ); let mut flag_args = share_args(); flag_args.url = Some("https://flag.example".to_string()); @@ -1542,10 +1551,8 @@ mod tests { &authed(), "https://flag.example".to_string(), Some(url), + &config, ); - unsafe { - std::env::remove_var(crate::config::CONFIG_DIR_ENV); - } let err = bare_unauthed.unwrap_err().to_string(); assert!(err.contains("team/sessions"), "got: {err}"); diff --git a/crates/path-cli/src/cmd_show.rs b/crates/path-cli/src/cmd_show.rs index 2ecf12c0..e608e1cc 100644 --- a/crates/path-cli/src/cmd_show.rs +++ b/crates/path-cli/src/cmd_show.rs @@ -121,7 +121,9 @@ fn derive_one(source: ShowSource, config: &Config) -> Result Ok(toolpath_claude::derive::derive_path(&convo, &cfg)) } ShowSource::Gemini { project, session } => { - let manager = providers::gemini_convo(config); + let manager = toolpath_gemini::GeminiConvo::with_resolver( + providers::require_gemini_resolver(config)?, + ); let convo = manager .read_conversation(&project, &session) .map_err(|e| anyhow::anyhow!("{}", e))?; diff --git a/crates/path-cli/src/config.rs b/crates/path-cli/src/config.rs index 9a76e2cb..9f7cfc11 100644 --- a/crates/path-cli/src/config.rs +++ b/crates/path-cli/src/config.rs @@ -43,8 +43,14 @@ pub(crate) const DOCUMENTS_DIR_NAME: &str = "documents"; /// Environment-derived configuration. [`Config::load`] reads the /// environment once, at the composition root. Code below the root /// receives values as parameters and does not read the environment. +/// +/// Public because `cmd_resume::run_with_strategy` takes a `&Config` +/// across the crate boundary. It is a test seam, not API: the item is +/// `#[doc(hidden)]` and the fields stay crate-private, so +/// [`Config::load`] is the only constructor outside the crate. +#[doc(hidden)] #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub(crate) struct Config { +pub struct Config { /// `$APPDATA`: Windows harness data root. pub(crate) appdata: Option, /// `$COPILOT_HOME`: Copilot CLI session root override. @@ -109,7 +115,8 @@ impl Config { } /// Read the process environment and extract an immutable `Config`. - pub(crate) fn load() -> Result { + #[doc(hidden)] + pub fn load() -> Result { let vars: Vec<&str> = Self::env_var_names().collect(); let env = Env::raw().only(&vars).map(|key| { Self::ENV_MAP @@ -140,29 +147,11 @@ impl Config { /// The home directory the provider resolvers should use: /// `$HOME`, falling back to `$USERPROFILE` (Windows). Matches the /// resolvers' own internal fallback. - #[cfg_attr(all(target_os = "emscripten", not(test)), expect(dead_code))] pub(crate) fn home_dir(&self) -> Option<&PathBuf> { self.home.as_ref().or(self.userprofile.as_ref()) } } -/// The configured toolpath config directory (default `~/.toolpath`, -/// overridable via `$TOOLPATH_CONFIG_DIR`). -/// -/// Transitional: loads a [`Config`] per call. New code takes `&Config` -/// as a parameter and calls [`Config::config_dir`]. -pub(crate) fn config_dir() -> Result { - Config::load()?.config_dir() -} - -/// Cross-platform `$HOME` lookup matching the providers' internal helpers. -/// Returns `None` only when neither `$HOME` nor `$USERPROFILE` is set. -pub(crate) fn home_dir() -> Option { - std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(PathBuf::from) -} - /// Display `path` as `~/relative/part` when it's under `home`, otherwise /// return its absolute lossy form. Pure helper — does no filesystem I/O. pub(crate) fn home_relative(path: &std::path::Path, home: Option<&std::path::Path>) -> String { @@ -179,10 +168,10 @@ pub(crate) fn home_relative(path: &std::path::Path, home: Option<&std::path::Pat path.display().to_string() } -/// Shared lock for tests that manipulate `$TOOLPATH_CONFIG_DIR`. Every -/// test module that calls `set_var` / `remove_var` on this env var should -/// grab this lock first, otherwise parallel tests race and clobber each -/// other's directories. +/// Shared lock for tests that mutate the process environment. Every test +/// that calls `set_var` / `remove_var`, or that runs a `figment::Jail`, +/// grabs this lock first, otherwise parallel tests clobber each other's +/// values. #[cfg(test)] pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); @@ -191,8 +180,8 @@ mod tests { use super::*; /// `figment::Jail` restores the variables it sets, but it serializes - /// only against other Jail tests. Hold `TEST_ENV_LOCK` too: other - /// test modules mutate `$HOME` / `$TOOLPATH_CONFIG_DIR` under that + /// only against other Jail tests. Hold `TEST_ENV_LOCK` too: the + /// `$PATH` guard in `cmd_resume` mutates the environment under that /// lock. // result_large_err: the Jail closure returns figment's own // 208-byte error type. @@ -326,21 +315,6 @@ mod tests { assert!(err.to_string().contains("$HOME")); } - /// The transitional free function honors the env override - /// end-to-end. - #[test] - fn config_dir_honors_override() { - let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - unsafe { - std::env::set_var(CONFIG_DIR_ENV, "/tmp/test-toolpath"); - } - let dir = config_dir().unwrap(); - unsafe { - std::env::remove_var(CONFIG_DIR_ENV); - } - assert_eq!(dir, PathBuf::from("/tmp/test-toolpath")); - } - #[test] fn home_relative_strips_home_prefix() { let home = std::path::Path::new("/Users/alex"); diff --git a/crates/path-cli/src/derive.rs b/crates/path-cli/src/derive.rs index d9b09e89..5fe259ef 100644 --- a/crates/path-cli/src/derive.rs +++ b/crates/path-cli/src/derive.rs @@ -10,6 +10,8 @@ use toolpath::v1::Graph; use crate::artifact::{ArtifactRef, ArtifactType, claude_chain_stamp, stat_stamp}; use crate::cache::make_id; +use crate::config::Config; +use crate::providers; pub(crate) struct DerivedDoc { pub(crate) cache_id: String, @@ -30,8 +32,16 @@ pub(crate) fn doc_inner_id(doc: &Graph) -> String { /// Derive a single Claude conversation given an explicit project + session. /// Used by `cmd_share` after its picker has resolved the pair; mirrors the /// `(Some(p), Some(s), _)` arm in [`derive_claude_with_manager`]. -pub(crate) fn derive_claude_session(project: &str, session: &str) -> Result { - derive_claude_session_with(&toolpath_claude::ClaudeConvo::new(), project, session) +pub(crate) fn derive_claude_session( + config: &Config, + project: &str, + session: &str, +) -> Result { + derive_claude_session_with( + &toolpath_claude::ClaudeConvo::with_resolver(providers::claude_resolver(config)), + project, + session, + ) } /// [`derive_claude_session`] against a caller-supplied manager, so sync @@ -89,8 +99,16 @@ pub(crate) fn derive_claude_session_with( } /// Derive a single Gemini conversation given an explicit project + session. -pub(crate) fn derive_gemini_session(project: &str, session: &str) -> Result { - derive_gemini_session_with(&toolpath_gemini::GeminiConvo::new(), project, session) +pub(crate) fn derive_gemini_session( + config: &Config, + project: &str, + session: &str, +) -> Result { + derive_gemini_session_with( + &toolpath_gemini::GeminiConvo::with_resolver(providers::require_gemini_resolver(config)?), + project, + session, + ) } /// [`derive_gemini_session`] against a caller-supplied manager. @@ -139,8 +157,11 @@ pub(crate) fn derive_gemini_session_with( } /// Derive a single Codex session given an explicit session id. -pub(crate) fn derive_codex_session(session: &str) -> Result { - derive_codex_session_with(&toolpath_codex::CodexConvo::new(), session) +pub(crate) fn derive_codex_session(config: &Config, session: &str) -> Result { + derive_codex_session_with( + &toolpath_codex::CodexConvo::with_resolver(providers::codex_resolver(config)), + session, + ) } /// [`derive_codex_session`] against a caller-supplied manager. @@ -176,8 +197,11 @@ pub(crate) fn derive_codex_session_with( } /// Derive a single Copilot session given an explicit session id. -pub(crate) fn derive_copilot_session(session: &str) -> Result { - derive_copilot_session_with(&toolpath_copilot::CopilotConvo::new(), session) +pub(crate) fn derive_copilot_session(config: &Config, session: &str) -> Result { + derive_copilot_session_with( + &toolpath_copilot::CopilotConvo::with_resolver(providers::copilot_resolver(config)), + session, + ) } /// [`derive_copilot_session`] against a caller-supplied manager. @@ -212,11 +236,12 @@ pub(crate) fn derive_copilot_session_with( /// Derive a single opencode session given an explicit session id. #[cfg(not(target_os = "emscripten"))] pub(crate) fn derive_opencode_session( + config: &Config, session: &str, no_snapshot_diffs: bool, ) -> Result { derive_opencode_session_with( - &toolpath_opencode::OpencodeConvo::new(), + &toolpath_opencode::OpencodeConvo::with_resolver(providers::opencode_resolver(config)), session, no_snapshot_diffs, ) @@ -260,8 +285,11 @@ pub(crate) fn derive_opencode_session_with( /// Derive a single cursor composer given an explicit composer id. #[cfg(not(target_os = "emscripten"))] -pub(crate) fn derive_cursor_session(session: &str) -> Result { - derive_cursor_session_with(&toolpath_cursor::CursorConvo::new(), session) +pub(crate) fn derive_cursor_session(config: &Config, session: &str) -> Result { + derive_cursor_session_with( + &toolpath_cursor::CursorConvo::with_resolver(providers::cursor_resolver(config)), + session, + ) } /// [`derive_cursor_session`] against a caller-supplied manager. @@ -301,17 +329,20 @@ pub(crate) fn derive_cursor_session_with( /// Derive a single Pi session given an explicit project + session. pub(crate) fn derive_pi_session( + config: &Config, project: &str, session: &str, base: Option, ) -> Result { - let manager = if let Some(path) = base { - let resolver = toolpath_pi::PathResolver::new().with_sessions_dir(&path); - toolpath_pi::PiConvo::with_resolver(resolver) - } else { - toolpath_pi::PiConvo::new() - }; - derive_pi_session_with(&manager, project, session) + let mut resolver = providers::pi_resolver(config); + if let Some(path) = base { + resolver = resolver.with_sessions_dir(&path); + } + derive_pi_session_with( + &toolpath_pi::PiConvo::with_resolver(resolver), + project, + session, + ) } /// [`derive_pi_session`] against a caller-supplied manager. @@ -356,14 +387,18 @@ pub(crate) fn derive_pi_session_with( /// URL or bare `owner/repo/` triple) and parse it as a toolpath /// document. Used by `path import pathbase` and `path resume `. #[cfg(not(target_os = "emscripten"))] -pub(crate) fn pathbase_fetch_to_doc(target: &str, url_flag: Option<&str>) -> Result { +pub(crate) fn pathbase_fetch_to_doc( + config: &Config, + target: &str, + url_flag: Option<&str>, +) -> Result { use crate::cmd_pathbase::{credentials_path, graphs_download, load_session, resolve_url}; let (base, ref_) = parse_pathbase_ref(target, url_flag)?; - let stored = load_session(&credentials_path()?)?; + let stored = load_session(&credentials_path(config)?)?; let base_url = base .or_else(|| stored.as_ref().map(|s| s.url.clone())) - .unwrap_or_else(|| resolve_url(None)); + .unwrap_or_else(|| resolve_url(config, None)); let token = stored.as_ref().map(|s| s.token.as_str()); @@ -404,7 +439,7 @@ pub(crate) fn parse_pathbase_ref( target: &str, url_flag: Option<&str>, ) -> Result<(Option, PathRef)> { - use crate::cmd_pathbase::resolve_url; + use crate::cmd_pathbase::normalize_url; let scheme = if target.starts_with("https://") { Some("https://") @@ -430,7 +465,7 @@ pub(crate) fn parse_pathbase_ref( })?; Ok((Some(format!("{scheme}{host}")), triple)) } else { - let base = url_flag.map(|u| resolve_url(Some(u.to_string()))); + let base = url_flag.map(normalize_url); let segs: Vec<&str> = target.split('/').filter(|s| !s.is_empty()).collect(); let triple = extract_triple(&segs) .ok_or_else(|| anyhow::anyhow!("expected `//`, got `{target}`"))?; @@ -590,7 +625,12 @@ mod tests { let server = MockServer::start("HTTP/1.1 200 OK", body); let url = format!("{}/u/alex/repos/pathstash/graphs/{UUID}", server.base()); - let derived = pathbase_fetch_to_doc(&url, None).unwrap(); + let cfg_dir = tempfile::tempdir().unwrap(); + let config = Config { + toolpath_config_dir: Some(cfg_dir.path().to_path_buf()), + ..Config::default() + }; + let derived = pathbase_fetch_to_doc(&config, &url, None).unwrap(); assert_eq!(derived.cache_id, format!("pathbase-alex-pathstash-{UUID}")); assert!(derived.doc.into_single_path().is_some()); diff --git a/crates/path-cli/src/harness.rs b/crates/path-cli/src/harness.rs index 490e5455..34bbfafd 100644 --- a/crates/path-cli/src/harness.rs +++ b/crates/path-cli/src/harness.rs @@ -75,8 +75,8 @@ impl ArtifactType { } } -/// Bundle of provider managers used during aggregation. Production code -/// builds this from real `$HOME` via `from_environment`; tests construct +/// Bundle of provider managers used during aggregation. Production +/// code builds this with `providers::harness_bundle`; tests construct /// it directly with provider-specific resolvers. #[derive(Default)] pub(crate) struct HarnessBundle { @@ -89,23 +89,6 @@ pub(crate) struct HarnessBundle { pub(crate) pi: Option, } -impl HarnessBundle { - /// Build the production bundle. Each provider is included - /// unconditionally (its `new()` doesn't fail on a missing home dir); - /// consumers skip the ones whose listing returns empty/NotFound. - pub(crate) fn from_environment() -> Self { - Self { - claude: Some(toolpath_claude::ClaudeConvo::new()), - gemini: Some(toolpath_gemini::GeminiConvo::new()), - codex: Some(toolpath_codex::CodexConvo::new()), - copilot: Some(toolpath_copilot::CopilotConvo::new()), - opencode: Some(toolpath_opencode::OpencodeConvo::new()), - cursor: Some(toolpath_cursor::CursorConvo::new()), - pi: Some(toolpath_pi::PiConvo::new()), - } - } -} - pub(crate) fn is_not_found_claude(err: &toolpath_claude::ConvoError) -> bool { use toolpath_claude::ConvoError; matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) @@ -116,7 +99,6 @@ pub(crate) fn is_not_found_claude(err: &toolpath_claude::ConvoError) -> bool { pub(crate) fn is_not_found_gemini(err: &toolpath_gemini::ConvoError) -> bool { use toolpath_gemini::ConvoError; matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound) - || matches!(err, ConvoError::NoHomeDirectory) || matches!(err, ConvoError::GeminiDirectoryNotFound(_)) } diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index ba4b793f..bab7b390 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -26,7 +26,8 @@ mod cmd_share; mod cmd_show; mod cmd_track; mod cmd_validate; -mod config; +#[doc(hidden)] +pub mod config; mod derive; #[cfg(not(target_os = "emscripten"))] mod fuzzy; @@ -128,7 +129,6 @@ enum Commands { pub fn run() -> Result<()> { let cli = Cli::parse(); - #[cfg_attr(target_os = "emscripten", expect(unused_variables))] let config = config::Config::load()?; #[cfg(not(target_os = "emscripten"))] @@ -142,13 +142,13 @@ pub fn run() -> Result<()> { #[cfg(not(target_os = "emscripten"))] Commands::Show { source, ansi } => cmd_show::run(source, ansi, &config), #[cfg(not(target_os = "emscripten"))] - Commands::Share { args } => cmd_share::run(args), + Commands::Share { args } => cmd_share::run(args, &config), #[cfg(not(target_os = "emscripten"))] - Commands::Resume { args } => cmd_resume::run(args), - Commands::Query { args } => cmd_query::run(args, cli.pretty), + Commands::Resume { args } => cmd_resume::run(args, &config), + Commands::Query { args } => cmd_query::run(args, cli.pretty, &config), Commands::Kind { args } => cmd_kind::run(args), #[cfg(not(target_os = "emscripten"))] - Commands::Auth { op } => cmd_auth::run(op), - Commands::P { command } => cmd_p::run(command, cli.pretty), + Commands::Auth { op } => cmd_auth::run(op, &config), + Commands::P { command } => cmd_p::run(command, cli.pretty, &config), } } diff --git a/crates/path-cli/src/providers.rs b/crates/path-cli/src/providers.rs index 873a5802..6d04533a 100644 --- a/crates/path-cli/src/providers.rs +++ b/crates/path-cli/src/providers.rs @@ -4,15 +4,29 @@ //! with every environment value it consumes taken from [`Config`]. //! Command modules construct managers through these factories. //! +//! A factory returns `Option` when its resolver takes the home +//! directory as a required argument: `None` means [`Config`] carries no +//! 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: their resolvers read `$XDG_DATA_HOME` //! / `$COPILOT_HOME` / `$APPDATA` internally, and those reads win //! against `with_home`. The injected directory wins against both. -#![cfg(not(target_os = "emscripten"))] use crate::config::Config; +#[cfg(not(target_os = "emscripten"))] +use crate::harness::HarnessBundle; use std::path::Path; +use anyhow::{Result, anyhow}; + +fn missing_home(harness: &str) -> anyhow::Error { + anyhow!( + "cannot determine the home directory; set $HOME ($USERPROFILE on Windows) to reach {harness} sessions" + ) +} + pub(crate) fn claude_convo(config: &Config) -> toolpath_claude::ClaudeConvo { let mut resolver = toolpath_claude::PathResolver::new(); if let Some(home) = config.home_dir() { @@ -21,12 +35,13 @@ pub(crate) fn claude_convo(config: &Config) -> toolpath_claude::ClaudeConvo { toolpath_claude::ClaudeConvo::with_resolver(resolver) } -pub(crate) fn gemini_convo(config: &Config) -> toolpath_gemini::GeminiConvo { - let mut resolver = toolpath_gemini::PathResolver::new(); - if let Some(home) = config.home_dir() { - resolver = resolver.with_home(home); - } - toolpath_gemini::GeminiConvo::with_resolver(resolver) +pub(crate) fn gemini_resolver(config: &Config) -> Option { + config.home_dir().map(toolpath_gemini::PathResolver::new) +} + +/// [`gemini_resolver`] for a command that targets Gemini. +pub(crate) fn require_gemini_resolver(config: &Config) -> Result { + gemini_resolver(config).ok_or_else(|| missing_home("Gemini")) } pub(crate) fn codex_convo(config: &Config) -> toolpath_codex::CodexConvo { @@ -48,6 +63,7 @@ pub(crate) fn copilot_convo(config: &Config) -> toolpath_copilot::CopilotConvo { toolpath_copilot::CopilotConvo::with_resolver(resolver) } +#[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() { @@ -59,6 +75,7 @@ pub(crate) fn opencode_convo(config: &Config) -> toolpath_opencode::OpencodeConv toolpath_opencode::OpencodeConvo::with_resolver(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() { @@ -86,7 +103,24 @@ pub(crate) fn pi_convo(config: &Config, base: Option<&Path>) -> toolpath_pi::PiC toolpath_pi::PiConvo::with_resolver(resolver) } -#[cfg(test)] +/// The production [`HarnessBundle`], every provider built from +/// `config`. A provider whose resolver needs a home directory is +/// present only when `config` carries one; consumers skip the ones +/// whose listing returns empty/NotFound. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn harness_bundle(config: &Config) -> HarnessBundle { + HarnessBundle { + claude: Some(claude_convo(config)), + gemini: gemini_resolver(config).map(toolpath_gemini::GeminiConvo::with_resolver), + codex: Some(codex_convo(config)), + copilot: Some(copilot_convo(config)), + opencode: Some(opencode_convo(config)), + cursor: Some(cursor_convo(config)), + pi: Some(pi_convo(config, None)), + } +} + +#[cfg(all(test, not(target_os = "emscripten")))] mod tests { use super::*; use std::path::PathBuf; @@ -113,12 +147,16 @@ mod tests { } #[test] - fn gemini_convo_roots_at_config_home() { - let manager = gemini_convo(&config_with_home()); - assert_eq!( - manager.resolver().gemini_dir().unwrap(), - PathBuf::from("/home/jailed/.gemini") - ); + fn gemini_resolver_roots_at_config_home() { + let resolver = gemini_resolver(&config_with_home()).unwrap(); + assert_eq!(resolver.gemini_dir(), PathBuf::from("/home/jailed/.gemini")); + } + + #[test] + fn gemini_resolver_is_none_without_a_home() { + assert!(gemini_resolver(&Config::default()).is_none()); + let err = require_gemini_resolver(&Config::default()).unwrap_err(); + assert!(err.to_string().contains("home directory")); } #[test] @@ -209,4 +247,17 @@ mod tests { let manager = pi_convo(&config_with_home(), Some(Path::new("/pi/base"))); assert_eq!(manager.resolver().sessions_dir(), PathBuf::from("/pi/base")); } + + #[test] + fn harness_bundle_roots_providers_at_config_home() { + let bundle = harness_bundle(&config_with_home()); + assert_eq!( + bundle.claude.unwrap().resolver().projects_dir().unwrap(), + PathBuf::from("/home/jailed/.claude/projects") + ); + assert_eq!( + bundle.pi.unwrap().resolver().sessions_dir(), + PathBuf::from("/home/jailed/.pi/agent/sessions") + ); + } } diff --git a/crates/path-cli/src/query/mod.rs b/crates/path-cli/src/query/mod.rs index a65602de..0d0601f6 100644 --- a/crates/path-cli/src/query/mod.rs +++ b/crates/path-cli/src/query/mod.rs @@ -42,6 +42,7 @@ pub struct Scope { /// `filter` is jaq source (`.` emits the array verbatim). /// `compact` forces single-line JSON; otherwise output is pretty-printed. /// `raw` prints string results without JSON quoting (like `jq -r`). +/// `explain` is the `$TOOLPATH_QUERY_EXPLAIN` value from [`crate::config::Config`]. /// /// The filter is analyzed once into a [`plan::Plan`]; the executor then streams /// documents one at a time. An element-wise `.[] | g` filter prints as it goes @@ -49,19 +50,25 @@ pub struct Scope { /// only its per-file partials — the filter's own output, not the input cache. /// Anything the planner can't prove decomposable falls back to the whole-array /// path, which is still lean — the step values are held once, not re-serialized. -pub fn run(scope: &Scope, code: &str, compact: bool, raw: bool) -> Result<()> { +pub fn run( + config: &crate::config::Config, + scope: &Scope, + code: &str, + compact: bool, + raw: bool, + explain: Option<&str>, +) -> Result<()> { let plan = plan::analyze(code); // Opt-in observability: `TOOLPATH_QUERY_EXPLAIN=1` reveals the execution // strategy on stderr. Not a behavioral flag — purely diagnostic. - let explain = std::env::var("TOOLPATH_QUERY_EXPLAIN"); - if matches!(explain.as_deref(), Ok(v) if !v.is_empty() && v != "0") { + if matches!(explain, Some(v) if !v.is_empty() && v != "0") { eprintln!("query plan: {}", plan.describe()); } // Buffer stdout: the streaming path prints one value per output, and a // raw `StdoutLock` is line-buffered (a syscall per line). let stdout = std::io::stdout(); let mut out = std::io::BufWriter::new(stdout.lock()); - execute_plan(scope, &plan, code, compact, raw, &mut out)?; + execute_plan(config, scope, &plan, code, compact, raw, &mut out)?; out.flush().context("flush stdout") } @@ -72,6 +79,7 @@ pub fn run(scope: &Scope, code: &str, compact: bool, raw: bool) -> Result<()> { /// parallelizes parsing only. #[cfg(not(target_os = "emscripten"))] fn execute_plan( + config: &crate::config::Config, scope: &Scope, plan: &plan::Plan, code: &str, @@ -81,11 +89,12 @@ fn execute_plan( ) -> Result<()> { match plan { plan::Plan::Slurp => filter::execute(plan, code, compact, raw, out, |emit| { - stream_files(scope, emit) + stream_files(config, scope, emit) }), plan::Plan::PerFileStream => { filter::compile_check(code)?; for_each_file( + config, scope, |steps| filter::render_file(code, steps, compact, raw), |bytes| { @@ -99,6 +108,7 @@ fn execute_plan( let mut partials: Vec = Vec::new(); let mut saw_file = false; for_each_file( + config, scope, |steps| filter::partials_file(code, steps), |bytes| { @@ -116,6 +126,7 @@ fn execute_plan( /// plan runs on the sequential engine. #[cfg(target_os = "emscripten")] fn execute_plan( + config: &crate::config::Config, scope: &Scope, plan: &plan::Plan, code: &str, @@ -124,7 +135,7 @@ fn execute_plan( out: &mut dyn Write, ) -> Result<()> { filter::execute(plan, code, compact, raw, out, |emit| { - stream_files(scope, emit) + stream_files(config, scope, emit) }) } @@ -137,6 +148,7 @@ fn execute_plan( /// sequential scan. #[cfg(not(target_os = "emscripten"))] fn for_each_file( + config: &crate::config::Config, scope: &Scope, per_file: impl Fn(Vec) -> Result + Sync, mut consume: impl FnMut(T) -> Result<()>, @@ -146,7 +158,7 @@ fn for_each_file( let kind_sel = scope.kind.as_deref().map(kinds::parse_kind_selector); let project = scope.project.as_deref().map(canonicalize_or_self); let project_under = scope.project_under.as_deref().map(canonicalize_or_self); - let sources = select_files(scope)?; + let sources = select_files(config, scope)?; let chunk = rayon::current_num_threads().max(1) * 2; for batch in sources.chunks(chunk) { @@ -205,11 +217,15 @@ impl DocSource { /// thread because jaq values are `Rc`-based, not `Send`. Chunking keeps /// output (and per-file warnings) byte-identical to a sequential scan while /// holding at most one chunk of parsed documents in memory. -fn stream_files(scope: &Scope, emit: &mut dyn FnMut(Val) -> Result<()>) -> Result<()> { +fn stream_files( + config: &crate::config::Config, + scope: &Scope, + emit: &mut dyn FnMut(Val) -> Result<()>, +) -> Result<()> { let kind_sel = scope.kind.as_deref().map(kinds::parse_kind_selector); let project = scope.project.as_deref().map(canonicalize_or_self); let project_under = scope.project_under.as_deref().map(canonicalize_or_self); - let sources = select_files(scope)?; + let sources = select_files(config, scope)?; #[cfg(not(target_os = "emscripten"))] { @@ -287,7 +303,7 @@ fn emit_wrapped( /// that is, when `--source`/`--id` is present, or when no `--input` is given /// at all (the default whole-cache scan). `--input` files are appended in the /// order given. -fn select_files(scope: &Scope) -> Result> { +fn select_files(config: &crate::config::Config, scope: &Scope) -> Result> { let mut sources = Vec::new(); let restrict = scope.source.is_some() || !scope.ids.is_empty(); @@ -304,7 +320,7 @@ fn select_files(scope: &Scope) -> Result> { // dropped. A `--source`/default scan is not explicit (skip-warn). let by_id = id_set.is_some(); let mut seen_ids: HashSet = HashSet::new(); - for entry in crate::cache::list_cached()? { + for entry in crate::cache::list_cached(config)? { if let Some(ids) = &id_set && !ids.contains(entry.id.as_str()) { @@ -617,7 +633,7 @@ mod tests { project_under: None, kind: None, }; - let files = select_files(&scope).unwrap(); + let files = select_files(&crate::config::Config::default(), &scope).unwrap(); assert_eq!(files.len(), 2); // The full path as given: inputs sharing a basename stay distinct. assert_eq!(files[0].cache_id, "/tmp/some.json"); diff --git a/crates/path-cli/src/share_config.rs b/crates/path-cli/src/share_config.rs index e77157dd..98a83985 100644 --- a/crates/path-cli/src/share_config.rs +++ b/crates/path-cli/src/share_config.rs @@ -25,7 +25,7 @@ use anyhow::{Context, Result}; use serde::Deserialize; use std::path::{Path, PathBuf}; -use crate::config::{home_dir, home_relative}; +use crate::config::{Config, home_relative}; use crate::remote::{RepoSpec, parse_remote}; /// A share remote resolved from config. `display` is the remote exactly @@ -43,9 +43,16 @@ pub(crate) struct ConfiguredRemote { } /// Resolve the share remote configured for `session_dir`, if any. -pub(crate) fn resolve_remote(session_dir: &Path) -> Result> { - let global = crate::config::config_dir()?.join(crate::config::CONFIG_FILE_NAME); - resolve_remote_from(&global, home_dir().as_deref(), session_dir) +pub(crate) fn resolve_remote( + config: &Config, + session_dir: &Path, +) -> Result> { + let global = config.config_dir()?.join(crate::config::CONFIG_FILE_NAME); + resolve_remote_from( + &global, + config.home_dir().map(PathBuf::as_path), + session_dir, + ) } fn resolve_remote_from( diff --git a/crates/path-cli/src/sync/engine.rs b/crates/path-cli/src/sync/engine.rs index c8dae375..664107e3 100644 --- a/crates/path-cli/src/sync/engine.rs +++ b/crates/path-cli/src/sync/engine.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use super::sources::{self, ArtifactSource}; use crate::artifact::{ArtifactRef, ArtifactType}; use crate::cache::write_cached; -use crate::config::{MANIFEST_FILE_NAME, MANIFEST_LOCK_FILE_NAME, config_dir}; +use crate::config::{Config, MANIFEST_FILE_NAME, MANIFEST_LOCK_FILE_NAME}; use crate::harness::HarnessBundle; /// How many manifest writes accumulate before a mid-run checkpoint. @@ -94,12 +94,14 @@ impl SyncObserver for () {} /// (query auto-syncs, imports) union their records instead of /// clobbering each other. pub(crate) fn sync_bundle( + config: &Config, bundle: &HarnessBundle, types: &[ArtifactType], project_under: Option<&Path>, observer: &mut dyn SyncObserver, ) -> Result> { - let manifest = load_manifest()?; + let config_dir = config.config_dir()?; + let manifest = load_manifest(&config_dir)?; let mut out = Vec::with_capacity(types.len()); for &artifact_type in types { // Types with no source in this bundle — an uninstalled @@ -115,6 +117,8 @@ pub(crate) fn sync_bundle( .cloned() .unwrap_or_default(); let outcome = sync_artifacts( + config, + &config_dir, source.as_ref(), artifact_type, &artifacts, @@ -131,7 +135,7 @@ pub(crate) fn sync_bundle( /// artifact needs nothing — no read, no scope check. All-`None` stamps /// mean freshness is unknowable; only a real stamp can vouch /// (mirrors `record_is_current`). -fn is_unchanged(rec: Option<&SyncRecord>, artifact: &ArtifactRef) -> bool { +fn is_unchanged(config: &Config, rec: Option<&SyncRecord>, artifact: &ArtifactRef) -> bool { rec.is_some_and(|rec| { (rec.modified.is_some() || rec.size.is_some()) && rec.modified == artifact.modified @@ -139,7 +143,7 @@ fn is_unchanged(rec: Option<&SyncRecord>, artifact: &ArtifactRef) -> bool { && rec .cache_id .as_deref() - .is_some_and(|id| crate::cache::cache_path(id).is_ok_and(|p| p.exists())) + .is_some_and(|id| crate::cache::cache_path(config, id).is_ok_and(|p| p.exists())) }) } @@ -153,12 +157,15 @@ fn newest_first(artifacts: &[ArtifactRef]) -> Vec<&ArtifactRef> { /// Merge staged records into the manifest under the lock and clear /// the stage. -fn flush_writes(pending: &mut BTreeMap<&'static str, BTreeMap>) -> Result<()> { +fn flush_writes( + config_dir: &Path, + pending: &mut BTreeMap<&'static str, BTreeMap>, +) -> Result<()> { if pending.is_empty() { return Ok(()); } let batch = std::mem::take(pending); - update_manifest(move |manifest| { + update_manifest(config_dir, move |manifest| { for (name, records) in batch { manifest .entry(name.to_string()) @@ -175,6 +182,8 @@ fn flush_writes(pending: &mut BTreeMap<&'static str, BTreeMap = newest_first(artifacts) .into_iter() - .map(|artifact| (artifact, is_unchanged(records.get(&artifact.id), artifact))) + .map(|artifact| { + ( + artifact, + is_unchanged(config, records.get(&artifact.id), artifact), + ) + }) .collect(); let pending_total = order.iter().filter(|(_, unchanged)| !unchanged).count(); observer.begin(artifact_type, pending_total); @@ -241,7 +255,7 @@ fn sync_artifacts( } observer.tick(); if unflushed >= MANIFEST_CHECKPOINT_EVERY_WRITES { - flush_writes(&mut writes)?; + flush_writes(config_dir, &mut writes)?; unflushed = 0; } continue; @@ -258,7 +272,7 @@ fn sync_artifacts( // force: sync owns refresh semantics — a re-sync or a // prior manual `p import` of the same session must not // error on the existing cache entry. - write_cached(&derived.cache_id, &derived.doc, true)?; + write_cached(config, &derived.cache_id, &derived.doc, true)?; stage( &mut writes, SyncRecord { @@ -286,19 +300,24 @@ fn sync_artifacts( } observer.tick(); if unflushed >= MANIFEST_CHECKPOINT_EVERY_WRITES { - flush_writes(&mut writes)?; + flush_writes(config_dir, &mut writes)?; unflushed = 0; } } - flush_writes(&mut writes)?; + flush_writes(config_dir, &mut writes)?; observer.end(); Ok(outcome) } /// Record an externally-derived cache write (`p import`, `share`) in /// the manifest, so sync doesn't re-derive what was just written. -pub(crate) fn record_artifact(artifact: &ArtifactRef, cache_id: &str) -> Result<()> { - update_manifest(|manifest| { +pub(crate) fn record_artifact( + config: &Config, + artifact: &ArtifactRef, + cache_id: &str, +) -> Result<()> { + let config_dir = config.config_dir()?; + update_manifest(&config_dir, |manifest| { manifest .entry(artifact.artifact_type.name().to_string()) .or_default() @@ -318,8 +337,11 @@ pub(crate) fn record_artifact(artifact: &ArtifactRef, cache_id: &str) -> Result< /// Whether the manifest already records exactly this artifact state /// under exactly this cache entry, with the doc present — i.e. a /// write would reproduce what's already there. -pub(crate) fn record_is_current(artifact: &ArtifactRef, cache_id: &str) -> bool { - let Ok(manifest) = load_manifest() else { +pub(crate) fn record_is_current(config: &Config, artifact: &ArtifactRef, cache_id: &str) -> bool { + let Ok(config_dir) = config.config_dir() else { + return false; + }; + let Ok(manifest) = load_manifest(&config_dir) else { return false; }; manifest @@ -332,7 +354,7 @@ pub(crate) fn record_is_current(artifact: &ArtifactRef, cache_id: &str) -> bool && (rec.modified.is_some() || rec.size.is_some()) && rec.modified == artifact.modified && rec.size == artifact.size - && crate::cache::cache_path(cache_id).is_ok_and(|p| p.exists()) + && crate::cache::cache_path(config, cache_id).is_ok_and(|p| p.exists()) }) } @@ -342,12 +364,14 @@ pub(crate) fn record_is_current(artifact: &ArtifactRef, cache_id: &str) -> bool /// Used by `share` to upload straight from the cache. The stat /// targets one artifact directly — no enumeration of its siblings. pub(crate) fn fresh_cache_id( + config: &Config, bundle: &HarnessBundle, artifact_type: ArtifactType, project: Option<&str>, id: &str, ) -> Option { - let manifest = load_manifest().ok()?; + let config_dir = config.config_dir().ok()?; + let manifest = load_manifest(&config_dir).ok()?; let rec = manifest.get(artifact_type.name())?.get(id)?; let cache_id = rec.cache_id.clone()?; let (modified, size) = sources::source_for(bundle, artifact_type)?.stamp(project, id)?; @@ -356,15 +380,15 @@ pub(crate) fn fresh_cache_id( ((rec.modified.is_some() || rec.size.is_some()) && rec.modified == modified && rec.size == size - && crate::cache::cache_path(&cache_id).is_ok_and(|p| p.exists())) + && crate::cache::cache_path(config, &cache_id).is_ok_and(|p| p.exists())) .then_some(cache_id) } /// `p cache rm` eviction: the doc is gone, so any record pointing /// at it downgrades to known-but-uncached (the artifact itself is /// still real; the next in-scope sync re-materializes it). -pub(crate) fn evict_cache_id(cache_id: &str) -> Result<()> { - update_manifest(|manifest| { +pub(crate) fn evict_cache_id(config_dir: &Path, cache_id: &str) -> Result<()> { + update_manifest(config_dir, |manifest| { for records in manifest.values_mut() { for rec in records.values_mut() { if rec.cache_id.as_deref() == Some(cache_id) { @@ -377,16 +401,16 @@ pub(crate) fn evict_cache_id(cache_id: &str) -> Result<()> { // ── manifest IO ──────────────────────────────────────────────────── -fn manifest_path() -> Result { - Ok(config_dir()?.join(MANIFEST_FILE_NAME)) +fn manifest_path(config_dir: &Path) -> PathBuf { + config_dir.join(MANIFEST_FILE_NAME) } /// Take the exclusive advisory lock serializing manifest writers /// across processes (query auto-syncs and imports can run /// concurrently). A sibling lock file — never renamed, unlike the /// manifest itself — held until the returned handle drops. -fn lock_manifest() -> Result { - let path = manifest_path()?; +fn lock_manifest(config_dir: &Path) -> Result { + let path = manifest_path(config_dir); let dir = path.parent().expect("manifest path has a parent"); std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; let lock_path = dir.join(MANIFEST_LOCK_FILE_NAME); @@ -405,15 +429,15 @@ fn lock_manifest() -> Result { /// One locked read-modify-write cycle against the manifest. Every /// writer goes through here, so concurrent invocations merge their /// records instead of clobbering each other's. -fn update_manifest(mutate: impl FnOnce(&mut Manifest)) -> Result<()> { - let _lock = lock_manifest()?; - let mut manifest = load_manifest()?; +fn update_manifest(config_dir: &Path, mutate: impl FnOnce(&mut Manifest)) -> Result<()> { + let _lock = lock_manifest(config_dir)?; + let mut manifest = load_manifest(config_dir)?; mutate(&mut manifest); - save_manifest(&manifest) + save_manifest(config_dir, &manifest) } -pub(crate) fn load_manifest() -> Result { - let path = manifest_path()?; +pub(crate) fn load_manifest(config_dir: &Path) -> Result { + let path = manifest_path(config_dir); let json = match std::fs::read_to_string(&path) { Ok(s) => s, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Manifest::default()), @@ -429,8 +453,8 @@ pub(crate) fn load_manifest() -> Result { /// Write the manifest atomically (temp file + rename) with the same /// permissions as the rest of `$CONFIG_DIR`. -fn save_manifest(manifest: &Manifest) -> Result<()> { - let path = manifest_path()?; +fn save_manifest(config_dir: &Path, manifest: &Manifest) -> Result<()> { + let path = manifest_path(config_dir); let dir = path.parent().expect("manifest path has a parent"); std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; #[cfg(unix)] @@ -454,26 +478,19 @@ fn save_manifest(manifest: &Manifest) -> Result<()> { #[cfg(test)] mod tests { use super::*; - use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK}; use std::path::Path; - /// Run `f` with `$TOOLPATH_CONFIG_DIR` pinned to `/.toolpath`; - /// `f` receives the tempdir root for building provider fixtures. - fn with_cfg R, R>(f: F) -> R { - let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + /// Run `f` with a `Config` whose config dir is `/.toolpath`; + /// `f` also receives the tempdir root for building provider fixtures + /// and the config directory itself. + fn with_cfg R, R>(f: F) -> R { let temp = tempfile::tempdir().unwrap(); - let prev = std::env::var_os(CONFIG_DIR_ENV); - unsafe { - std::env::set_var(CONFIG_DIR_ENV, temp.path().join(".toolpath")); - } - let result = f(temp.path()); - unsafe { - match prev { - Some(v) => std::env::set_var(CONFIG_DIR_ENV, v), - None => std::env::remove_var(CONFIG_DIR_ENV), - } - } - result + let config_root = temp.path().join(".toolpath"); + let config = Config { + toolpath_config_dir: Some(config_root.clone()), + ..Config::default() + }; + f(temp.path(), &config, &config_root) } fn write_claude_session(home: &Path, project_slug: &str, session: &str, prompt: &str) { @@ -500,8 +517,8 @@ mod tests { } } - fn cached_step_count(cache_id: &str) -> usize { - let path = crate::cache::cache_path(cache_id).unwrap(); + fn cached_step_count(config: &Config, cache_id: &str) -> usize { + let path = crate::cache::cache_path(config, cache_id).unwrap(); let json = std::fs::read_to_string(path).unwrap(); let doc = toolpath::v1::Graph::from_json(&json).unwrap(); doc.single_path().map(|p| p.steps.len()).unwrap_or(0) @@ -519,8 +536,8 @@ mod tests { #[test] fn manifest_roundtrips_and_missing_is_empty() { - with_cfg(|_| { - assert!(load_manifest().unwrap().is_empty()); + with_cfg(|_, _, config_dir| { + assert!(load_manifest(config_dir).unwrap().is_empty()); let mut manifest = Manifest::default(); manifest.entry("claude".to_string()).or_default().insert( @@ -533,8 +550,8 @@ mod tests { synced_at: "2026-07-09T00:00:00Z".parse().unwrap(), }, ); - save_manifest(&manifest).unwrap(); - assert_eq!(load_manifest().unwrap(), manifest); + save_manifest(config_dir, &manifest).unwrap(); + assert_eq!(load_manifest(config_dir).unwrap(), manifest); }); } @@ -542,9 +559,9 @@ mod tests { #[test] fn manifest_file_is_0600() { use std::os::unix::fs::PermissionsExt; - with_cfg(|_| { - save_manifest(&Manifest::default()).unwrap(); - let mode = std::fs::metadata(manifest_path().unwrap()) + with_cfg(|_, _, config_dir| { + save_manifest(config_dir, &Manifest::default()).unwrap(); + let mode = std::fs::metadata(manifest_path(config_dir)) .unwrap() .permissions() .mode() @@ -555,17 +572,17 @@ mod tests { #[test] fn corrupt_manifest_errors_with_hint() { - with_cfg(|_| { - save_manifest(&Manifest::default()).unwrap(); - std::fs::write(manifest_path().unwrap(), "not json").unwrap(); - let err = load_manifest().unwrap_err(); + with_cfg(|_, _, config_dir| { + save_manifest(config_dir, &Manifest::default()).unwrap(); + std::fs::write(manifest_path(config_dir), "not json").unwrap(); + let err = load_manifest(config_dir).unwrap_err(); assert!(err.to_string().contains("re-sync from scratch")); }); } #[test] fn enumerated_claude_sessions_are_stamped() { - with_cfg(|home| { + with_cfg(|home, _, _| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); let source = sources::source_for(&bundle, ArtifactType::Claude).unwrap(); @@ -583,12 +600,13 @@ mod tests { #[test] fn first_sync_ingests_then_second_is_unchanged() { - with_cfg(|home| { + with_cfg(|home, config, config_dir| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); write_claude_session(home, "-test-project", "sess-bbb", "Fix a bug"); let bundle = claude_bundle(home); - let outcomes = sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); + let outcomes = + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); assert_eq!(outcomes.len(), 1); let (_, first) = outcomes[0]; assert_eq!( @@ -596,7 +614,7 @@ mod tests { (2, 0, 0, 0) ); - let manifest = load_manifest().unwrap(); + let manifest = load_manifest(config_dir).unwrap(); let records = manifest.get("claude").unwrap(); assert_eq!(records.len(), 2); let rec = records.get("sess-aaa").unwrap(); @@ -608,12 +626,13 @@ mod tests { .as_deref() .expect("synced record is materialized"); assert!( - crate::cache::cache_path(cache_id).unwrap().exists(), + crate::cache::cache_path(config, cache_id).unwrap().exists(), "cache doc must exist for {cache_id}" ); let (_, second) = - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap() + [0]; assert_eq!( (second.new, second.updated, second.unchanged, second.failed), (0, 0, 2, 0) @@ -623,16 +642,16 @@ mod tests { #[test] fn changed_session_is_rederived() { - with_cfg(|home| { + with_cfg(|home, config, config_dir| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); - let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] + let cache_id = load_manifest(config_dir).unwrap()["claude"]["sess-aaa"] .cache_id .clone() .expect("synced record is materialized"); - let steps_before = cached_step_count(&cache_id); + let steps_before = cached_step_count(config, &cache_id); // Session continues: a later user turn lands in the file, // changing its size (and mtime). @@ -645,7 +664,8 @@ mod tests { std::fs::write(&file, body).unwrap(); let (_, outcome) = - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap() + [0]; assert_eq!( ( outcome.new, @@ -656,7 +676,7 @@ mod tests { (0, 1, 0, 0) ); assert!( - cached_step_count(&cache_id) > steps_before, + cached_step_count(config, &cache_id) > steps_before, "re-derived doc must contain the appended turn" ); }); @@ -664,14 +684,15 @@ mod tests { #[test] fn sync_touches_only_requested_types() { - with_cfg(|home| { + with_cfg(|home, config, config_dir| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - let outcomes = sync_bundle(&bundle, &[ArtifactType::Codex], None, &mut ()).unwrap(); + let outcomes = + sync_bundle(config, &bundle, &[ArtifactType::Codex], None, &mut ()).unwrap(); assert_eq!(outcomes[0].1, SyncOutcome::default()); assert!( - load_manifest().unwrap().is_empty(), + load_manifest(config_dir).unwrap().is_empty(), "codex-only sync must not ingest claude sessions" ); }); @@ -679,24 +700,25 @@ mod tests { #[test] fn sync_overwrites_cache_entry_it_does_not_remember() { - with_cfg(|home| { + with_cfg(|home, config, config_dir| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); // Losing the manifest (or a prior manual `p import`) leaves a // cache entry sync doesn't know about; re-syncing must // overwrite it, not die on the exists-check. - std::fs::remove_file(manifest_path().unwrap()).unwrap(); + std::fs::remove_file(manifest_path(config_dir)).unwrap(); let (_, outcome) = - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap() + [0]; assert_eq!((outcome.new, outcome.failed), (1, 0)); }); } #[test] fn failed_derivation_is_tallied_and_skipped() { - with_cfg(|home| { + with_cfg(|home, config, config_dir| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); let source = sources::source_for(&bundle, ArtifactType::Claude).unwrap(); @@ -704,6 +726,8 @@ mod tests { artifacts.push(make_ref(ArtifactType::Claude, "does-not-exist")); let outcome = sync_artifacts( + config, + config_dir, source.as_ref(), ArtifactType::Claude, &artifacts, @@ -713,7 +737,7 @@ mod tests { ) .unwrap(); assert_eq!((outcome.new, outcome.failed), (1, 1)); - let records = &load_manifest().unwrap()["claude"]; + let records = &load_manifest(config_dir).unwrap()["claude"]; assert!(records.contains_key("sess-aaa")); assert!( !records.contains_key("does-not-exist"), @@ -724,15 +748,15 @@ mod tests { #[test] fn rotated_session_resyncs_under_its_head_id() { - with_cfg(|home| { + with_cfg(|home, config, config_dir| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); - let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); + let cache_id = load_manifest(config_dir).unwrap()["claude"]["sess-aaa"] .cache_id .clone() .unwrap(); - let steps_before = cached_step_count(&cache_id); + let steps_before = cached_step_count(config, &cache_id); // The session rotates: a successor file whose first entry // carries the predecessor's sessionId (the bridge). @@ -749,46 +773,50 @@ mod tests { .unwrap(); let (_, outcome) = - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap() + [0]; assert_eq!( (outcome.new, outcome.updated, outcome.unchanged), (0, 1, 0), "the chain must re-sync under its head id, not read as unchanged" ); - let manifest = load_manifest().unwrap(); + let manifest = load_manifest(config_dir).unwrap(); assert!( !manifest["claude"].contains_key("sess-bbb"), "successor segments are not separate artifacts" ); assert!( - cached_step_count(&cache_id) > steps_before, + cached_step_count(config, &cache_id) > steps_before, "post-rotation turns must reach the cached doc" ); // And the grown chain settles: a third sync is a no-op. let (_, again) = - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap() + [0]; assert_eq!((again.updated, again.unchanged), (0, 1)); }); } #[test] fn all_none_stamps_never_read_as_unchanged() { - with_cfg(|home| { + with_cfg(|home, config, config_dir| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); // A record whose stamps are all None (stat failed when it // was written) must not match a stub whose stat also // failed — unknowable freshness re-derives. - let mut records = load_manifest().unwrap()["claude"].clone(); + let mut records = load_manifest(config_dir).unwrap()["claude"].clone(); let rec = records.get_mut("sess-aaa").unwrap(); rec.modified = None; rec.size = None; let artifact = make_ref(ArtifactType::Claude, "sess-aaa"); let source = sources::source_for(&bundle, ArtifactType::Claude).unwrap(); let outcome = sync_artifacts( + config, + config_dir, source.as_ref(), ArtifactType::Claude, &[artifact], @@ -803,7 +831,7 @@ mod tests { #[test] fn recorded_import_is_unchanged_to_the_next_sync() { - with_cfg(|home| { + with_cfg(|home, config, _| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); @@ -818,12 +846,13 @@ mod tests { let artifact = derived.provenance.as_ref().unwrap(); assert_eq!(artifact.id, "sess-aaa"); assert!(artifact.modified.is_some() && artifact.size.is_some()); - crate::cache::write_cached(&derived.cache_id, &derived.doc, true).unwrap(); - record_artifact(artifact, &derived.cache_id).unwrap(); + crate::cache::write_cached(config, &derived.cache_id, &derived.doc, true).unwrap(); + record_artifact(config, artifact, &derived.cache_id).unwrap(); // The import's stamp must match sync's own enumeration. let (_, outcome) = - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap() + [0]; assert_eq!( ( outcome.new, @@ -838,12 +867,13 @@ mod tests { #[test] fn project_under_scopes_path_keyed_enumeration() { - with_cfg(|home| { + with_cfg(|home, config, config_dir| { write_claude_session(home, "-scope-alpha", "aaaa1111-x", "In alpha"); write_claude_session(home, "-scope-beta", "bbbb2222-x", "In beta"); let bundle = claude_bundle(home); let (_, scoped) = sync_bundle( + config, &bundle, &[ArtifactType::Claude], Some(Path::new("/scope/alpha")), @@ -851,7 +881,7 @@ mod tests { ) .unwrap()[0]; assert_eq!((scoped.new, scoped.out_of_scope), (1, 0)); - let manifest = load_manifest().unwrap(); + let manifest = load_manifest(config_dir).unwrap(); assert!( !manifest["claude"].contains_key("bbbb2222-x"), "pruned projects must not be enumerated or recorded" @@ -859,7 +889,8 @@ mod tests { // Unscoped sync picks up the rest. let (_, full) = - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap() + [0]; assert_eq!((full.new, full.unchanged), (1, 1)); }); } @@ -886,12 +917,13 @@ mod tests { #[test] fn out_of_scope_codex_peek_is_memoized_then_scope_match_derives() { - with_cfg(|home| { + with_cfg(|home, config, config_dir| { let bundle = codex_bundle(home, "/work/proj"); // cwd lives outside the constraint: one bounded peek, a // known-but-uncached record, no derive. let (_, out) = sync_bundle( + config, &bundle, &[ArtifactType::Codex], Some(Path::new("/elsewhere")), @@ -900,7 +932,8 @@ mod tests { .unwrap()[0]; assert_eq!((out.new, out.out_of_scope), (0, 1)); let rec = - load_manifest().unwrap()["codex"]["00000000-0000-0000-0000-0000000000aa"].clone(); + load_manifest(config_dir).unwrap()["codex"]["00000000-0000-0000-0000-0000000000aa"] + .clone(); assert_eq!( rec.path.as_deref(), Some("/work/proj"), @@ -911,6 +944,7 @@ mod tests { // Matching constraint: the memoized record answers the scope // question and the artifact derives. let (_, hit) = sync_bundle( + config, &bundle, &[ArtifactType::Codex], Some(Path::new("/work/proj")), @@ -919,7 +953,8 @@ mod tests { .unwrap()[0]; assert_eq!((hit.new, hit.updated, hit.out_of_scope), (0, 1, 0)); let rec = - load_manifest().unwrap()["codex"]["00000000-0000-0000-0000-0000000000aa"].clone(); + load_manifest(config_dir).unwrap()["codex"]["00000000-0000-0000-0000-0000000000aa"] + .clone(); assert!(rec.cache_id.is_some(), "materialized now"); assert_eq!( rec.path.as_deref(), @@ -948,11 +983,12 @@ mod tests { #[test] fn copilot_syncs_and_scopes_via_memoized_peek() { - with_cfg(|home| { + with_cfg(|home, config, config_dir| { let bundle = copilot_bundle(home, "sess-cp", "/work/proj"); // Out-of-scope first: one peek, a known record with the cwd. let (_, out) = sync_bundle( + config, &bundle, &[ArtifactType::Copilot], Some(Path::new("/elsewhere")), @@ -960,12 +996,13 @@ mod tests { ) .unwrap()[0]; assert_eq!((out.new, out.out_of_scope), (0, 1)); - let rec = load_manifest().unwrap()["copilot"]["sess-cp"].clone(); + let rec = load_manifest(config_dir).unwrap()["copilot"]["sess-cp"].clone(); assert_eq!(rec.path.as_deref(), Some("/work/proj")); assert!(rec.cache_id.is_none()); // In scope: derives; then a plain re-sync is a no-op. let (_, hit) = sync_bundle( + config, &bundle, &[ArtifactType::Copilot], Some(Path::new("/work")), @@ -974,36 +1011,40 @@ mod tests { .unwrap()[0]; assert_eq!((hit.updated, hit.out_of_scope), (1, 0)); let (_, again) = - sync_bundle(&bundle, &[ArtifactType::Copilot], None, &mut ()).unwrap()[0]; + sync_bundle(config, &bundle, &[ArtifactType::Copilot], None, &mut ()).unwrap() + [0]; assert_eq!(again.unchanged, 1); }); } #[test] fn evicted_cache_entry_rematerializes_on_next_sync() { - with_cfg(|home| { + with_cfg(|home, config, config_dir| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); - let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); + let cache_id = load_manifest(config_dir).unwrap()["claude"]["sess-aaa"] .cache_id .clone() .unwrap(); // `p cache rm`: doc removed, record downgraded to known. - crate::cache::remove_cached(&cache_id).unwrap(); - evict_cache_id(&cache_id).unwrap(); + crate::cache::remove_cached(config, &cache_id).unwrap(); + evict_cache_id(config_dir, &cache_id).unwrap(); assert!( - load_manifest().unwrap()["claude"]["sess-aaa"] + load_manifest(config_dir).unwrap()["claude"]["sess-aaa"] .cache_id .is_none() ); let (_, outcome) = - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap() + [0]; assert_eq!((outcome.new, outcome.updated), (0, 1)); assert!( - crate::cache::cache_path(&cache_id).unwrap().exists(), + crate::cache::cache_path(config, &cache_id) + .unwrap() + .exists(), "evicted artifact re-materializes" ); }); @@ -1011,21 +1052,22 @@ mod tests { #[test] fn manually_deleted_doc_is_restored_even_with_stale_record() { - with_cfg(|home| { + with_cfg(|home, config, config_dir| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); - let cache_id = load_manifest().unwrap()["claude"]["sess-aaa"] + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); + let cache_id = load_manifest(config_dir).unwrap()["claude"]["sess-aaa"] .cache_id .clone() .unwrap(); // Doc deleted behind the CLI's back: the record still claims // materialization, but sync verifies the doc exists. - let doc = crate::cache::cache_path(&cache_id).unwrap(); + let doc = crate::cache::cache_path(config, &cache_id).unwrap(); std::fs::remove_file(&doc).unwrap(); let (_, outcome) = - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()[0]; + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap() + [0]; assert_eq!((outcome.new, outcome.updated), (0, 1)); assert!(doc.exists()); }); @@ -1033,13 +1075,14 @@ mod tests { #[test] fn fresh_cache_id_tracks_source_and_eviction() { - with_cfg(|home| { + with_cfg(|home, config, config_dir| { write_claude_session(home, "-test-project", "sess-aaa", "Add a feature"); let bundle = claude_bundle(home); // Nothing synced yet: no fresh copy. assert!( fresh_cache_id( + config, &bundle, ArtifactType::Claude, Some("/test/project"), @@ -1048,8 +1091,9 @@ mod tests { .is_none() ); - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); let cache_id = fresh_cache_id( + config, &bundle, ArtifactType::Claude, Some("/test/project"), @@ -1067,6 +1111,7 @@ mod tests { std::fs::write(&file, body).unwrap(); assert!( fresh_cache_id( + config, &bundle, ArtifactType::Claude, Some("/test/project"), @@ -1074,9 +1119,10 @@ mod tests { ) .is_none() ); - sync_bundle(&bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); + sync_bundle(config, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap(); assert!( fresh_cache_id( + config, &bundle, ArtifactType::Claude, Some("/test/project"), @@ -1086,10 +1132,11 @@ mod tests { ); // Evicted: known but not materialized, so not fresh. - crate::cache::remove_cached(&cache_id).unwrap(); - evict_cache_id(&cache_id).unwrap(); + crate::cache::remove_cached(config, &cache_id).unwrap(); + evict_cache_id(config_dir, &cache_id).unwrap(); assert!( fresh_cache_id( + config, &bundle, ArtifactType::Claude, Some("/test/project"), @@ -1102,7 +1149,7 @@ mod tests { #[test] fn copilot_peek_accepts_top_level_cwd() { - with_cfg(|home| { + with_cfg(|home, config, config_dir| { // Older CLIs store cwd at the payload top level, no // `context` object — the peek must still find it. let copilot_dir = home.join(".copilot"); @@ -1124,6 +1171,7 @@ mod tests { ..Default::default() }; let (_, out) = sync_bundle( + config, &bundle, &[ArtifactType::Copilot], Some(Path::new("/elsewhere")), @@ -1131,7 +1179,7 @@ mod tests { ) .unwrap()[0]; assert_eq!(out.out_of_scope, 1); - let rec = load_manifest().unwrap()["copilot"]["sess-legacy"].clone(); + let rec = load_manifest(config_dir).unwrap()["copilot"]["sess-legacy"].clone(); assert_eq!(rec.path.as_deref(), Some("/work/proj")); }); } diff --git a/crates/path-cli/src/sync/sources.rs b/crates/path-cli/src/sync/sources.rs index ae8b1319..d310249c 100644 --- a/crates/path-cli/src/sync/sources.rs +++ b/crates/path-cli/src/sync/sources.rs @@ -558,8 +558,12 @@ mod tests { assert!(source_for(&empty, ArtifactType::Claude).is_none()); assert!(source_for(&empty, ArtifactType::Git).is_none()); + let config = crate::config::Config { + home: Some(PathBuf::from("/home/jailed")), + ..Default::default() + }; let with_claude = HarnessBundle { - claude: Some(toolpath_claude::ClaudeConvo::new()), + claude: Some(crate::providers::claude_convo(&config)), ..Default::default() }; assert!(source_for(&with_claude, ArtifactType::Claude).is_some()); diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index f751c40e..0df0a4c1 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -19,7 +19,7 @@ use support::*; #[test] fn file_input_explicit_claude_projects_and_records_exec() { let _env = env_lock(); - let _home = ScopedHome::new(); + let home = ScopedHome::new(); let _path = ScopedPath::with_binary("claude"); let cwd = tempfile::tempdir().unwrap(); @@ -30,6 +30,7 @@ fn file_input_explicit_claude_projects_and_records_exec() { run_with_strategy( args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, + &home.config(), ) .unwrap(); @@ -53,7 +54,7 @@ fn file_input_explicit_claude_projects_and_records_exec() { #[test] fn file_input_explicit_gemini_projects_and_records_exec() { let _env = env_lock(); - let _home = ScopedHome::new(); + let home = ScopedHome::new(); let _path = ScopedPath::with_binary("gemini"); let cwd = tempfile::tempdir().unwrap(); @@ -64,6 +65,7 @@ fn file_input_explicit_gemini_projects_and_records_exec() { run_with_strategy( args_explicit(doc_file, cwd.path(), Harness::Gemini), &recorder, + &home.config(), ) .unwrap(); @@ -81,7 +83,7 @@ fn file_input_explicit_gemini_projects_and_records_exec() { #[test] fn file_input_explicit_codex_projects_and_records_exec() { let _env = env_lock(); - let _home = ScopedHome::new(); + let home = ScopedHome::new(); let _path = ScopedPath::with_binary("codex"); let cwd = tempfile::tempdir().unwrap(); @@ -92,6 +94,7 @@ fn file_input_explicit_codex_projects_and_records_exec() { run_with_strategy( args_explicit(doc_file, cwd.path(), Harness::Codex), &recorder, + &home.config(), ) .unwrap(); @@ -109,7 +112,7 @@ fn file_input_explicit_codex_projects_and_records_exec() { #[test] fn file_input_explicit_copilot_projects_and_records_exec() { let _env = env_lock(); - let _home = ScopedHome::new(); + let home = ScopedHome::new(); let _path = ScopedPath::with_binary("copilot"); let cwd = tempfile::tempdir().unwrap(); @@ -120,6 +123,7 @@ fn file_input_explicit_copilot_projects_and_records_exec() { run_with_strategy( args_explicit(doc_file, cwd.path(), Harness::Copilot), &recorder, + &home.config(), ) .unwrap(); @@ -144,7 +148,7 @@ fn file_input_explicit_copilot_projects_and_records_exec() { #[test] fn file_input_explicit_opencode_projects_and_records_exec() { let _env = env_lock(); - let _home = ScopedHome::new(); + let home = ScopedHome::new(); let _path = ScopedPath::with_binary("opencode"); let cwd = tempfile::tempdir().unwrap(); @@ -194,6 +198,7 @@ fn file_input_explicit_opencode_projects_and_records_exec() { run_with_strategy( args_explicit(doc_file, cwd.path(), Harness::Opencode), &recorder, + &home.config(), ) .unwrap(); @@ -212,7 +217,7 @@ fn file_input_explicit_opencode_projects_and_records_exec() { #[test] fn file_input_explicit_pi_projects_and_records_exec() { let _env = env_lock(); - let _home = ScopedHome::new(); + let home = ScopedHome::new(); let _path = ScopedPath::with_binary("pi"); let cwd = tempfile::tempdir().unwrap(); @@ -220,7 +225,12 @@ fn file_input_explicit_pi_projects_and_records_exec() { let doc_file = write_path_to_temp(cwd.path(), path); let recorder = RecordingExec::default(); - run_with_strategy(args_explicit(doc_file, cwd.path(), Harness::Pi), &recorder).unwrap(); + run_with_strategy( + args_explicit(doc_file, cwd.path(), Harness::Pi), + &recorder, + &home.config(), + ) + .unwrap(); let cap = recorder.captured(); assert_eq!(cap.binary, "pi"); @@ -238,7 +248,7 @@ fn file_input_explicit_pi_projects_and_records_exec() { #[test] fn cache_id_input_loads_and_projects() { let _env = env_lock(); - let _home = ScopedHome::new(); + let home = ScopedHome::new(); let _path = ScopedPath::with_binary("claude"); let cwd = tempfile::tempdir().unwrap(); @@ -268,7 +278,7 @@ fn cache_id_input_loads_and_projects() { }; let recorder = RecordingExec::default(); - run_with_strategy(resume_args, &recorder).unwrap(); + run_with_strategy(resume_args, &recorder, &home.config()).unwrap(); let cap = recorder.captured(); assert_eq!(cap.binary, "claude"); @@ -280,7 +290,7 @@ fn cache_id_input_loads_and_projects() { #[test] fn multi_path_graph_returns_clear_error() { let _env = env_lock(); - let _home = ScopedHome::new(); + let home = ScopedHome::new(); let _path = ScopedPath::with_binary("claude"); let cwd = tempfile::tempdir().unwrap(); @@ -303,6 +313,7 @@ fn multi_path_graph_returns_clear_error() { let err = run_with_strategy( args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, + &home.config(), ) .unwrap_err(); let s = err.to_string(); @@ -313,7 +324,7 @@ fn multi_path_graph_returns_clear_error() { #[test] fn agentless_path_returns_clear_error() { let _env = env_lock(); - let _home = ScopedHome::new(); + let home = ScopedHome::new(); let _path = ScopedPath::with_binary("claude"); let cwd = tempfile::tempdir().unwrap(); @@ -325,6 +336,7 @@ fn agentless_path_returns_clear_error() { let err = run_with_strategy( args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, + &home.config(), ) .unwrap_err(); assert!(err.to_string().contains("no agent session")); @@ -333,7 +345,7 @@ fn agentless_path_returns_clear_error() { #[test] fn explicit_harness_not_on_path_errors() { let _env = env_lock(); - let _home = ScopedHome::new(); + let home = ScopedHome::new(); let _path = ScopedPath::empty(); let cwd = tempfile::tempdir().unwrap(); @@ -344,6 +356,7 @@ fn explicit_harness_not_on_path_errors() { let err = run_with_strategy( args_explicit(doc_file, cwd.path(), Harness::Claude), &recorder, + &home.config(), ) .unwrap_err(); let s = err.to_string(); diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index bf7597ba..c652a3cf 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; use path_cli::cmd_resume::ResumeArgs; +use path_cli::config::Config; use path_cli::harness::Harness; /// Process-wide lock for tests that mutate `$HOME`, `$PATH`, or @@ -53,6 +54,13 @@ impl ScopedHome { pub fn home_dir(&self) -> PathBuf { PathBuf::from(self._td.path()) } + + /// The `Config` the CLI extracts at its composition root. Loaded + /// under this guard, so every path it carries points into the + /// sandbox. + pub fn config(&self) -> Config { + Config::load().expect("load config") + } } impl Drop for ScopedHome { diff --git a/crates/toolpath-gemini/Cargo.toml b/crates/toolpath-gemini/Cargo.toml index b5b09f3e..7ba036b6 100644 --- a/crates/toolpath-gemini/Cargo.toml +++ b/crates/toolpath-gemini/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-gemini" -version = "0.6.1" +version = "0.7.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-gemini/README.md b/crates/toolpath-gemini/README.md index 3cf6930d..54bfa743 100644 --- a/crates/toolpath-gemini/README.md +++ b/crates/toolpath-gemini/README.md @@ -36,7 +36,7 @@ and provides: ```rust,no_run use toolpath_gemini::{GeminiConvo, derive::{DeriveConfig, derive_path}}; -let manager = GeminiConvo::new(); +let manager = GeminiConvo::new("/Users/alex"); let convo = manager.read_conversation( "/Users/alex/project", "session-uuid", @@ -52,7 +52,7 @@ let path = derive_path(&convo, &config); ```rust,no_run use toolpath_gemini::GeminiConvo; -let manager = GeminiConvo::new(); +let manager = GeminiConvo::new("/Users/alex"); // List projects let projects = manager.list_projects()?; @@ -92,7 +92,7 @@ Gemini-specific structures. use toolpath_gemini::GeminiConvo; use toolpath_convo::ConversationProvider; -let provider = GeminiConvo::new(); +let provider = GeminiConvo::new("/Users/alex"); let view = provider.load_conversation("/path/to/project", "session-uuid")?; for turn in &view.turns { diff --git a/crates/toolpath-gemini/src/error.rs b/crates/toolpath-gemini/src/error.rs index e358ed88..b9764cb1 100644 --- a/crates/toolpath-gemini/src/error.rs +++ b/crates/toolpath-gemini/src/error.rs @@ -11,9 +11,6 @@ pub enum ConvoError { #[error("JSON parsing error: {0}")] Json(#[from] serde_json::Error), - #[error("Home directory not found")] - NoHomeDirectory, - #[error("Gemini directory not found at path: {0}")] GeminiDirectoryNotFound(PathBuf), diff --git a/crates/toolpath-gemini/src/io.rs b/crates/toolpath-gemini/src/io.rs index 66a730b2..d23e7d9e 100644 --- a/crates/toolpath-gemini/src/io.rs +++ b/crates/toolpath-gemini/src/io.rs @@ -27,16 +27,10 @@ pub struct ConvoIO { resolver: PathResolver, } -impl Default for ConvoIO { - fn default() -> Self { - Self::new() - } -} - impl ConvoIO { - pub fn new() -> Self { + pub fn new>(home: P) -> Self { Self { - resolver: PathResolver::new(), + resolver: PathResolver::new(home), } } @@ -48,7 +42,7 @@ impl ConvoIO { &self.resolver } - pub fn gemini_dir_path(&self) -> Result { + pub fn gemini_dir_path(&self) -> PathBuf { self.resolver.gemini_dir() } @@ -372,7 +366,7 @@ mod tests { }"#; fs::write(session_dir.join("sub-s.json"), sub).unwrap(); - let resolver = PathResolver::new().with_gemini_dir(&gemini); + let resolver = PathResolver::new(temp.path()).with_gemini_dir(&gemini); (temp, ConvoIO::with_resolver(resolver)) } @@ -498,7 +492,7 @@ mod tests { ) .unwrap(); - let io = ConvoIO::with_resolver(PathResolver::new().with_gemini_dir(&gemini)); + let io = ConvoIO::with_resolver(PathResolver::new(temp.path()).with_gemini_dir(&gemini)); let convo = io.read_session("/p", "sess").unwrap(); // Fell back to the first file as "main" assert_eq!(convo.sub_agents.len(), 1); @@ -555,7 +549,7 @@ mod tests { ) .unwrap(); - let io = ConvoIO::with_resolver(PathResolver::new().with_gemini_dir(&gemini)); + let io = ConvoIO::with_resolver(PathResolver::new(temp.path()).with_gemini_dir(&gemini)); (temp, io) } @@ -613,7 +607,7 @@ mod tests { ) .unwrap(); - let io = ConvoIO::with_resolver(PathResolver::new().with_gemini_dir(&gemini)); + let io = ConvoIO::with_resolver(PathResolver::new(temp.path()).with_gemini_dir(&gemini)); let convo = io.read_session("/p", "session-solo").unwrap(); assert_eq!(convo.main.session_id, "solo-uuid"); assert!(convo.sub_agents.is_empty()); @@ -646,7 +640,7 @@ mod tests { #[test] fn test_gemini_dir_path_accessor() { let (temp, io) = setup(); - let p = io.gemini_dir_path().unwrap(); + let p = io.gemini_dir_path(); assert_eq!(p, temp.path().join(".gemini")); } @@ -654,7 +648,7 @@ mod tests { fn test_exists_accessor() { let (_t, io) = setup(); assert!(io.exists()); - let missing = ConvoIO::with_resolver(PathResolver::new().with_gemini_dir("/nowhere")); + let missing = ConvoIO::with_resolver(PathResolver::new("/nowhere")); assert!(!missing.exists()); } diff --git a/crates/toolpath-gemini/src/lib.rs b/crates/toolpath-gemini/src/lib.rs index 09ab8b5a..242bc21f 100644 --- a/crates/toolpath-gemini/src/lib.rs +++ b/crates/toolpath-gemini/src/lib.rs @@ -37,7 +37,7 @@ pub use watcher::ConversationWatcher; /// ```rust,no_run /// use toolpath_gemini::GeminiConvo; /// -/// let manager = GeminiConvo::new(); +/// let manager = GeminiConvo::new("/Users/alex"); /// let projects = manager.list_projects()?; /// let convo = manager.read_conversation( /// "/Users/alex/project", @@ -51,15 +51,11 @@ pub struct GeminiConvo { io: ConvoIO, } -impl Default for GeminiConvo { - fn default() -> Self { - Self::new() - } -} - impl GeminiConvo { - pub fn new() -> Self { - Self { io: ConvoIO::new() } + pub fn new>(home: P) -> Self { + Self { + io: ConvoIO::new(home), + } } pub fn with_resolver(resolver: PathResolver) -> Self { @@ -80,7 +76,7 @@ impl GeminiConvo { self.io.exists() } - pub fn gemini_dir_path(&self) -> Result { + pub fn gemini_dir_path(&self) -> std::path::PathBuf { self.io.gemini_dir_path() } @@ -225,7 +221,7 @@ mod tests { ) .unwrap(); - let resolver = PathResolver::new().with_gemini_dir(&gemini); + let resolver = PathResolver::new(temp.path()).with_gemini_dir(&gemini); (temp, GeminiConvo::with_resolver(resolver)) } @@ -333,7 +329,7 @@ mod tests { #[test] fn test_gemini_dir_path() { let (t, mgr) = setup(); - assert_eq!(mgr.gemini_dir_path().unwrap(), t.path().join(".gemini")); + assert_eq!(mgr.gemini_dir_path(), t.path().join(".gemini")); } #[test] @@ -344,8 +340,10 @@ mod tests { } #[test] - fn test_default() { - let _mgr = GeminiConvo::default(); + fn test_new_roots_at_home() { + let temp = TempDir::new().unwrap(); + let mgr = GeminiConvo::new(temp.path()); + assert_eq!(mgr.gemini_dir_path(), temp.path().join(".gemini")); } #[test] diff --git a/crates/toolpath-gemini/src/paths.rs b/crates/toolpath-gemini/src/paths.rs index 561811ef..a3981c32 100644 --- a/crates/toolpath-gemini/src/paths.rs +++ b/crates/toolpath-gemini/src/paths.rs @@ -6,7 +6,7 @@ //! path. Both are supported: the resolver prefers the friendly name when //! it exists on disk, and falls back to the hash otherwise. -use crate::error::{ConvoError, Result}; +use crate::error::Result; use serde::Deserialize; use sha2::{Digest, Sha256}; use std::collections::HashMap; @@ -34,51 +34,40 @@ pub struct SessionEntry { #[derive(Debug, Clone)] pub struct PathResolver { - home_dir: Option, + home_dir: PathBuf, gemini_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: dirs::home_dir(), + home_dir: home.into(), gemini_dir: None, } } - pub fn with_home>(mut self, home: P) -> Self { - self.home_dir = Some(home.into()); - self - } - pub fn with_gemini_dir>(mut self, gemini_dir: P) -> Self { self.gemini_dir = Some(gemini_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 gemini_dir(&self) -> Result { - if let Some(d) = &self.gemini_dir { - return Ok(d.clone()); + pub fn gemini_dir(&self) -> PathBuf { + match &self.gemini_dir { + Some(d) => d.clone(), + None => self.home_dir.join(".gemini"), } - Ok(self.home_dir()?.join(".gemini")) } - pub fn projects_file(&self) -> Result { - Ok(self.gemini_dir()?.join(PROJECTS_FILE)) + pub fn projects_file(&self) -> PathBuf { + self.gemini_dir().join(PROJECTS_FILE) } - pub fn tmp_dir(&self) -> Result { - Ok(self.gemini_dir()?.join(TMP_DIR)) + pub fn tmp_dir(&self) -> PathBuf { + self.gemini_dir().join(TMP_DIR) } /// Absolute path to the project slot directory under `tmp/`. @@ -88,7 +77,7 @@ impl PathResolver { /// `tmp//`. The returned path may not exist /// yet — callers decide how to handle that. pub fn project_dir(&self, project_path: &str) -> Result { - let tmp = self.tmp_dir()?; + let tmp = self.tmp_dir(); if let Some(friendly) = self.friendly_name_for(project_path)? { let candidate = tmp.join(&friendly); @@ -142,10 +131,10 @@ impl PathResolver { /// Read `projects.json` and reverse-lookup a friendly name for the /// given absolute project path. pub fn friendly_name_for(&self, project_path: &str) -> Result> { - let file = match self.projects_file() { - Ok(p) if p.exists() => p, - _ => return Ok(None), - }; + let file = self.projects_file(); + if !file.exists() { + return Ok(None); + } let bytes = fs::read(&file)?; let projects: ProjectsFile = match serde_json::from_slice(&bytes) { Ok(p) => p, @@ -162,8 +151,8 @@ impl PathResolver { let mut seen = std::collections::HashSet::new(); // projects.json entries. - if let Ok(file) = self.projects_file() - && file.exists() + let file = self.projects_file(); + if file.exists() && let Ok(bytes) = fs::read(&file) && let Ok(projects) = serde_json::from_slice::(&bytes) { @@ -175,9 +164,8 @@ impl PathResolver { } // `.project_root` markers under tmp/. - if let Ok(tmp) = self.tmp_dir() - && tmp.exists() - { + let tmp = self.tmp_dir(); + if tmp.exists() { for entry in fs::read_dir(&tmp)?.flatten() { if entry.file_type().ok().is_some_and(|ft| ft.is_dir()) { let marker = entry.path().join(".project_root"); @@ -378,7 +366,7 @@ impl PathResolver { } pub fn exists(&self) -> bool { - self.gemini_dir().map(|p| p.exists()).unwrap_or(false) + self.gemini_dir().exists() } } @@ -456,17 +444,6 @@ pub fn project_hash(project_path: &str) -> String { s } -mod dirs { - use std::env; - use std::path::PathBuf; - - pub fn home_dir() -> Option { - env::var_os("HOME") - .or_else(|| env::var_os("USERPROFILE")) - .map(PathBuf::from) - } -} - #[cfg(test)] mod tests { use super::*; @@ -476,9 +453,7 @@ mod tests { let temp = TempDir::new().unwrap(); let gemini = temp.path().join(".gemini"); fs::create_dir_all(&gemini).unwrap(); - let resolver = PathResolver::new() - .with_home(temp.path()) - .with_gemini_dir(&gemini); + let resolver = PathResolver::new(temp.path()).with_gemini_dir(&gemini); (temp, resolver) } @@ -504,21 +479,21 @@ mod tests { #[test] fn test_gemini_dir_default() { let (temp, resolver) = setup(); - let dir = resolver.gemini_dir().unwrap(); + let dir = resolver.gemini_dir(); assert_eq!(dir, temp.path().join(".gemini")); } #[test] fn test_gemini_dir_from_home() { let temp = TempDir::new().unwrap(); - let resolver = PathResolver::new().with_home(temp.path()); - assert_eq!(resolver.gemini_dir().unwrap(), temp.path().join(".gemini")); + let resolver = PathResolver::new(temp.path()); + assert_eq!(resolver.gemini_dir(), temp.path().join(".gemini")); } #[test] fn test_project_dir_friendly_name() { let (_temp, resolver) = setup(); - let gemini = resolver.gemini_dir().unwrap(); + let gemini = resolver.gemini_dir(); fs::write( gemini.join("projects.json"), r#"{"projects":{"/abs/myrepo":"myrepo"}}"#, @@ -533,7 +508,7 @@ mod tests { #[test] fn test_project_dir_hash_fallback() { let (_temp, resolver) = setup(); - let gemini = resolver.gemini_dir().unwrap(); + let gemini = resolver.gemini_dir(); let hashed = project_hash("/abs/other"); fs::create_dir_all(gemini.join("tmp").join(&hashed)).unwrap(); @@ -544,7 +519,7 @@ mod tests { #[test] fn test_project_dir_no_dir_returns_hash_path() { let (_temp, resolver) = setup(); - let gemini = resolver.gemini_dir().unwrap(); + let gemini = resolver.gemini_dir(); let dir = resolver.project_dir("/never/exists").unwrap(); assert_eq!(dir, gemini.join("tmp").join(project_hash("/never/exists"))); } @@ -552,7 +527,7 @@ mod tests { #[test] fn test_project_dir_prefers_friendly_name_even_without_tmp() { let (_temp, resolver) = setup(); - let gemini = resolver.gemini_dir().unwrap(); + let gemini = resolver.gemini_dir(); // Friendly name is present in projects.json, but tmp// // doesn't exist. When no slot exists, we still prefer the friendly // path so callers targeting the known name work. @@ -568,7 +543,7 @@ mod tests { #[test] fn test_session_dir_chat_file() { let (_temp, resolver) = setup(); - let gemini = resolver.gemini_dir().unwrap(); + let gemini = resolver.gemini_dir(); fs::create_dir_all(gemini.join("tmp/myrepo/chats/session-uuid")).unwrap(); fs::write( gemini.join("projects.json"), @@ -593,7 +568,7 @@ mod tests { #[test] fn test_logs_file() { let (_temp, resolver) = setup(); - let gemini = resolver.gemini_dir().unwrap(); + let gemini = resolver.gemini_dir(); let logs = resolver.logs_file("/abs/myrepo").unwrap(); assert!(logs.ends_with("logs.json")); // Should live inside the project slot @@ -609,7 +584,7 @@ mod tests { #[test] fn test_friendly_name_lookup_malformed_file() { let (_temp, resolver) = setup(); - let gemini = resolver.gemini_dir().unwrap(); + let gemini = resolver.gemini_dir(); fs::write(gemini.join("projects.json"), "not json").unwrap(); assert_eq!(resolver.friendly_name_for("/nope").unwrap(), None); } @@ -617,7 +592,7 @@ mod tests { #[test] fn test_list_project_dirs_union() { let (_temp, resolver) = setup(); - let gemini = resolver.gemini_dir().unwrap(); + let gemini = resolver.gemini_dir(); fs::write( gemini.join("projects.json"), @@ -646,7 +621,7 @@ mod tests { #[test] fn test_list_sessions() { let (_temp, resolver) = setup(); - let gemini = resolver.gemini_dir().unwrap(); + let gemini = resolver.gemini_dir(); fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap(); fs::create_dir_all(gemini.join("tmp/p/chats/session-a")).unwrap(); fs::create_dir_all(gemini.join("tmp/p/chats/session-b")).unwrap(); @@ -670,7 +645,7 @@ mod tests { #[test] fn test_list_chat_files() { let (_temp, resolver) = setup(); - let gemini = resolver.gemini_dir().unwrap(); + let gemini = resolver.gemini_dir(); fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap(); fs::create_dir_all(gemini.join("tmp/p/chats/session-x")).unwrap(); fs::write(gemini.join("tmp/p/chats/session-x/main.json"), "{}").unwrap(); @@ -686,28 +661,21 @@ mod tests { let (_temp, resolver) = setup(); assert!(resolver.exists()); - let missing = PathResolver::new().with_gemini_dir("/never/exists"); + let missing = PathResolver::new("/never/exists"); assert!(!missing.exists()); } - #[test] - fn test_home_dir_from_env() { - let home = dirs::home_dir(); - // Most test environments have one of HOME/USERPROFILE set - assert!(home.is_some()); - } - #[test] fn test_tmp_dir() { let (_t, r) = setup(); - let tmp = r.tmp_dir().unwrap(); + let tmp = r.tmp_dir(); assert!(tmp.ends_with(".gemini/tmp")); } #[test] fn test_chats_dir() { let (_t, r) = setup(); - let gemini = r.gemini_dir().unwrap(); + let gemini = r.gemini_dir(); fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap(); let chats = r.chats_dir("/p").unwrap(); assert_eq!(chats, gemini.join("tmp/p/chats")); @@ -718,7 +686,7 @@ mod tests { // Flat main files at the top of `chats/` are enumerated; UUID // subdirectories are not. let (_t, r) = setup(); - let gemini = r.gemini_dir().unwrap(); + let gemini = r.gemini_dir(); fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap(); let chats = gemini.join("tmp/p/chats"); fs::create_dir_all(&chats).unwrap(); @@ -748,7 +716,7 @@ mod tests { #[test] fn test_main_session_file_path() { let (_t, r) = setup(); - let gemini = r.gemini_dir().unwrap(); + let gemini = r.gemini_dir(); fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap(); let p = r.main_session_file("/p", "session-2026-04-17-abc").unwrap(); assert_eq!(p, gemini.join("tmp/p/chats/session-2026-04-17-abc.json")); @@ -762,7 +730,7 @@ mod tests { #[test] fn test_resolve_main_file_by_stem() { let (_t, r) = setup(); - let gemini = r.gemini_dir().unwrap(); + let gemini = r.gemini_dir(); fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap(); let chats = gemini.join("tmp/p/chats"); fs::create_dir_all(&chats).unwrap(); @@ -781,7 +749,7 @@ mod tests { // Matches the way Gemini CLI's `--resume ` resolves: scans // all main files and matches on inner `sessionId`. let (_t, r) = setup(); - let gemini = r.gemini_dir().unwrap(); + let gemini = r.gemini_dir(); fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap(); let chats = gemini.join("tmp/p/chats"); fs::create_dir_all(&chats).unwrap(); @@ -805,7 +773,7 @@ mod tests { // match, the direct stem lookup wins — it's the fast path and // mirrors CLI lookup order. let (_t, r) = setup(); - let gemini = r.gemini_dir().unwrap(); + let gemini = r.gemini_dir(); fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap(); let chats = gemini.join("tmp/p/chats"); fs::create_dir_all(&chats).unwrap(); @@ -829,7 +797,7 @@ mod tests { #[test] fn test_resolve_main_file_returns_none_when_unmatched() { let (_t, r) = setup(); - let gemini = r.gemini_dir().unwrap(); + let gemini = r.gemini_dir(); fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap(); let chats = gemini.join("tmp/p/chats"); fs::create_dir_all(&chats).unwrap(); @@ -848,7 +816,7 @@ mod tests { // A main file whose inner sessionId matches a sibling UUID dir // should surface once as the main stem, not twice. let (_t, r) = setup(); - let gemini = r.gemini_dir().unwrap(); + let gemini = r.gemini_dir(); fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap(); let chats = gemini.join("tmp/p/chats"); fs::create_dir_all(&chats).unwrap(); diff --git a/crates/toolpath-gemini/src/provider.rs b/crates/toolpath-gemini/src/provider.rs index ead4700a..0335cd86 100644 --- a/crates/toolpath-gemini/src/provider.rs +++ b/crates/toolpath-gemini/src/provider.rs @@ -682,7 +682,7 @@ mod tests { }"#; fs::write(session_dir.join("qclszz.json"), sub).unwrap(); - let resolver = PathResolver::new().with_gemini_dir(&gemini); + let resolver = PathResolver::new(temp.path()).with_gemini_dir(&gemini); (temp, GeminiConvo::with_resolver(resolver)) } @@ -1026,7 +1026,8 @@ mod tests { ) .unwrap(); - let mgr = GeminiConvo::with_resolver(PathResolver::new().with_gemini_dir(&gemini)); + let mgr = + GeminiConvo::with_resolver(PathResolver::new(temp.path()).with_gemini_dir(&gemini)); let view = ConversationProvider::load_conversation(&mgr, "/p", "s").unwrap(); let d = &view.turns[1].delegations[0]; @@ -1066,7 +1067,8 @@ mod tests { ) .unwrap(); - let mgr = GeminiConvo::with_resolver(PathResolver::new().with_gemini_dir(&gemini)); + let mgr = + GeminiConvo::with_resolver(PathResolver::new(temp.path()).with_gemini_dir(&gemini)); let view = ConversationProvider::load_conversation(&mgr, "/p", "s").unwrap(); let delegations = &view.turns[1].delegations; assert_eq!(delegations.len(), 2); diff --git a/crates/toolpath-gemini/src/watcher.rs b/crates/toolpath-gemini/src/watcher.rs index a8ac49f2..d065c81e 100644 --- a/crates/toolpath-gemini/src/watcher.rs +++ b/crates/toolpath-gemini/src/watcher.rs @@ -45,7 +45,7 @@ struct FileState { /// use toolpath_gemini::{GeminiConvo, ConversationWatcher}; /// use toolpath_convo::WatcherEvent; /// -/// let manager = GeminiConvo::new(); +/// let manager = GeminiConvo::new("/Users/alex"); /// let mut watcher = ConversationWatcher::new( /// manager, /// "/path/to/project".to_string(), @@ -265,7 +265,8 @@ mod tests { r#"{"projects":{"/abs/myrepo":"myrepo"}}"#, ) .unwrap(); - let mgr = GeminiConvo::with_resolver(PathResolver::new().with_gemini_dir(&gemini)); + let mgr = + GeminiConvo::with_resolver(PathResolver::new(temp.path()).with_gemini_dir(&gemini)); (temp, mgr, session_dir) } diff --git a/crates/toolpath-gemini/tests/fixture_roundtrip.rs b/crates/toolpath-gemini/tests/fixture_roundtrip.rs index de65fdbe..82a8dddc 100644 --- a/crates/toolpath-gemini/tests/fixture_roundtrip.rs +++ b/crates/toolpath-gemini/tests/fixture_roundtrip.rs @@ -34,7 +34,7 @@ fn fixture_load_via_provider() { let temp = tempfile::tempdir().unwrap(); let (gemini, _sd) = write_session(temp.path()); - let mgr = GeminiConvo::with_resolver(PathResolver::new().with_gemini_dir(&gemini)); + let mgr = GeminiConvo::with_resolver(PathResolver::new(temp.path()).with_gemini_dir(&gemini)); let view = ConversationProvider::load_conversation( &mgr, "/Users/ben/empathic/oss/toolpath", @@ -68,7 +68,7 @@ fn fixture_derives_to_valid_path() { let temp = tempfile::tempdir().unwrap(); let (gemini, _sd) = write_session(temp.path()); - let mgr = GeminiConvo::with_resolver(PathResolver::new().with_gemini_dir(&gemini)); + let mgr = GeminiConvo::with_resolver(PathResolver::new(temp.path()).with_gemini_dir(&gemini)); let convo = mgr .read_conversation("/Users/ben/empathic/oss/toolpath", "session-uuid") .unwrap(); diff --git a/crates/toolpath-gemini/tests/projection_roundtrip.rs b/crates/toolpath-gemini/tests/projection_roundtrip.rs index 2327a7bc..2b51310a 100644 --- a/crates/toolpath-gemini/tests/projection_roundtrip.rs +++ b/crates/toolpath-gemini/tests/projection_roundtrip.rs @@ -407,7 +407,7 @@ fn projected_conversation_loads_via_convo_io() { .unwrap(); } - let resolver = PathResolver::new().with_gemini_dir(&gemini_dir); + let resolver = PathResolver::new(temp.path()).with_gemini_dir(&gemini_dir); let convo = GeminiConvo::with_resolver(resolver); let loaded = convo .read_conversation("/abs/toolpath", &rebuilt.session_uuid) diff --git a/site/_data/crates.json b/site/_data/crates.json index fefe5d46..83f25cd8 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -41,7 +41,7 @@ }, { "name": "toolpath-gemini", - "version": "0.6.1", + "version": "0.7.0", "description": "Derive from Gemini CLI conversation logs", "docs": "https://docs.rs/toolpath-gemini", "crate": "https://crates.io/crates/toolpath-gemini",