From 23a51c3a85c8082403305df400e92c91924b796b Mon Sep 17 00:00:00 2001 From: Robert Queenin <2177841+ecalifornica@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:44:46 -0400 Subject: [PATCH] refactor(claude): PathResolver::new takes a required home argument (#185) toolpath-claude reads no environment variable. The caller supplies the home directory; the crate keeps the layout below it (`/.claude`). Library (breaking, 0.13.0): - `PathResolver::new(home)` takes the home directory as a required argument. `ClaudeConvo::new(home)` and `ConvoIO::new(home)` take the same argument. - Delete the `Default` impls on the three types, `with_home`, the private `dirs::home_dir` helper, and the `NoHomeDirectory` error variant. - Keep `with_claude_dir` as the full override. - The home directory is always present, so `home_dir()`, `claude_dir()`, `projects_dir()`, `history_file()`, `project_dir()`, `conversation_file()`, `ConvoIO::claude_dir_path()`, and `ConvoIO::conversation_exists()` return a value, not a `Result`. Callers inside the crate drop the dead error arms. - Verbose parse warnings are a `bool` parameter. `ConversationReader::read_conversation_with(path, verbose_warnings)` takes it directly. `ConvoIO::with_verbose_warnings` and `ClaudeConvo::with_verbose_warnings` store it and pass it down. `ConversationReader::read_conversation(path)` warns about the first 5 unparseable lines only. path-cli: - `providers::claude_resolver` returns `Option`. `None` means `Config` carries no home directory, so Claude is out of reach. - `harness_bundle` omits the Claude manager in that case. - `providers::require_claude_resolver` reports "cannot determine the home directory" for the 6 commands that target Claude. - `Config` reads `$CLAUDE_CLI_DEBUG`. `providers::claude_verbose_warnings` turns its presence into the flag every `ClaudeConvo` receives, so the variable keeps its behavior for CLI users. Version bump per the release checklist: crate manifest, workspace dependency, site/_data/crates.json, CHANGELOG.md. --- CHANGELOG.md | 34 +++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- crates/path-cli/src/artifact.rs | 4 +- crates/path-cli/src/cmd_export.rs | 12 +- crates/path-cli/src/cmd_import.rs | 8 +- crates/path-cli/src/cmd_list.rs | 10 +- crates/path-cli/src/cmd_share.rs | 16 +-- crates/path-cli/src/cmd_show.rs | 5 +- crates/path-cli/src/config.rs | 7 + crates/path-cli/src/derive.rs | 3 +- crates/path-cli/src/harness.rs | 1 - crates/path-cli/src/providers.rs | 55 ++++++-- crates/path-cli/src/sync/engine.rs | 3 +- crates/path-cli/src/sync/sources.rs | 4 +- crates/path-cli/tests/roundtrip.rs | 2 +- crates/toolpath-claude/Cargo.toml | 2 +- crates/toolpath-claude/README.md | 6 +- crates/toolpath-claude/src/chain.rs | 8 +- crates/toolpath-claude/src/error.rs | 3 - crates/toolpath-claude/src/io.rs | 74 +++++----- crates/toolpath-claude/src/lib.rs | 68 +++++----- crates/toolpath-claude/src/paths.rs | 179 ++++++++++--------------- crates/toolpath-claude/src/provider.rs | 32 ++--- crates/toolpath-claude/src/reader.rs | 14 +- crates/toolpath-claude/src/watcher.rs | 22 +-- site/_data/crates.json | 2 +- 28 files changed, 308 insertions(+), 272 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fe386b1..baadd838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,40 @@ cache the same queries run ~4.7× faster (e.g. `length` 966 ms → drivers so the zero-file rule lives once. - The emscripten (playground) build keeps the sequential engine — no threads there. +## `toolpath-claude`: the caller supplies the home directory — 2026-08-13 + +- **`toolpath-claude`** (0.13.0): breaking. `PathResolver::new(home)` + takes the home directory as a required argument. The crate reads no + environment variable; it keeps the layout knowledge (`/.claude`) + and the caller owns "what is home". `ClaudeConvo::new(home)` and + `ConvoIO::new(home)` take the same argument. + + Removed: the `Default` impls on `PathResolver`, `ConvoIO`, and + `ClaudeConvo`; `PathResolver::with_home`; the `NoHomeDirectory` error + variant. `with_claude_dir` stays as the full override. + + The home directory is always present, so `home_dir()`, + `claude_dir()`, `projects_dir()`, `history_file()`, `project_dir()`, + `conversation_file()`, `ConvoIO::claude_dir_path()`, and + `ConvoIO::conversation_exists()` return a value instead of a + `Result`. `ClaudeConvo::claude_dir_path()` and + `ClaudeConvo::conversation_exists()` follow. + + Verbose parse warnings are a parameter. + `ClaudeConvo::with_verbose_warnings(bool)` and + `ConvoIO::with_verbose_warnings(bool)` set it, + `ConversationReader::read_conversation_with(path, verbose_warnings)` + takes it directly, and `ConversationReader::read_conversation(path)` + warns about the first 5 unparseable lines only. The crate reads no + environment variable for it. +- **`path-cli`** (unreleased): `providers::claude_resolver` returns + `Option`. `None` means the configuration carries no home + directory, so Claude is out of reach: the harness bundle omits it, and + a command that targets Claude reports "cannot determine the home + directory". `Config` reads `$CLAUDE_CLI_DEBUG` and passes the flag to + every `ClaudeConvo` it builds, so the variable keeps its behavior for + CLI users. + ## `toolpath-codex`: the caller supplies the home directory — 2026-08-13 - **`toolpath-codex`** (0.7.0): breaking. `PathResolver::new(home)` diff --git a/Cargo.lock b/Cargo.lock index 2b485093..492763fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4226,7 +4226,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.12.2" +version = "0.13.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index e5f4487c..626bfba3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ license = "Apache-2.0" 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-claude = { version = "0.13.0", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.7.0", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.7.0", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } diff --git a/README.md b/README.md index 3252d80c..84397973 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Everything the CLI does is a library call. Each source has its own crate ```rust use toolpath_claude::{ClaudeConvo, derive::{derive_path, DeriveConfig}}; -let convo = ClaudeConvo::new(); +let convo = ClaudeConvo::new("/Users/alex"); let conversation = convo.read_conversation("/path/to/project", "session-id")?; let path = derive_path(&conversation, &DeriveConfig { include_thinking: true, diff --git a/crates/path-cli/src/artifact.rs b/crates/path-cli/src/artifact.rs index b7127584..2c32399b 100644 --- a/crates/path-cli/src/artifact.rs +++ b/crates/path-cli/src/artifact.rs @@ -135,9 +135,7 @@ pub(crate) fn claude_chain_stamp( let mut modified: Option> = None; let mut size: Option = None; for segment in &segments { - let Ok(file) = mgr.resolver().conversation_file(project, segment) else { - continue; - }; + let file = mgr.resolver().conversation_file(project, segment); let (m, s) = stat_stamp(&file); if let Some(m) = m { modified = Some(modified.map_or(m, |cur| cur.max(m))); diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 2de6fc7d..42a6e740 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -338,10 +338,8 @@ fn claude_session_file( ) -> Result> { let project_dir = std::fs::canonicalize(project_dir) .with_context(|| format!("resolve project path {}", project_dir.display()))?; - 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))?; + let resolver = providers::require_claude_resolver(config)?; + let claude_project_dir = resolver.project_dir(&project_dir.to_string_lossy()); let candidate = claude_project_dir.join(format!("{}.jsonl", session_id)); Ok(candidate.exists().then_some(candidate)) } @@ -754,10 +752,8 @@ fn write_into_claude_project( .with_context(|| format!("resolve project path {}", project_dir.display()))?; let project_path = project_dir.to_string_lossy(); - 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))?; + let resolver = providers::require_claude_resolver(config)?; + let claude_project_dir = resolver.project_dir(&project_path); std::fs::create_dir_all(&claude_project_dir) .with_context(|| format!("create {}", claude_project_dir.display()))?; diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 46ff9be8..94c66c37 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -438,7 +438,9 @@ fn derive_claude( all: bool, config: &Config, ) -> Result> { - let manager = toolpath_claude::ClaudeConvo::with_resolver(providers::claude_resolver(config)); + let manager = + toolpath_claude::ClaudeConvo::with_resolver(providers::require_claude_resolver(config)?) + .with_verbose_warnings(providers::claude_verbose_warnings(config)); derive_claude_with_manager(&manager, project, session, all) } @@ -1576,7 +1578,7 @@ mod tests { ) .unwrap(); - let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir); + let resolver = toolpath_claude::PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = toolpath_claude::ClaudeConvo::with_resolver(resolver); (temp, manager) } @@ -1621,7 +1623,7 @@ mod tests { .unwrap(); } - let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir); + let resolver = toolpath_claude::PathResolver::new(temp.path()).with_claude_dir(&claude_dir); (temp, toolpath_claude::ClaudeConvo::with_resolver(resolver)) } diff --git a/crates/path-cli/src/cmd_list.rs b/crates/path-cli/src/cmd_list.rs index 1b3401cf..c3a63f05 100644 --- a/crates/path-cli/src/cmd_list.rs +++ b/crates/path-cli/src/cmd_list.rs @@ -279,7 +279,9 @@ fn run_github(repo: String, fmt: ListFormat) -> Result<()> { // ── Claude ────────────────────────────────────────────────────────────────── fn run_claude(project: Option, fmt: ListFormat, config: &Config) -> Result<()> { - let manager = providers::claude_convo(config); + let manager = + toolpath_claude::ClaudeConvo::with_resolver(providers::require_claude_resolver(config)?) + .with_verbose_warnings(providers::claude_verbose_warnings(config)); match (project, fmt) { // TSV/JSON without --project: emit sessions across every project so @@ -1311,7 +1313,7 @@ mod tests { ) .unwrap(); - let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir); + let resolver = toolpath_claude::PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = toolpath_claude::ClaudeConvo::with_resolver(resolver); (temp, manager) } @@ -1344,7 +1346,7 @@ mod tests { let projects_dir = claude_dir.join("projects"); std::fs::create_dir_all(&projects_dir).unwrap(); - let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir); + let resolver = toolpath_claude::PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = toolpath_claude::ClaudeConvo::with_resolver(resolver); let result = list_claude_projects(&manager, ListFormat::Pretty); @@ -1386,7 +1388,7 @@ mod tests { let projects_dir = claude_dir.join("projects/-empty-project"); std::fs::create_dir_all(&projects_dir).unwrap(); - let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir); + let resolver = toolpath_claude::PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = toolpath_claude::ClaudeConvo::with_resolver(resolver); let result = list_claude_sessions(&manager, "/empty/project", ListFormat::Pretty); diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 623c23fc..e48dc9cd 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -675,12 +675,10 @@ fn harness_status_claude(bundle: &HarnessBundle, home: Option<&std::path::Path>) let Some(mgr) = &bundle.claude else { return HarnessStatus::unresolved(); }; - match mgr.resolver().projects_dir() { - Ok(p) => HarnessStatus { - path: crate::config::home_relative(&p, home), - exists: p.exists(), - }, - Err(_) => HarnessStatus::unresolved(), + let p = mgr.resolver().projects_dir(); + HarnessStatus { + path: crate::config::home_relative(&p, home), + exists: p.exists(), } } @@ -1042,7 +1040,7 @@ mod tests { fn claude_only_bundle(home: &Path) -> HarnessBundle { let claude_dir = home.join(".claude"); std::fs::create_dir_all(&claude_dir).unwrap(); - let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir); + let resolver = toolpath_claude::PathResolver::new(home).with_claude_dir(&claude_dir); HarnessBundle { claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)), ..Default::default() @@ -1364,7 +1362,7 @@ mod tests { // it as missing rather than going through the `unresolved` branch. let temp = TempDir::new().unwrap(); let claude_dir = temp.path().join(".claude"); // never created - let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir); + let resolver = toolpath_claude::PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let bundle = HarnessBundle { claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)), ..Default::default() @@ -1383,7 +1381,7 @@ mod tests { let temp = TempDir::new().unwrap(); let claude_dir = temp.path().join(".claude"); std::fs::create_dir_all(claude_dir.join("projects")).unwrap(); - let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir); + let resolver = toolpath_claude::PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let bundle = HarnessBundle { claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)), ..Default::default() diff --git a/crates/path-cli/src/cmd_show.rs b/crates/path-cli/src/cmd_show.rs index dbc0933e..c8d73ba8 100644 --- a/crates/path-cli/src/cmd_show.rs +++ b/crates/path-cli/src/cmd_show.rs @@ -110,7 +110,10 @@ pub fn run(source: ShowSource, ansi: bool, config: &Config) -> Result<()> { fn derive_one(source: ShowSource, config: &Config) -> Result { match source { ShowSource::Claude { project, session } => { - let manager = providers::claude_convo(config); + let manager = toolpath_claude::ClaudeConvo::with_resolver( + providers::require_claude_resolver(config)?, + ) + .with_verbose_warnings(providers::claude_verbose_warnings(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 7342dcc1..4139819e 100644 --- a/crates/path-cli/src/config.rs +++ b/crates/path-cli/src/config.rs @@ -53,6 +53,10 @@ pub(crate) const DOCUMENTS_DIR_NAME: &str = "documents"; pub struct Config { /// `$APPDATA`: Windows harness data root. pub(crate) appdata: Option, + /// `$CLAUDE_CLI_DEBUG`: the Claude reader warns about every + /// unparseable conversation line, not just the first 5. Presence is + /// the signal; the value is not read. + pub(crate) claude_cli_debug: Option, /// `$CODEX_ROLLOUT_STRICT`: the Codex reader errors on an /// unparseable rollout line. Presence is the signal; the value is /// not read. @@ -104,6 +108,7 @@ impl Config { /// influence a `Config`. Names match case-insensitively. const ENV_MAP: &'static [(&'static str, &'static str)] = &[ ("APPDATA", "appdata"), + ("CLAUDE_CLI_DEBUG", "claude_cli_debug"), ("CODEX_ROLLOUT_STRICT", "codex_rollout_strict"), ("COPILOT_HOME", "copilot_home"), ("HOME", "home"), @@ -197,6 +202,7 @@ mod tests { figment::Jail::expect_with(|jail| { jail.set_env(CONFIG_DIR_ENV, "/tmp/cfg-root"); jail.set_env("HOME", "/home/jailed"); + jail.set_env("CLAUDE_CLI_DEBUG", "1"); jail.set_env("CODEX_ROLLOUT_STRICT", "1"); jail.set_env("XDG_DATA_HOME", "/home/jailed/.local/share"); jail.set_env("COPILOT_HOME", "/home/jailed/.copilot"); @@ -209,6 +215,7 @@ mod tests { config, Config { appdata: Some(PathBuf::from("/home/jailed/appdata")), + claude_cli_debug: Some("1".to_string()), codex_rollout_strict: Some("1".to_string()), copilot_home: Some(PathBuf::from("/home/jailed/.copilot")), home: Some(PathBuf::from("/home/jailed")), diff --git a/crates/path-cli/src/derive.rs b/crates/path-cli/src/derive.rs index 6286e9b9..2340c898 100644 --- a/crates/path-cli/src/derive.rs +++ b/crates/path-cli/src/derive.rs @@ -38,7 +38,8 @@ pub(crate) fn derive_claude_session( session: &str, ) -> Result { derive_claude_session_with( - &toolpath_claude::ClaudeConvo::with_resolver(providers::claude_resolver(config)), + &toolpath_claude::ClaudeConvo::with_resolver(providers::require_claude_resolver(config)?) + .with_verbose_warnings(providers::claude_verbose_warnings(config)), project, session, ) diff --git a/crates/path-cli/src/harness.rs b/crates/path-cli/src/harness.rs index 9d9e1eed..385533b2 100644 --- a/crates/path-cli/src/harness.rs +++ b/crates/path-cli/src/harness.rs @@ -92,7 +92,6 @@ pub(crate) struct HarnessBundle { 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) - || matches!(err, ConvoError::NoHomeDirectory) || matches!(err, ConvoError::ClaudeDirectoryNotFound(_)) } diff --git a/crates/path-cli/src/providers.rs b/crates/path-cli/src/providers.rs index 39ce563b..a60b514f 100644 --- a/crates/path-cli/src/providers.rs +++ b/crates/path-cli/src/providers.rs @@ -27,12 +27,19 @@ fn missing_home(harness: &str) -> anyhow::Error { ) } -pub(crate) fn claude_convo(config: &Config) -> toolpath_claude::ClaudeConvo { - let mut resolver = toolpath_claude::PathResolver::new(); - if let Some(home) = config.home_dir() { - resolver = resolver.with_home(home); - } - toolpath_claude::ClaudeConvo::with_resolver(resolver) +pub(crate) fn claude_resolver(config: &Config) -> Option { + config.home_dir().map(toolpath_claude::PathResolver::new) +} + +/// [`claude_resolver`] for a command that targets Claude. +pub(crate) fn require_claude_resolver(config: &Config) -> Result { + claude_resolver(config).ok_or_else(|| missing_home("Claude")) +} + +/// The Claude reader's verbose-warning flag. `$CLAUDE_CLI_DEBUG` is +/// verbose when set, whatever its value. +pub(crate) fn claude_verbose_warnings(config: &Config) -> bool { + config.claude_cli_debug.is_some() } pub(crate) fn gemini_resolver(config: &Config) -> Option { @@ -117,7 +124,10 @@ pub(crate) fn pi_convo(config: &Config, base: Option<&Path>) -> toolpath_pi::PiC #[cfg(not(target_os = "emscripten"))] pub(crate) fn harness_bundle(config: &Config) -> HarnessBundle { HarnessBundle { - claude: Some(claude_convo(config)), + claude: claude_resolver(config).map(|r| { + toolpath_claude::ClaudeConvo::with_resolver(r) + .with_verbose_warnings(claude_verbose_warnings(config)) + }), gemini: gemini_resolver(config).map(toolpath_gemini::GeminiConvo::with_resolver), codex: codex_resolver(config).map(|r| { toolpath_codex::CodexConvo::with_resolver(r).with_strict(codex_strict(config)) @@ -147,14 +157,31 @@ mod tests { } #[test] - fn claude_convo_roots_at_config_home() { - let manager = claude_convo(&config_with_home()); + fn claude_resolver_roots_at_config_home() { + let resolver = claude_resolver(&config_with_home()).unwrap(); assert_eq!( - manager.resolver().projects_dir().unwrap(), + resolver.projects_dir(), PathBuf::from("/home/jailed/.claude/projects") ); } + #[test] + fn claude_resolver_is_none_without_a_home() { + assert!(claude_resolver(&Config::default()).is_none()); + let err = require_claude_resolver(&Config::default()).unwrap_err(); + assert!(err.to_string().contains("home directory")); + } + + #[test] + fn claude_verbose_warnings_follows_presence_of_the_variable() { + assert!(!claude_verbose_warnings(&Config::default())); + let config = Config { + claude_cli_debug: Some(String::new()), + ..Config::default() + }; + assert!(claude_verbose_warnings(&config)); + } + #[test] fn gemini_resolver_roots_at_config_home() { let resolver = gemini_resolver(&config_with_home()).unwrap(); @@ -232,14 +259,14 @@ mod tests { } #[test] - fn convos_fall_back_to_config_userprofile() { + fn claude_resolver_falls_back_to_config_userprofile() { let config = Config { userprofile: Some(PathBuf::from("/users/jailed")), ..Config::default() }; - let manager = claude_convo(&config); + let resolver = claude_resolver(&config).unwrap(); assert_eq!( - manager.resolver().projects_dir().unwrap(), + resolver.projects_dir(), PathBuf::from("/users/jailed/.claude/projects") ); } @@ -278,7 +305,7 @@ mod tests { fn harness_bundle_roots_providers_at_config_home() { let bundle = harness_bundle(&config_with_home()); assert_eq!( - bundle.claude.unwrap().resolver().projects_dir().unwrap(), + bundle.claude.unwrap().resolver().projects_dir(), PathBuf::from("/home/jailed/.claude/projects") ); assert_eq!( diff --git a/crates/path-cli/src/sync/engine.rs b/crates/path-cli/src/sync/engine.rs index ef8e5adb..d572b84a 100644 --- a/crates/path-cli/src/sync/engine.rs +++ b/crates/path-cli/src/sync/engine.rs @@ -510,7 +510,8 @@ mod tests { } fn claude_bundle(home: &Path) -> HarnessBundle { - let resolver = toolpath_claude::PathResolver::new().with_claude_dir(home.join(".claude")); + let resolver = + toolpath_claude::PathResolver::new(home).with_claude_dir(home.join(".claude")); HarnessBundle { claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)), ..Default::default() diff --git a/crates/path-cli/src/sync/sources.rs b/crates/path-cli/src/sync/sources.rs index d310249c..0d4aded9 100644 --- a/crates/path-cli/src/sync/sources.rs +++ b/crates/path-cli/src/sync/sources.rs @@ -563,7 +563,9 @@ mod tests { ..Default::default() }; let with_claude = HarnessBundle { - claude: Some(crate::providers::claude_convo(&config)), + claude: Some(toolpath_claude::ClaudeConvo::with_resolver( + crate::providers::claude_resolver(&config).unwrap(), + )), ..Default::default() }; assert!(source_for(&with_claude, ArtifactType::Claude).is_some()); diff --git a/crates/path-cli/tests/roundtrip.rs b/crates/path-cli/tests/roundtrip.rs index e768ba25..668bc10a 100644 --- a/crates/path-cli/tests/roundtrip.rs +++ b/crates/path-cli/tests/roundtrip.rs @@ -26,7 +26,7 @@ fn setup_fixture() -> (TempDir, ClaudeConvo) { fs::write(project_dir.join("session-rt.jsonl"), FIXTURE).unwrap(); - let resolver = PathResolver::new().with_home(temp.path()); + let resolver = PathResolver::new(temp.path()); let convo = ClaudeConvo::with_resolver(resolver); (temp, convo) } diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index acb66c2f..a0aaf7ab 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.12.2" +version = "0.13.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-claude/README.md b/crates/toolpath-claude/README.md index 6c3423c1..7bbbcbf6 100644 --- a/crates/toolpath-claude/README.md +++ b/crates/toolpath-claude/README.md @@ -29,7 +29,7 @@ Convert Claude conversations into Toolpath documents: ```rust,no_run use toolpath_claude::{ClaudeConvo, derive::{DeriveConfig, derive_path}}; -let manager = ClaudeConvo::new(); +let manager = ClaudeConvo::new("/Users/alex"); let convo = manager.read_conversation("/Users/alex/project", "session-uuid")?; let config = DeriveConfig::default(); @@ -54,7 +54,7 @@ let path = derive_path(&convo, &config); ```rust,no_run use toolpath_claude::ClaudeConvo; -let manager = ClaudeConvo::new(); +let manager = ClaudeConvo::new("/Users/alex"); // List projects let projects = manager.list_projects()?; @@ -134,7 +134,7 @@ populated and tool-result-only user entries are absorbed (no phantom empty turns use toolpath_claude::ClaudeConvo; use toolpath_convo::ConversationProvider; -let provider = ClaudeConvo::new(); +let provider = ClaudeConvo::new("/Users/alex"); let view = provider.load_conversation("/path/to/project", "session-id")?; for turn in &view.turns { diff --git a/crates/toolpath-claude/src/chain.rs b/crates/toolpath-claude/src/chain.rs index 5854668e..391725bd 100644 --- a/crates/toolpath-claude/src/chain.rs +++ b/crates/toolpath-claude/src/chain.rs @@ -90,7 +90,7 @@ impl ChainIndex { } self.known_files.insert(file_stem.clone()); - let path = resolver.conversation_file(project_path, file_stem)?; + let path = resolver.conversation_file(project_path, file_stem); if let Some(first_sid) = ConversationReader::read_first_session_id(&path) { if first_sid != *file_stem { // This file is a successor of first_sid @@ -165,14 +165,12 @@ mod tests { let project_dir = claude_dir.join("projects/-test-project"); fs::create_dir_all(&project_dir).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); (temp, resolver) } fn write_session(resolver: &PathResolver, session_id: &str, entries: &[&str]) { - let path = resolver - .conversation_file("/test/project", session_id) - .unwrap(); + let path = resolver.conversation_file("/test/project", session_id); fs::write(&path, entries.join("\n")).unwrap(); } diff --git a/crates/toolpath-claude/src/error.rs b/crates/toolpath-claude/src/error.rs index 8bb61092..d3e002ee 100644 --- a/crates/toolpath-claude/src/error.rs +++ b/crates/toolpath-claude/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("Claude directory not found at path: {0}")] ClaudeDirectoryNotFound(PathBuf), diff --git a/crates/toolpath-claude/src/io.rs b/crates/toolpath-claude/src/io.rs index 46f15999..0cd024ff 100644 --- a/crates/toolpath-claude/src/io.rs +++ b/crates/toolpath-claude/src/io.rs @@ -7,17 +7,30 @@ use std::path::PathBuf; #[derive(Debug, Clone)] pub struct ConvoIO { resolver: PathResolver, + verbose_warnings: bool, } impl ConvoIO { - pub fn new() -> Self { + pub fn new>(home: P) -> Self { + Self::with_resolver(PathResolver::new(home)) + } + + pub fn with_resolver(resolver: PathResolver) -> Self { Self { - resolver: PathResolver::new(), + resolver, + verbose_warnings: false, } } - pub fn with_resolver(resolver: PathResolver) -> Self { - Self { resolver } + /// Verbose warnings cover every unparseable line of a conversation + /// file, not just the first 5. + pub fn with_verbose_warnings(mut self, verbose_warnings: bool) -> Self { + self.verbose_warnings = verbose_warnings; + self + } + + pub fn verbose_warnings(&self) -> bool { + self.verbose_warnings } pub fn resolver(&self) -> &PathResolver { @@ -25,8 +38,8 @@ impl ConvoIO { } pub fn read_conversation(&self, project_path: &str, session_id: &str) -> Result { - let path = self.resolver.conversation_file(project_path, session_id)?; - ConversationReader::read_conversation(&path) + let path = self.resolver.conversation_file(project_path, session_id); + ConversationReader::read_conversation_with(&path, self.verbose_warnings) } pub fn read_conversation_metadata( @@ -34,7 +47,7 @@ impl ConvoIO { project_path: &str, session_id: &str, ) -> Result { - let path = self.resolver.conversation_file(project_path, session_id)?; + let path = self.resolver.conversation_file(project_path, session_id); ConversationReader::read_conversation_metadata(&path) } @@ -67,7 +80,7 @@ impl ConvoIO { } pub fn read_history(&self) -> Result> { - let path = self.resolver.history_file()?; + let path = self.resolver.history_file(); ConversationReader::read_history(&path) } @@ -75,26 +88,18 @@ impl ConvoIO { self.resolver.exists() } - pub fn claude_dir_path(&self) -> Result { + pub fn claude_dir_path(&self) -> PathBuf { self.resolver.claude_dir() } - pub fn conversation_exists(&self, project_path: &str, session_id: &str) -> Result { - let path = self.resolver.conversation_file(project_path, session_id)?; - Ok(path.exists()) - } - - pub fn project_exists(&self, project_path: &str) -> bool { + pub fn conversation_exists(&self, project_path: &str, session_id: &str) -> bool { self.resolver - .project_dir(project_path) - .map(|p| p.exists()) - .unwrap_or(false) + .conversation_file(project_path, session_id) + .exists() } -} -impl Default for ConvoIO { - fn default() -> Self { - Self::new() + pub fn project_exists(&self, project_path: &str) -> bool { + self.resolver.project_dir(project_path).exists() } } @@ -118,14 +123,18 @@ mod tests { ) .unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let io = ConvoIO::with_resolver(resolver); (temp, io) } #[test] - fn test_default() { - let _io = ConvoIO::default(); + fn new_roots_at_home() { + let temp = TempDir::new().unwrap(); + let io = ConvoIO::new(temp.path()); + assert_eq!(io.claude_dir_path(), temp.path().join(".claude")); + assert!(!io.verbose_warnings()); + assert!(io.clone().with_verbose_warnings(true).verbose_warnings()); } #[test] @@ -162,7 +171,7 @@ mod tests { ); fs::write(dir.join("imported-session.jsonl"), format!("{entry}\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let io = ConvoIO::with_resolver(resolver); let meta = io .read_conversation_metadata(on_disk, "imported-session") @@ -203,21 +212,14 @@ mod tests { #[test] fn test_claude_dir_path() { let (_temp, io) = setup_io(); - let path = io.claude_dir_path().unwrap(); - assert!(path.exists()); + assert!(io.claude_dir_path().exists()); } #[test] fn test_conversation_exists() { let (_temp, io) = setup_io(); - assert!( - io.conversation_exists("/test/project", "session-1") - .unwrap() - ); - assert!( - !io.conversation_exists("/test/project", "nonexistent") - .unwrap() - ); + assert!(io.conversation_exists("/test/project", "session-1")); + assert!(!io.conversation_exists("/test/project", "nonexistent")); } #[test] diff --git a/crates/toolpath-claude/src/lib.rs b/crates/toolpath-claude/src/lib.rs index 7251769e..86b37380 100644 --- a/crates/toolpath-claude/src/lib.rs +++ b/crates/toolpath-claude/src/lib.rs @@ -46,7 +46,7 @@ pub use watcher::ConversationWatcher; /// ```rust,no_run /// use toolpath_claude::ClaudeConvo; /// -/// let manager = ClaudeConvo::new(); +/// let manager = ClaudeConvo::new("/Users/alex"); /// /// // List all projects /// let projects = manager.list_projects()?; @@ -75,19 +75,11 @@ impl Clone for ClaudeConvo { } } -impl Default for ClaudeConvo { - fn default() -> Self { - Self::new() - } -} - impl ClaudeConvo { - /// Creates a new ClaudeConvo manager with default path resolution. - pub fn new() -> Self { - Self { - io: ConvoIO::new(), - chain_cache: std::cell::RefCell::new(std::collections::HashMap::new()), - } + /// Creates a new ClaudeConvo manager rooted at `home`, so the + /// Claude directory is `/.claude`. + pub fn new>(home: P) -> Self { + Self::with_io(ConvoIO::new(home)) } /// Creates a ClaudeConvo manager with a custom path resolver. @@ -99,15 +91,24 @@ impl ClaudeConvo { /// ```rust /// use toolpath_claude::{ClaudeConvo, PathResolver}; /// - /// let resolver = PathResolver::new() - /// .with_home("/custom/home") - /// .with_claude_dir("/custom/.claude"); + /// let resolver = PathResolver::new("/custom/home").with_claude_dir("/custom/.claude"); /// /// let manager = ClaudeConvo::with_resolver(resolver); /// ``` pub fn with_resolver(resolver: PathResolver) -> Self { + Self::with_io(ConvoIO::with_resolver(resolver)) + } + + /// Verbose warnings cover every unparseable line of a conversation + /// file, not just the first 5. + pub fn with_verbose_warnings(mut self, verbose_warnings: bool) -> Self { + self.io = self.io.with_verbose_warnings(verbose_warnings); + self + } + + fn with_io(io: ConvoIO) -> Self { Self { - io: ConvoIO::with_resolver(resolver), + io, chain_cache: std::cell::RefCell::new(std::collections::HashMap::new()), } } @@ -280,12 +281,12 @@ impl ClaudeConvo { } /// Returns the path to the Claude directory. - pub fn claude_dir_path(&self) -> Result { + pub fn claude_dir_path(&self) -> std::path::PathBuf { self.io.claude_dir_path() } /// Checks if a specific conversation exists. - pub fn conversation_exists(&self, project_path: &str, session_id: &str) -> Result { + pub fn conversation_exists(&self, project_path: &str, session_id: &str) -> bool { self.io.conversation_exists(project_path, session_id) } @@ -415,7 +416,7 @@ mod tests { let claude_dir = temp.path().join(".claude"); fs::create_dir_all(claude_dir.join("projects/-test-project")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(claude_dir); let manager = ClaudeConvo::with_resolver(resolver); (temp, manager) @@ -456,7 +457,7 @@ mod tests { ) .unwrap(); - let resolver = PathResolver::new().with_claude_dir(claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(claude_dir); let manager = ClaudeConvo::with_resolver(resolver); (temp, manager) } @@ -500,16 +501,8 @@ mod tests { #[test] fn test_conversation_exists() { let (_temp, manager) = setup_test_with_conversation(); - assert!( - manager - .conversation_exists("/test/project", "session-abc") - .unwrap() - ); - assert!( - !manager - .conversation_exists("/test/project", "nonexistent") - .unwrap() - ); + assert!(manager.conversation_exists("/test/project", "session-abc")); + assert!(!manager.conversation_exists("/test/project", "nonexistent")); } #[test] @@ -527,8 +520,7 @@ mod tests { #[test] fn test_claude_dir_path() { let (_temp, manager) = setup_test_with_conversation(); - let path = manager.claude_dir_path().unwrap(); - assert!(path.exists()); + assert!(manager.claude_dir_path().exists()); } #[test] @@ -595,9 +587,11 @@ mod tests { } #[test] - fn test_default_impl() { - // Test that Default trait works - let _manager = ClaudeConvo::default(); + fn new_roots_at_home() { + let temp = TempDir::new().unwrap(); + let manager = ClaudeConvo::new(temp.path()); + assert_eq!(manager.claude_dir_path(), temp.path().join(".claude")); + assert!(manager.with_verbose_warnings(true).io().verbose_warnings()); } // ── Session chain convenience methods ──────────────────────────── @@ -628,7 +622,7 @@ mod tests { ]; fs::write(project_dir.join("session-c.jsonl"), c.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(claude_dir); (temp, ClaudeConvo::with_resolver(resolver)) } diff --git a/crates/toolpath-claude/src/paths.rs b/crates/toolpath-claude/src/paths.rs index cd3331a8..a1db29b5 100644 --- a/crates/toolpath-claude/src/paths.rs +++ b/crates/toolpath-claude/src/paths.rs @@ -1,72 +1,58 @@ -use crate::error::{ConvoError, Result}; -use std::env; +use crate::error::Result; use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub struct PathResolver { - home_dir: Option, + home_dir: PathBuf, claude_dir: Option, } -impl Default for PathResolver { - fn default() -> Self { - Self::new() - } -} - impl PathResolver { - pub fn new() -> Self { - let home_dir = dirs::home_dir(); + pub fn new>(home: P) -> Self { Self { - home_dir, + home_dir: home.into(), claude_dir: None, } } - pub fn with_home>(mut self, home: P) -> Self { - self.home_dir = Some(home.into()); - self - } - + /// Override the claude directory directly (defaults to + /// `~/.claude`). pub fn with_claude_dir>(mut self, claude_dir: P) -> Self { self.claude_dir = Some(claude_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 claude_dir(&self) -> Result { - if let Some(ref claude_dir) = self.claude_dir { - return Ok(claude_dir.clone()); + pub fn claude_dir(&self) -> PathBuf { + match &self.claude_dir { + Some(claude_dir) => claude_dir.clone(), + None => self.home_dir.join(".claude"), } - - let home = self.home_dir()?; - Ok(home.join(".claude")) } - pub fn projects_dir(&self) -> Result { - Ok(self.claude_dir()?.join("projects")) + pub fn projects_dir(&self) -> PathBuf { + self.claude_dir().join("projects") } - pub fn history_file(&self) -> Result { - Ok(self.claude_dir()?.join("history.jsonl")) + pub fn history_file(&self) -> PathBuf { + self.claude_dir().join("history.jsonl") } - pub fn project_dir(&self, project_path: &str) -> Result { - let sanitized = sanitize_project_path(project_path); - Ok(self.projects_dir()?.join(sanitized)) + pub fn project_dir(&self, project_path: &str) -> PathBuf { + self.projects_dir() + .join(sanitize_project_path(project_path)) } - pub fn conversation_file(&self, project_path: &str, session_id: &str) -> Result { - Ok(self - .project_dir(project_path)? - .join(format!("{}.jsonl", session_id))) + pub fn conversation_file(&self, project_path: &str, session_id: &str) -> PathBuf { + self.project_dir(project_path) + .join(format!("{}.jsonl", session_id)) } pub fn list_project_dirs(&self) -> Result> { - let projects_dir = self.projects_dir()?; + let projects_dir = self.projects_dir(); if !projects_dir.exists() { return Ok(Vec::new()); } @@ -84,7 +70,7 @@ impl PathResolver { } pub fn list_conversations(&self, project_path: &str) -> Result> { - let project_dir = self.project_dir(project_path)?; + let project_dir = self.project_dir(project_path); if !project_dir.exists() { return Ok(Vec::new()); } @@ -103,7 +89,7 @@ impl PathResolver { } pub fn exists(&self) -> bool { - self.claude_dir().map(|p| p.exists()).unwrap_or(false) + self.claude_dir().exists() } } @@ -118,37 +104,40 @@ pub(crate) fn unsanitize_project_path(sanitized: &str) -> String { sanitized.replace('-', "/") } -mod dirs { - use super::*; - - pub fn home_dir() -> Option { - env::var_os("HOME") - .or_else(|| env::var_os("USERPROFILE")) - .map(PathBuf::from) - } -} - #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; - #[test] - fn test_path_resolution() { + /// A resolver rooted at a temporary home, so the claude directory + /// is `/.claude`. + fn setup() -> (TempDir, PathResolver) { let temp = TempDir::new().unwrap(); - let resolver = PathResolver::new() - .with_home(temp.path()) - .with_claude_dir(temp.path().join(".claude")); + let resolver = PathResolver::new(temp.path()); + (temp, resolver) + } - let claude_dir = resolver.claude_dir().unwrap(); - assert_eq!(claude_dir, temp.path().join(".claude")); + #[test] + fn test_path_resolution() { + let (temp, resolver) = setup(); - let projects_dir = resolver.projects_dir().unwrap(); - assert_eq!(projects_dir, temp.path().join(".claude/projects")); + assert_eq!(resolver.claude_dir(), temp.path().join(".claude")); + assert_eq!( + resolver.projects_dir(), + temp.path().join(".claude/projects") + ); + assert_eq!( + resolver.history_file(), + temp.path().join(".claude/history.jsonl") + ); + } - let history = resolver.history_file().unwrap(); - assert_eq!(history, temp.path().join(".claude/history.jsonl")); + #[test] + fn claude_dir_override_wins_against_home() { + let resolver = PathResolver::new("/custom/home").with_claude_dir("/custom/.claude"); + assert_eq!(resolver.claude_dir(), PathBuf::from("/custom/.claude")); + assert_eq!(resolver.home_dir(), Path::new("/custom/home")); } #[test] @@ -175,29 +164,23 @@ mod tests { #[test] fn test_conversation_file_path() { - let temp = TempDir::new().unwrap(); - let resolver = PathResolver::new().with_claude_dir(temp.path()); - - let convo_file = resolver - .conversation_file("/Users/alex/project", "session-123") - .unwrap(); + let (temp, resolver) = setup(); assert_eq!( - convo_file, + resolver.conversation_file("/Users/alex/project", "session-123"), temp.path() - .join("projects/-Users-alex-project/session-123.jsonl") + .join(".claude/projects/-Users-alex-project/session-123.jsonl") ); } #[test] fn test_list_projects() { - let temp = TempDir::new().unwrap(); - let projects_dir = temp.path().join("projects"); + let (_temp, resolver) = setup(); + let projects_dir = resolver.projects_dir(); fs::create_dir_all(&projects_dir).unwrap(); fs::create_dir(projects_dir.join("-Users-alex-project1")).unwrap(); fs::create_dir(projects_dir.join("-Users-bob-project2")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(temp.path()); let projects = resolver.list_project_dirs().unwrap(); assert_eq!(projects.len(), 2); @@ -207,34 +190,29 @@ mod tests { #[test] fn test_list_projects_empty() { - let temp = TempDir::new().unwrap(); - let projects_dir = temp.path().join("projects"); - fs::create_dir_all(&projects_dir).unwrap(); + let (_temp, resolver) = setup(); + fs::create_dir_all(resolver.projects_dir()).unwrap(); - let resolver = PathResolver::new().with_claude_dir(temp.path()); let projects = resolver.list_project_dirs().unwrap(); assert!(projects.is_empty()); } #[test] fn test_list_projects_no_dir() { - let temp = TempDir::new().unwrap(); - // Don't create projects dir - let resolver = PathResolver::new().with_claude_dir(temp.path()); + let (_temp, resolver) = setup(); let projects = resolver.list_project_dirs().unwrap(); assert!(projects.is_empty()); } #[test] fn test_list_conversations() { - let temp = TempDir::new().unwrap(); - let project_dir = temp.path().join("projects/-test-project"); + let (_temp, resolver) = setup(); + let project_dir = resolver.project_dir("/test/project"); fs::create_dir_all(&project_dir).unwrap(); fs::write(project_dir.join("session-1.jsonl"), "{}").unwrap(); fs::write(project_dir.join("session-2.jsonl"), "{}").unwrap(); fs::write(project_dir.join("not-jsonl.txt"), "{}").unwrap(); - let resolver = PathResolver::new().with_claude_dir(temp.path()); let sessions = resolver.list_conversations("/test/project").unwrap(); assert_eq!(sessions.len(), 2); assert!(sessions.contains(&"session-1".to_string())); @@ -243,54 +221,39 @@ mod tests { #[test] fn test_list_conversations_empty_project() { - let temp = TempDir::new().unwrap(); - let project_dir = temp.path().join("projects/-test-project"); - fs::create_dir_all(&project_dir).unwrap(); + let (_temp, resolver) = setup(); + fs::create_dir_all(resolver.project_dir("/test/project")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(temp.path()); let sessions = resolver.list_conversations("/test/project").unwrap(); assert!(sessions.is_empty()); } #[test] fn test_list_conversations_no_project() { - let temp = TempDir::new().unwrap(); - let resolver = PathResolver::new().with_claude_dir(temp.path()); + let (_temp, resolver) = setup(); let sessions = resolver.list_conversations("/nonexistent/project").unwrap(); assert!(sessions.is_empty()); } #[test] fn test_exists() { - let temp = TempDir::new().unwrap(); - let resolver = PathResolver::new().with_claude_dir(temp.path()); + let (_temp, resolver) = setup(); + assert!(!resolver.exists()); + fs::create_dir_all(resolver.claude_dir()).unwrap(); assert!(resolver.exists()); - let resolver2 = PathResolver::new().with_claude_dir("/nonexistent/dir"); - assert!(!resolver2.exists()); + assert!(!PathResolver::new("/nonexistent/home").exists()); } #[test] - fn test_with_home() { - let resolver = PathResolver::new().with_home("/custom/home"); - assert_eq!( - resolver.home_dir().unwrap().to_str().unwrap(), - "/custom/home" - ); + fn test_home_dir() { + let resolver = PathResolver::new("/custom/home"); + assert_eq!(resolver.home_dir(), Path::new("/custom/home")); } #[test] fn test_history_file() { - let temp = TempDir::new().unwrap(); - let resolver = PathResolver::new().with_claude_dir(temp.path()); - let hist = resolver.history_file().unwrap(); - assert!(hist.ends_with("history.jsonl")); - } - - #[test] - fn test_default_impl() { - let resolver = PathResolver::default(); - // Should not panic, just use system home dir - let _ = resolver.claude_dir(); + let (_temp, resolver) = setup(); + assert!(resolver.history_file().ends_with("history.jsonl")); } } diff --git a/crates/toolpath-claude/src/provider.rs b/crates/toolpath-claude/src/provider.rs index 0e544f96..aab96f6a 100644 --- a/crates/toolpath-claude/src/provider.rs +++ b/crates/toolpath-claude/src/provider.rs @@ -948,7 +948,7 @@ mod tests { ]; fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); (temp, ClaudeConvo::with_resolver(resolver)) } @@ -978,7 +978,7 @@ mod tests { ]; fs::write(project_dir.join("session-2.jsonl"), entries.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); (temp, ClaudeConvo::with_resolver(resolver)) } @@ -1119,7 +1119,7 @@ mod tests { ]; fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let provider = ClaudeConvo::with_resolver(resolver); let view = ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap(); @@ -1144,7 +1144,7 @@ mod tests { ]; fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let provider = ClaudeConvo::with_resolver(resolver); let view = ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap(); @@ -1172,7 +1172,7 @@ mod tests { ]; fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let provider = ClaudeConvo::with_resolver(resolver); let view = ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap(); @@ -1196,7 +1196,7 @@ mod tests { ]; fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let provider = ClaudeConvo::with_resolver(resolver); let view = ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap(); @@ -1296,7 +1296,7 @@ mod tests { ]; fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = ClaudeConvo::with_resolver(resolver); let mut watcher = crate::watcher::ConversationWatcher::new( @@ -1333,7 +1333,7 @@ mod tests { ]; fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = ClaudeConvo::with_resolver(resolver); let mut watcher = crate::watcher::ConversationWatcher::new( @@ -1389,7 +1389,7 @@ mod tests { ) .unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = ClaudeConvo::with_resolver(resolver); let mut watcher = crate::watcher::ConversationWatcher::new( @@ -1565,7 +1565,7 @@ mod tests { ]; fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let provider = ClaudeConvo::with_resolver(resolver); let view = ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap(); @@ -1593,7 +1593,7 @@ mod tests { ]; fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let provider = ClaudeConvo::with_resolver(resolver); let view = ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap(); @@ -1639,7 +1639,7 @@ mod tests { ]; fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let provider = ClaudeConvo::with_resolver(resolver); let view = ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap(); @@ -1662,7 +1662,7 @@ mod tests { ]; fs::write(project_dir.join("s1.jsonl"), entries.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let provider = ClaudeConvo::with_resolver(resolver); let view = ConversationProvider::load_conversation(&provider, "/test/project", "s1").unwrap(); @@ -1758,7 +1758,7 @@ mod tests { ]; fs::write(project_dir.join("session-b.jsonl"), entries_b.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); (temp, ClaudeConvo::with_resolver(resolver)) } @@ -1809,7 +1809,7 @@ mod tests { ]; fs::write(project_dir.join("solo.jsonl"), entries.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let provider = ClaudeConvo::with_resolver(resolver); let view = ConversationProvider::load_conversation(&provider, "/test/project", "solo").unwrap(); @@ -1852,7 +1852,7 @@ mod tests { ) .unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = ClaudeConvo::with_resolver(resolver); let mut watcher = crate::watcher::ConversationWatcher::new( diff --git a/crates/toolpath-claude/src/reader.rs b/crates/toolpath-claude/src/reader.rs index 0fd75912..3b45e0d9 100644 --- a/crates/toolpath-claude/src/reader.rs +++ b/crates/toolpath-claude/src/reader.rs @@ -7,7 +7,19 @@ use std::path::Path; pub struct ConversationReader; impl ConversationReader { + /// Read a conversation file, warning about the first 5 unparseable + /// lines only. pub fn read_conversation>(path: P) -> Result { + Self::read_conversation_with(path, false) + } + + /// [`Self::read_conversation`] with the verbose-warning flag + /// supplied by the caller. Verbose warnings cover every unparseable + /// line, not just the first 5. + pub fn read_conversation_with>( + path: P, + verbose_warnings: bool, + ) -> Result { let path = path.as_ref(); if !path.exists() { return Err(ConvoError::ConversationNotFound(path.display().to_string())); @@ -42,7 +54,7 @@ impl ConversationReader { // re-emit them on roundtrip. if let Ok(value) = serde_json::from_str::(&line) { conversation.preamble.push(value); - } else if line_num < 5 || std::env::var("CLAUDE_CLI_DEBUG").is_ok() { + } else if line_num < 5 || verbose_warnings { eprintln!( "Warning: Failed to parse line {} in {:?}: not valid JSON", line_num + 1, diff --git a/crates/toolpath-claude/src/watcher.rs b/crates/toolpath-claude/src/watcher.rs index 6640993e..a92dbd06 100644 --- a/crates/toolpath-claude/src/watcher.rs +++ b/crates/toolpath-claude/src/watcher.rs @@ -22,7 +22,7 @@ use std::collections::HashSet; /// ```rust,no_run /// use toolpath_claude::{ClaudeConvo, ConversationWatcher}; /// -/// let manager = ClaudeConvo::new(); +/// let manager = ClaudeConvo::new("/Users/alex"); /// let mut watcher = ConversationWatcher::new( /// manager, /// "/path/to/project".to_string(), @@ -260,7 +260,7 @@ mod tests { create_test_jsonl(&claude_dir, "session-1", &[entry1, entry2]); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = ClaudeConvo::with_resolver(resolver); let mut watcher = ConversationWatcher::new( @@ -288,7 +288,7 @@ mod tests { create_test_jsonl(&claude_dir, "session-1", &[entry1]); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = ClaudeConvo::with_resolver(resolver); let mut watcher = ConversationWatcher::new( @@ -312,7 +312,7 @@ mod tests { let claude_dir = temp.path().join(".claude"); create_test_jsonl(&claude_dir, "session-1", &[]); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = ClaudeConvo::with_resolver(resolver); let watcher = ConversationWatcher::new( @@ -334,7 +334,7 @@ mod tests { let entry1 = r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#; create_test_jsonl(&claude_dir, "session-1", &[entry1]); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = ClaudeConvo::with_resolver(resolver); let mut watcher = ConversationWatcher::new( @@ -366,7 +366,7 @@ mod tests { let entry2 = r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#; create_test_jsonl(&claude_dir, "session-1", &[entry1, entry2]); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = ClaudeConvo::with_resolver(resolver); let mut watcher = ConversationWatcher::new( @@ -398,7 +398,7 @@ mod tests { let entry2 = r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#; create_test_jsonl(&claude_dir, "session-1", &[entry1, entry2]); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = ClaudeConvo::with_resolver(resolver); let mut watcher = ConversationWatcher::new( @@ -430,7 +430,7 @@ mod tests { ) .unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = ClaudeConvo::with_resolver(resolver); let mut watcher = ConversationWatcher::new( @@ -476,7 +476,7 @@ mod tests { ) .unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = ClaudeConvo::with_resolver(resolver); let mut watcher = ConversationWatcher::new( @@ -527,7 +527,7 @@ mod tests { ]; fs::write(project_dir.join("session-b.jsonl"), entries_b.join("\n")).unwrap(); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = ClaudeConvo::with_resolver(resolver); let mut watcher = ConversationWatcher::new( @@ -562,7 +562,7 @@ mod tests { let entry1 = r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#; create_test_jsonl(&claude_dir, "session-1", &[entry1]); - let resolver = PathResolver::new().with_claude_dir(&claude_dir); + let resolver = PathResolver::new(temp.path()).with_claude_dir(&claude_dir); let manager = ClaudeConvo::with_resolver(resolver); let mut watcher = ConversationWatcher::new( diff --git a/site/_data/crates.json b/site/_data/crates.json index 715e093a..3fc3f754 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.12.2", + "version": "0.13.0", "description": "Derive from Claude conversation logs", "docs": "https://docs.rs/toolpath-claude", "crate": "https://crates.io/crates/toolpath-claude",