diff --git a/vendor/README.md b/vendor/README.md index e8151495..976eba75 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -63,6 +63,14 @@ next sync should treat these as already-present and not re-apply them. | `#856` (`ae36db5c`) — **M13 selective parser-dependency completion** | Existing Droid settings snapshots can read a same-session fallback JSONL for the model, legacy Kimi wire logs read the shared `~/.kimi/config.json`, and existing Kiro CLI session headers read a same-stem JSONL for estimated tokens, prompt time, and duration. Those dependencies were parser-only: both cache lanes, the live-tail token, and pruning still observed just the scanned primary. **TokenBar adaptation:** parser-owned path helpers now drive specialized monolithic fingerprints, materialized and shipping streaming lanes, latest-mtime probing, and sibling-aware fail-open pruning. Hermetic regressions cover absent-then-created and rewritten dependencies, independently warmed materialized/streaming caches, all three change-token lanes, fresh-dependency retention, missing-dependency pruning, and stat failure. The upstream shard cache, `CacheIdentity`, `parser_version`, Kimi Code, Kiro IDE/globalStorage/`.chat`/Windows sources, and unrelated client changes remain excluded. Related-file fingerprint storage already exists, so cache schema remains 28. | `src/sessions/droid.rs`, `src/sessions/kimi.rs`, `src/sessions/kiro.rs`, `src/message_cache.rs`, `src/lib.rs` | | `#878` (`3587f745`) — **M14** | Codex token snapshots now attribute `duration_ms` to non-overlapping intervals from the previous accepted snapshot rather than repeatedly measuring from the turn start. Invalid, equal, backward, replayed, duplicate, regressive, and zero-token rows do not advance the cursor; a new `turn_context` resets it. **TokenBar adaptation:** the cursor is serialized in the existing incremental state, the local monolithic cache schema bumps **28→29** so unchanged sources cannot replay overlapping durations or resume without the cursor, and hermetic regressions cover parser edge cases, incremental append parity, same-fingerprint schema-28 rebuild, materialized warm-cache parity, and shipping streaming cold/warm performance (`7,000 ms / 170 timed tokens / 3 samples`). Upstream's shard-cache `parser_version` bump is deliberately excluded; the existing Rust → FFI → Swift `ModelPerformance` shape is unchanged. | `src/sessions/codex.rs`, `src/message_cache.rs`, `src/lib.rs` (tests), `tests/fixtures/codex_duration_timing.jsonl` | +## Recovered Windows downstream commits + +The Windows port started from TokenBar [`2ed256ee`](https://github.com/Nanako0129/TokenBar/commit/2ed256eea7f6761e85198e3bc584e08a8d3d8ac1), then accumulated and validated the vendor-only sequence recorded in the [issue #45 handoff](https://github.com/Nanako0129/TokenBar/issues/45#issuecomment-5002865759). These commits are now recovered into this repository so it remains the canonical source for the next Windows sync; the detailed Windows build, hostile-environment, profile-manifest, and FFI evidence stays in that handoff rather than being duplicated here. + +| Commits | What | Files | Cache / upstream status | +|---|---|---|---| +| `e5200634` → `db2a96a3` → `15f418ee` → `e807f333` → `0979cdb0` → `26b892a6` → `6ac77c03` → `e91fb2c6` → `fbecb99c` → `3c8bfc52` → `aec5bd88` | Makes core tests hermetic with panic-safe environment/current-directory guards, serial coordination, isolated cache/XDG roots, platform-safe JSON/path fixtures, and explicit-home parser/scanner fixtures while retaining dedicated positive environment cases. Production fixes release the temporary cache writer before Windows atomic replacement, reopen the final cache read/write for the durability sync, keep Windows Zed/config fallbacks behind `use_env_roots`, and compare extra-path warnings against the supplied scan home. | `src/clients.rs`, `src/lib.rs`, `src/message_cache.rs`, `src/pricing/cache.rs`, `src/scanner.rs`, `src/sessions/claudecode.rs`, `src/sessions/opencode.rs` | No serialized parser output, cache layout, FFI, or public API change; `CACHE_SCHEMA_VERSION` remains 29. Upstream follow-up is deliberately split into cache runtime, explicit-home scanner, and test-only hermetic contributions; none is reported yet. | + ## Upstream fixes reported but not yet vendored Bugs we found *while* vendoring, reported upstream, and that were fixed there — diff --git a/vendor/tokscale-core/src/clients.rs b/vendor/tokscale-core/src/clients.rs index fc866214..c0d1509d 100644 --- a/vendor/tokscale-core/src/clients.rs +++ b/vendor/tokscale-core/src/clients.rs @@ -33,17 +33,10 @@ impl PathRoot { if let Ok(xdg_config_home) = std::env::var("XDG_CONFIG_HOME") { return format!("{xdg_config_home}/tokscale"); } - } - // Match paths::get_config_dir() platform branches so the - // scanner reads from the same root the writer (e.g. - // get_antigravity_cache_dir) targets. Hardcoding - // `{home}/.config/tokscale` everywhere would diverge from - // dirs::config_dir() on Windows (where it resolves to - // %APPDATA%\tokscale), causing synced data to land in - // %APPDATA% while the scanner looks in %USERPROFILE%. - #[cfg(target_os = "windows")] - { + // Match paths::get_config_dir() so default Windows scans + // read the same %APPDATA% root used by cache writers. + #[cfg(target_os = "windows")] if let Some(dir) = dirs::config_dir() { return dir.join("tokscale").to_string_lossy().into_owned(); } @@ -535,20 +528,57 @@ impl Default for ClientCounts { #[cfg(test)] mod tests { use super::*; - use std::sync::{Mutex, OnceLock}; + use serial_test::serial; + use std::ffi::OsStr; + + struct EnvGuard(Vec<(&'static str, Option)>); + + impl EnvGuard { + fn capture(keys: &[&'static str]) -> Self { + Self( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ) + } + + fn set(&mut self, key: &'static str, value: impl AsRef) { + unsafe { std::env::set_var(key, value) }; + } - fn env_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) + fn remove(&mut self, key: &'static str) { + unsafe { std::env::remove_var(key) }; + } } - fn restore_env(var: &str, previous: Option) { - match previous { - Some(value) => unsafe { std::env::set_var(var, value) }, - None => unsafe { std::env::remove_var(var) }, + impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + for (key, previous) in self.0.drain(..) { + match previous { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } } } + #[test] + #[serial] + fn test_env_guard_restores_after_unwind() { + const KEY: &str = "TOKSCALE_CLIENTS_ENV_GUARD_SELF_CHECK"; + let mut outer = EnvGuard::capture(&[KEY]); + outer.set(KEY, "before"); + let result = std::panic::catch_unwind(|| { + let mut inner = EnvGuard::capture(&[KEY]); + inner.set(KEY, "during"); + panic!("exercise EnvGuard unwinding"); + }); + assert!(result.is_err()); + assert_eq!(std::env::var_os(KEY), Some("before".into())); + } + #[test] fn test_client_id_count() { assert_eq!(ClientId::COUNT, 31); @@ -583,77 +613,60 @@ mod tests { } #[test] + #[serial] fn test_path_root_xdg_data_uses_env_var_when_set() { - let _guard = env_lock().lock().unwrap(); - let previous = std::env::var("XDG_DATA_HOME").ok(); - unsafe { std::env::set_var("XDG_DATA_HOME", "/tmp/xdg-data-home") }; + let mut _env = EnvGuard::capture(&["XDG_DATA_HOME"]); + _env.set("XDG_DATA_HOME", "/tmp/xdg-data-home"); let resolved = PathRoot::XdgData.resolve("/tmp/home"); assert_eq!(resolved, "/tmp/xdg-data-home"); - - restore_env("XDG_DATA_HOME", previous); } #[test] + #[serial] fn test_path_root_xdg_data_falls_back_when_unset() { - let _guard = env_lock().lock().unwrap(); - let previous = std::env::var("XDG_DATA_HOME").ok(); - unsafe { std::env::remove_var("XDG_DATA_HOME") }; + let mut _env = EnvGuard::capture(&["XDG_DATA_HOME"]); + _env.remove("XDG_DATA_HOME"); let resolved = PathRoot::XdgData.resolve("/tmp/home"); assert_eq!(resolved, "/tmp/home/.local/share"); - - restore_env("XDG_DATA_HOME", previous); } #[test] + #[serial] fn test_path_root_xdg_data_ignores_env_when_disabled() { - let _guard = env_lock().lock().unwrap(); - let previous = std::env::var("XDG_DATA_HOME").ok(); - unsafe { std::env::set_var("XDG_DATA_HOME", "/tmp/xdg-data-home") }; + let mut _env = EnvGuard::capture(&["XDG_DATA_HOME"]); + _env.set("XDG_DATA_HOME", "/tmp/xdg-data-home"); let resolved = PathRoot::XdgData.resolve_with_env_strategy("/tmp/home", false); assert_eq!(resolved, "/tmp/home/.local/share"); - - restore_env("XDG_DATA_HOME", previous); } #[test] + #[serial] fn test_path_root_config_uses_override_when_set() { - let _guard = env_lock().lock().unwrap(); - let previous_override = std::env::var("TOKSCALE_CONFIG_DIR").ok(); - let previous_xdg = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { - std::env::set_var("TOKSCALE_CONFIG_DIR", "/tmp/custom-config-root"); - std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-config-home"); - } + let mut _env = EnvGuard::capture(&["TOKSCALE_CONFIG_DIR", "XDG_CONFIG_HOME"]); + _env.set("TOKSCALE_CONFIG_DIR", "/tmp/custom-config-root"); + _env.set("XDG_CONFIG_HOME", "/tmp/xdg-config-home"); let resolved = PathRoot::Config.resolve("/tmp/home"); assert_eq!(resolved, "/tmp/custom-config-root"); - - restore_env("TOKSCALE_CONFIG_DIR", previous_override); - restore_env("XDG_CONFIG_HOME", previous_xdg); } #[test] + #[serial] #[cfg(target_os = "linux")] fn test_path_root_config_uses_xdg_config_home_when_override_unset() { - let _guard = env_lock().lock().unwrap(); - let previous_override = std::env::var("TOKSCALE_CONFIG_DIR").ok(); - let previous_xdg = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { - std::env::remove_var("TOKSCALE_CONFIG_DIR"); - std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-config-home"); - } + let mut _env = EnvGuard::capture(&["TOKSCALE_CONFIG_DIR", "XDG_CONFIG_HOME"]); + _env.remove("TOKSCALE_CONFIG_DIR"); + _env.set("XDG_CONFIG_HOME", "/tmp/xdg-config-home"); let resolved = PathRoot::Config.resolve("/tmp/home"); assert_eq!(resolved, "/tmp/xdg-config-home/tokscale"); - - restore_env("TOKSCALE_CONFIG_DIR", previous_override); - restore_env("XDG_CONFIG_HOME", previous_xdg); } #[test] + #[serial] #[cfg(target_os = "windows")] fn test_path_root_config_uses_dirs_config_dir_on_windows() { // Windows must resolve PathRoot::Config to the same root that @@ -661,11 +674,8 @@ mod tests { // i.e. dirs::config_dir() (= %APPDATA%\tokscale). Hardcoding // {home}/.config/tokscale would diverge from the writer side // and silently hide synced Antigravity data from reports. - let _guard = env_lock().lock().unwrap(); - let previous_override = std::env::var("TOKSCALE_CONFIG_DIR").ok(); - unsafe { - std::env::remove_var("TOKSCALE_CONFIG_DIR"); - } + let mut _env = EnvGuard::capture(&["TOKSCALE_CONFIG_DIR"]); + _env.remove("TOKSCALE_CONFIG_DIR"); let resolved = PathRoot::Config.resolve("C:\\fake-home"); let expected = dirs::config_dir() @@ -677,33 +687,25 @@ mod tests { resolved, expected, "PathRoot::Config on Windows must match dirs::config_dir().join('tokscale') so the scanner agrees with the writer" ); - - restore_env("TOKSCALE_CONFIG_DIR", previous_override); } #[test] + #[serial] fn test_path_root_config_ignores_env_when_disabled() { - let _guard = env_lock().lock().unwrap(); - let previous_override = std::env::var("TOKSCALE_CONFIG_DIR").ok(); - let previous_xdg = std::env::var("XDG_CONFIG_HOME").ok(); - unsafe { - std::env::set_var("TOKSCALE_CONFIG_DIR", "/tmp/custom-config-root"); - std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-config-home"); - } + let mut _env = EnvGuard::capture(&["TOKSCALE_CONFIG_DIR", "XDG_CONFIG_HOME"]); + _env.set("TOKSCALE_CONFIG_DIR", "/tmp/custom-config-root"); + _env.set("XDG_CONFIG_HOME", "/tmp/xdg-config-home"); let resolved = PathRoot::Config.resolve_with_env_strategy("/tmp/home", false); assert_eq!(resolved, "/tmp/home/.config/tokscale"); - - restore_env("TOKSCALE_CONFIG_DIR", previous_override); - restore_env("XDG_CONFIG_HOME", previous_xdg); } #[test] + #[serial] fn test_path_root_env_var_uses_env_when_set() { - let _guard = env_lock().lock().unwrap(); let var = "TOKSCALE_TEST_PATH_ROOT"; - let previous = std::env::var(var).ok(); - unsafe { std::env::set_var(var, "/tmp/custom-root") }; + let mut _env = EnvGuard::capture(&[var]); + _env.set(var, "/tmp/custom-root"); let root = PathRoot::EnvVar { var, @@ -711,16 +713,14 @@ mod tests { }; let resolved = root.resolve("/tmp/home"); assert_eq!(resolved, "/tmp/custom-root"); - - restore_env(var, previous); } #[test] + #[serial] fn test_path_root_env_var_falls_back_when_unset() { - let _guard = env_lock().lock().unwrap(); let var = "TOKSCALE_TEST_PATH_ROOT"; - let previous = std::env::var(var).ok(); - unsafe { std::env::remove_var(var) }; + let mut _env = EnvGuard::capture(&[var]); + _env.remove(var); let root = PathRoot::EnvVar { var, @@ -728,16 +728,14 @@ mod tests { }; let resolved = root.resolve("/tmp/home"); assert_eq!(resolved, "/tmp/home/.fallback"); - - restore_env(var, previous); } #[test] + #[serial] fn test_path_root_env_var_ignores_env_when_disabled() { - let _guard = env_lock().lock().unwrap(); let var = "TOKSCALE_TEST_PATH_ROOT"; - let previous = std::env::var(var).ok(); - unsafe { std::env::set_var(var, "/tmp/custom-root") }; + let mut _env = EnvGuard::capture(&[var]); + _env.set(var, "/tmp/custom-root"); let root = PathRoot::EnvVar { var, @@ -745,8 +743,6 @@ mod tests { }; let resolved = root.resolve_with_env_strategy("/tmp/home", false); assert_eq!(resolved, "/tmp/home/.fallback"); - - restore_env(var, previous); } #[test] @@ -837,17 +833,15 @@ mod tests { } #[test] + #[serial] fn test_zed_data_dir_path() { - let _guard = env_lock().lock().unwrap(); - let previous = std::env::var("XDG_DATA_HOME").ok(); - unsafe { std::env::remove_var("XDG_DATA_HOME") }; + let mut _env = EnvGuard::capture(&["XDG_DATA_HOME"]); + _env.remove("XDG_DATA_HOME"); assert_eq!( ClientId::Zed.data().resolve_path("/tmp/home"), "/tmp/home/.local/share/zed/threads/threads.db" ); - - restore_env("XDG_DATA_HOME", previous); } #[test] @@ -875,7 +869,9 @@ mod tests { assert!(client.data().parse_local); assert!(client.data().submit_default); assert_eq!( - client.data().resolve_path("/tmp/home"), + client + .data() + .resolve_path_with_env_strategy("/tmp/home", false), "/tmp/home/.grok/sessions" ); } diff --git a/vendor/tokscale-core/src/lib.rs b/vendor/tokscale-core/src/lib.rs index b3386b2f..b16084e7 100644 --- a/vendor/tokscale-core/src/lib.rs +++ b/vendor/tokscale-core/src/lib.rs @@ -4050,8 +4050,8 @@ mod tests { agent_bucket_key, aggregate_model_usage_entries, apply_pricing_if_available, dedupe_latest_trae_messages, fold_messages_streaming, get_agents_report, get_hourly_report, get_model_report, get_monthly_report, latest_source_mtime_ms, message_cache, - normalize_model_for_grouping, parse_all_messages_with_pricing, - parse_all_messages_with_pricing_with_env_strategy, parse_local_clients, + normalize_model_for_grouping, parse_all_messages_with_pricing_with_env_strategy, + parse_local_clients, parse_local_unified_messages, parsed_to_unified, pricing, prune_scan_result_by_mtime, reprice_lane_message, retain_for_requested_clients, scan_messages_streaming, scanner, select_local_parse_pricing, sessions, unified_to_parsed, AgentAccumulator, ClientId, @@ -4100,15 +4100,55 @@ mod tests { } } + fn scanner_fixture_path(home: &Path, relative: &str) -> PathBuf { + #[cfg(windows)] + { + // The production scanner builds these roots with format!("{home}/{relative}"). + // Match that lexical path on Windows so cache assertions use the same key. + PathBuf::from(format!("{}/{}", home.display(), relative)) + } + #[cfg(not(windows))] + { + home.join(relative) + } + } + + fn opencode_test_env(cache_home: &Path, source_home: &Path) -> EnvGuard { + let xdg_data_home = scanner_fixture_path(source_home, ".local/share"); + EnvGuard::set(&[ + ("HOME", cache_home.as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.as_os_str()), + ("XDG_DATA_HOME", xdg_data_home.as_os_str()), + ]) + } + + fn parse_all_messages_with_pricing( + home_dir: &str, + clients: &[String], + pricing: Option<&pricing::PricingService>, + ) -> Vec { + parse_all_messages_with_pricing_with_env_strategy( + home_dir, + clients, + pricing, + false, + &scanner::ScannerSettings::default(), + ) + } + #[test] #[serial_test::serial] fn test_env_guard_restores_some_and_none_after_panic() { - const KEYS: [&str; 2] = ["HOME", "TOKSCALE_PRICING_CACHE_ONLY"]; + const KEYS: [&str; 3] = ["HOME", "TOKSCALE_PRICING_CACHE_ONLY", "TOKSCALE_CONFIG_DIR"]; let _original = EnvGuard::capture(&KEYS); unsafe { std::env::set_var("HOME", "/tmp/tokscale-env-guard-home-before"); std::env::remove_var("TOKSCALE_PRICING_CACHE_ONLY"); + std::env::set_var( + "TOKSCALE_CONFIG_DIR", + "/tmp/tokscale-env-guard-config-before", + ); } let first = std::panic::catch_unwind(|| { let _guard = EnvGuard::set(&[ @@ -4117,6 +4157,10 @@ mod tests { std::ffi::OsStr::new("/tmp/tokscale-env-guard-home-during"), ), ("TOKSCALE_PRICING_CACHE_ONLY", std::ffi::OsStr::new("1")), + ( + "TOKSCALE_CONFIG_DIR", + std::ffi::OsStr::new("/tmp/tokscale-env-guard-config-during"), + ), ]); panic!("exercise EnvGuard unwinding"); }); @@ -4128,10 +4172,17 @@ mod tests { )) ); assert_eq!(std::env::var_os("TOKSCALE_PRICING_CACHE_ONLY"), None); + assert_eq!( + std::env::var_os("TOKSCALE_CONFIG_DIR"), + Some(std::ffi::OsString::from( + "/tmp/tokscale-env-guard-config-before" + )) + ); unsafe { std::env::remove_var("HOME"); std::env::set_var("TOKSCALE_PRICING_CACHE_ONLY", "before"); + std::env::remove_var("TOKSCALE_CONFIG_DIR"); } let second = std::panic::catch_unwind(|| { let _guard = EnvGuard::set(&[ @@ -4140,6 +4191,10 @@ mod tests { std::ffi::OsStr::new("/tmp/tokscale-env-guard-home-during"), ), ("TOKSCALE_PRICING_CACHE_ONLY", std::ffi::OsStr::new("1")), + ( + "TOKSCALE_CONFIG_DIR", + std::ffi::OsStr::new("/tmp/tokscale-env-guard-config-during"), + ), ]); panic!("exercise inverse EnvGuard unwinding"); }); @@ -4149,6 +4204,7 @@ mod tests { std::env::var_os("TOKSCALE_PRICING_CACHE_ONLY"), Some(std::ffi::OsString::from("before")) ); + assert_eq!(std::env::var_os("TOKSCALE_CONFIG_DIR"), None); } #[test] @@ -5189,7 +5245,13 @@ mod tests { } #[test] + #[serial_test::serial] fn test_cursor_parse_path_reprices_zero_cost_composer_1_5_rows() { + let cache_home = tempfile::TempDir::new().unwrap(); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); let temp_dir = tempfile::TempDir::new().unwrap(); let cursor_cache_dir = temp_dir.path().join(".config/tokscale/cursor-cache"); std::fs::create_dir_all(&cursor_cache_dir).unwrap(); @@ -5231,8 +5293,10 @@ mod tests { fn test_parse_all_messages_with_pricing_kimi_deduplicates_repeated_status_updates() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { write_kimi_repeated_status_fixture(source_home.path()); @@ -5247,11 +5311,6 @@ mod tests { assert_eq!(messages.iter().map(|m| m.tokens.input).sum::(), 40); assert_eq!(messages.iter().map(|m| m.tokens.output).sum::(), 5); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -5259,8 +5318,10 @@ mod tests { fn test_parse_local_clients_kimi_deduplicates_repeated_status_updates() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { write_kimi_repeated_status_fixture(source_home.path()); @@ -5282,11 +5343,6 @@ mod tests { assert_eq!(parsed.messages.iter().map(|m| m.input).sum::(), 40); assert_eq!(parsed.messages.iter().map(|m| m.output).sum::(), 5); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } // Regression: the streaming driver must NOT share one dedup set across @@ -5301,8 +5357,10 @@ mod tests { fn test_streaming_driver_does_not_dedup_across_clients() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { // kimi: one StatusUpdate carrying message_id "COLLIDE". @@ -5344,11 +5402,6 @@ mod tests { "codebuff message with shared dedup_key must survive: {seen:?}" ); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } // M2 (codex fork-replay): the parser-level fork dedup (#649/#681) must also @@ -5362,8 +5415,10 @@ mod tests { fn test_streaming_codex_collapses_parent_replay_across_forks() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { write_codex_parent_replay_fixture(source_home.path()); @@ -5394,11 +5449,6 @@ mod tests { assert_eq!(input_sum, 140); assert_eq!(output_sum, 14); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } // Issue #6: the agents report must dedup the simple_lane! clients @@ -5413,10 +5463,12 @@ mod tests { fn test_agents_report_dedups_like_model_report_issue6() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); // Hermetic: cache-only pricing + temp HOME → no network, pricing None. - std::env::set_var("TOKSCALE_PRICING_CACHE_ONLY", "1"); + let _pricing = EnvGuard::set(&[("TOKSCALE_PRICING_CACHE_ONLY", std::ffi::OsStr::new("1"))]); { let write_codebuff = |proj: &str| { @@ -5488,12 +5540,6 @@ mod tests { model.total_cost ); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } - std::env::remove_var("TOKSCALE_PRICING_CACHE_ONLY"); } // Preservation: with no duplicate dedup_keys (and only parse_local==true @@ -5505,9 +5551,11 @@ mod tests { fn test_agents_report_preserves_numbers_without_duplicates() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); - std::env::set_var("TOKSCALE_PRICING_CACHE_ONLY", "1"); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); + let _pricing = EnvGuard::set(&[("TOKSCALE_PRICING_CACHE_ONLY", std::ffi::OsStr::new("1"))]); { let cb_dir = source_home.path().join(".config/manicode/projects/proj"); @@ -5584,12 +5632,6 @@ mod tests { // Sanity: codebuff contributes its known tokens. assert!(main.input >= 200 && main.output >= 80); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } - std::env::remove_var("TOKSCALE_PRICING_CACHE_ONLY"); } // Issue #36: the client selection must be applied at the STREAMING SCAN, @@ -5605,9 +5647,11 @@ mod tests { fn test_agents_report_client_filter_scopes_shared_bucket_issue36() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); - std::env::set_var("TOKSCALE_PRICING_CACHE_ONLY", "1"); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); + let _pricing = EnvGuard::set(&[("TOKSCALE_PRICING_CACHE_ONLY", std::ffi::OsStr::new("1"))]); { let cb_dir = source_home.path().join(".config/manicode/projects/proj"); @@ -5662,12 +5706,6 @@ mod tests { assert_eq!(filtered.entries[0].clients, vec!["codebuff".to_string()]); assert_eq!(filtered.total_messages, 1, "kimi's message is gone"); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } - std::env::remove_var("TOKSCALE_PRICING_CACHE_ONLY"); } // Issue #36 (round 3): a cc-mirror variant id (`cc-mirror/kimi-code`) is @@ -5684,9 +5722,11 @@ mod tests { fn test_agents_report_cc_mirror_variant_slice_issue36() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); - std::env::set_var("TOKSCALE_PRICING_CACHE_ONLY", "1"); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); + let _pricing = EnvGuard::set(&[("TOKSCALE_PRICING_CACHE_ONLY", std::ffi::OsStr::new("1"))]); { // Plain claude session (client "claude"): 100 in / 50 out. @@ -5704,10 +5744,12 @@ mod tests { std::fs::create_dir_all(&project_dir).unwrap(); std::fs::write( variant_dir.join("variant.json"), - format!( - r#"{{"name":"kimi-code","provider":"kimi","configDir":"{}"}}"#, - config_dir.display() - ), + serde_json::json!({ + "name": "kimi-code", + "provider": "kimi", + "configDir": config_dir, + }) + .to_string(), ) .unwrap(); std::fs::write( @@ -5752,12 +5794,6 @@ mod tests { "claude slice = plain claude (100), not the mixed 400" ); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } - std::env::remove_var("TOKSCALE_PRICING_CACHE_ONLY"); } // Agent bucketing + fold arithmetic in isolation (no fixtures): normalized @@ -5881,13 +5917,13 @@ mod tests { fn test_source_cache_refreshes_stale_date_on_cache_hit() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = opencode_test_env(cache_home.path(), source_home.path()); { - let message_dir = source_home - .path() - .join(".local/share/opencode/storage/message/project-1"); + let message_dir = scanner_fixture_path( + source_home.path(), + ".local/share/opencode/storage/message/project-1", + ); std::fs::create_dir_all(&message_dir).unwrap(); let path = message_dir.join("msg_001.json"); std::fs::write( @@ -5952,11 +5988,6 @@ mod tests { .date ); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[cfg(unix)] @@ -5967,13 +5998,13 @@ mod tests { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = opencode_test_env(cache_home.path(), source_home.path()); { - let message_dir = source_home - .path() - .join(".local/share/opencode/storage/message/project-1"); + let message_dir = scanner_fixture_path( + source_home.path(), + ".local/share/opencode/storage/message/project-1", + ); std::fs::create_dir_all(&message_dir).unwrap(); let path = message_dir.join("msg_001.json"); std::fs::write( @@ -6007,11 +6038,6 @@ mod tests { ); assert_eq!(second_messages.len(), 1); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -6019,25 +6045,34 @@ mod tests { fn test_empty_cache_hits_are_reparsed_for_optional_file_sources() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = opencode_test_env(cache_home.path(), source_home.path()); { - let message_dir = source_home - .path() - .join(".local/share/opencode/storage/message/project-1"); + let message_dir = scanner_fixture_path( + source_home.path(), + ".local/share/opencode/storage/message/project-1", + ); std::fs::create_dir_all(&message_dir).unwrap(); - let path = message_dir.join("msg_001.json"); + let source_path = message_dir.join("msg_001.json"); std::fs::write( - &path, + &source_path, r#"{"id":"msg-1","sessionID":"session-1","role":"assistant","modelID":"accounts/fireworks/models/deepseek-v3-0324","providerID":"fireworks","cost":0,"tokens":{"input":10,"output":5,"reasoning":0,"cache":{"read":0,"write":0}},"time":{"created":1733011200000}}"#, ) .unwrap(); + let cache_path = scanner::scan_all_clients_with_env_strategy( + source_home.path().to_str().unwrap(), + &["opencode".to_string()], + true, + ) + .get(ClientId::OpenCode) + .first() + .cloned() + .expect("scanner must find the OpenCode fixture"); - let fingerprint = message_cache::SourceFingerprint::from_path(&path).unwrap(); + let fingerprint = message_cache::SourceFingerprint::from_path(&cache_path).unwrap(); let mut cache = message_cache::SourceMessageCache::default(); cache.insert(message_cache::CachedSourceEntry::new( - &path, + &cache_path, fingerprint, Vec::new(), Vec::new(), @@ -6053,14 +6088,9 @@ mod tests { assert_eq!(messages.len(), 1); let loaded = message_cache::SourceMessageCache::load(); - let repaired_entry = loaded.get(&path).unwrap(); + let repaired_entry = loaded.get(&cache_path).unwrap(); assert_eq!(repaired_entry.messages.len(), 1); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -6068,8 +6098,7 @@ mod tests { fn test_sqlite_source_cache_invalidates_on_wal_change() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = opencode_test_env(cache_home.path(), source_home.path()); { let db_dir = source_home.path().join(".local/share/opencode"); @@ -6133,11 +6162,6 @@ mod tests { ); assert_eq!(refreshed_messages.len(), 2); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -6148,8 +6172,7 @@ mod tests { // must only be counted once. let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = opencode_test_env(cache_home.path(), source_home.path()); { let db_dir = source_home.path().join(".local/share/opencode"); @@ -6249,11 +6272,6 @@ mod tests { "warm cache must also dedup shared message across channel dbs" ); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -6261,8 +6279,7 @@ mod tests { fn test_parse_all_messages_with_pricing_opencode_sqlite_deduplicates_forked_history() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = opencode_test_env(cache_home.path(), source_home.path()); { let db_dir = source_home.path().join(".local/share/opencode"); @@ -6327,11 +6344,6 @@ mod tests { assert_eq!(messages.iter().map(|m| m.tokens.output).sum::(), 250); assert_eq!(messages.iter().map(|m| m.cost).sum::(), 0.06); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -6339,8 +6351,7 @@ mod tests { fn test_parse_local_clients_opencode_sqlite_counts_deduplicated_forked_history() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = opencode_test_env(cache_home.path(), source_home.path()); { let db_dir = source_home.path().join(".local/share/opencode"); @@ -6411,11 +6422,6 @@ mod tests { assert_eq!(parsed.messages.iter().map(|m| m.input).sum::(), 600); assert_eq!(parsed.messages.iter().map(|m| m.output).sum::(), 250); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } /// Regression fixture for Codex sessions that are live-only, archive-only, @@ -6874,8 +6880,10 @@ mod tests { fn test_parse_all_messages_with_pricing_codex_deduplicates_forked_history() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { write_codex_forked_history_fixture(source_home.path()); @@ -6909,11 +6917,6 @@ mod tests { 33 ); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -6921,8 +6924,10 @@ mod tests { fn test_parse_all_messages_with_pricing_codex_keeps_user_fork_own_turn() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { write_codex_user_fork_replay_fixture(source_home.path()); @@ -6944,11 +6949,6 @@ mod tests { assert_eq!(messages.iter().map(|m| m.tokens.cache_read).sum::(), 500); assert_eq!(messages.iter().map(|m| m.tokens.output).sum::(), 150); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -6956,8 +6956,10 @@ mod tests { fn test_parse_all_messages_with_pricing_codex_deduplicates_parent_replay_across_forks() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { write_codex_parent_replay_fixture(source_home.path()); @@ -6981,11 +6983,6 @@ mod tests { assert_eq!(messages.iter().map(|m| m.tokens.input).sum::(), 140); assert_eq!(messages.iter().map(|m| m.tokens.output).sum::(), 14); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } fn write_codex_twin_token_count_fixture(source_home: &std::path::Path) { @@ -7017,8 +7014,10 @@ mod tests { fn test_parse_all_messages_with_pricing_codex_keeps_twin_token_counts_at_distinct_timestamps() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { write_codex_twin_token_count_fixture(source_home.path()); @@ -7057,11 +7056,6 @@ mod tests { 4, ); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -7069,8 +7063,10 @@ mod tests { fn test_parse_local_clients_codex_counts_deduplicated_forked_history() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { write_codex_forked_history_fixture(source_home.path()); @@ -7114,11 +7110,6 @@ mod tests { 33 ); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -7127,11 +7118,13 @@ mod tests { let cache_home = tempfile::TempDir::new().unwrap(); let fresh_cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { - let codex_dir = source_home.path().join(".codex/sessions"); + let codex_dir = scanner_fixture_path(source_home.path(), ".codex/sessions"); std::fs::create_dir_all(&codex_dir).unwrap(); let path = codex_dir.join("session.jsonl"); std::fs::write( @@ -7176,7 +7169,10 @@ mod tests { &["codex".to_string()], None, ); - std::env::set_var("HOME", fresh_cache_home.path()); + let _fresh_env = EnvGuard::set(&[ + ("HOME", fresh_cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", fresh_cache_home.path().as_os_str()), + ]); let fresh_messages = parse_all_messages_with_pricing( source_home.path().to_str().unwrap(), &["codex".to_string()], @@ -7189,11 +7185,6 @@ mod tests { .iter() .all(|message| message.model_id == "gpt-5.5")); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -7202,11 +7193,13 @@ mod tests { let cache_home = tempfile::TempDir::new().unwrap(); let fresh_cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { - let codex_dir = source_home.path().join(".codex/sessions"); + let codex_dir = scanner_fixture_path(source_home.path(), ".codex/sessions"); std::fs::create_dir_all(&codex_dir).unwrap(); let path = codex_dir.join("session.jsonl"); std::fs::write( @@ -7247,7 +7240,10 @@ mod tests { &["codex".to_string()], None, ); - std::env::set_var("HOME", fresh_cache_home.path()); + let _fresh_env = EnvGuard::set(&[ + ("HOME", fresh_cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", fresh_cache_home.path().as_os_str()), + ]); let fresh_messages = parse_all_messages_with_pricing( source_home.path().to_str().unwrap(), &["codex".to_string()], @@ -7256,11 +7252,6 @@ mod tests { assert_eq!(warm_messages, fresh_messages); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -7269,11 +7260,13 @@ mod tests { let cache_home = tempfile::TempDir::new().unwrap(); let fresh_cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { - let codex_dir = source_home.path().join(".codex/sessions"); + let codex_dir = scanner_fixture_path(source_home.path(), ".codex/sessions"); std::fs::create_dir_all(&codex_dir).unwrap(); let path = codex_dir.join("session.jsonl"); std::fs::write( @@ -7320,7 +7313,10 @@ mod tests { .get(&path) .is_none()); - std::env::set_var("HOME", fresh_cache_home.path()); + let _fresh_env = EnvGuard::set(&[ + ("HOME", fresh_cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", fresh_cache_home.path().as_os_str()), + ]); let fresh_messages = parse_all_messages_with_pricing( source_home.path().to_str().unwrap(), &["codex".to_string()], @@ -7329,11 +7325,6 @@ mod tests { assert_eq!(warm_messages, fresh_messages); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -7341,11 +7332,13 @@ mod tests { fn test_exact_hit_codex_cache_repairs_fallback_timestamps_without_incremental_state() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { - let session_dir = source_home.path().join(".codex/sessions"); + let session_dir = scanner_fixture_path(source_home.path(), ".codex/sessions"); std::fs::create_dir_all(&session_dir).unwrap(); let path = session_dir.join("session.jsonl"); std::fs::write( @@ -7385,11 +7378,6 @@ mod tests { assert_eq!(messages, expected); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -7398,11 +7386,13 @@ mod tests { let cache_home = tempfile::TempDir::new().unwrap(); let fresh_cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { - let session_dir = source_home.path().join(".codex/sessions"); + let session_dir = scanner_fixture_path(source_home.path(), ".codex/sessions"); std::fs::create_dir_all(&session_dir).unwrap(); let path = session_dir.join("session.jsonl"); let contents = concat!( @@ -7429,7 +7419,10 @@ mod tests { None, ); - std::env::set_var("HOME", fresh_cache_home.path()); + let _fresh_env = EnvGuard::set(&[ + ("HOME", fresh_cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", fresh_cache_home.path().as_os_str()), + ]); let fresh_messages = parse_all_messages_with_pricing( source_home.path().to_str().unwrap(), &["codex".to_string()], @@ -7439,11 +7432,6 @@ mod tests { assert_eq!(warm_messages, fresh_messages); assert_ne!(warm_messages[0].timestamp, initial_messages[0].timestamp); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -7451,11 +7439,13 @@ mod tests { fn test_full_log_parse_preserves_valid_messages_before_invalid_line_error() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { - let session_dir = source_home.path().join(".codex/sessions"); + let session_dir = scanner_fixture_path(source_home.path(), ".codex/sessions"); std::fs::create_dir_all(&session_dir).unwrap(); let path = session_dir.join("session.jsonl"); @@ -7484,11 +7474,6 @@ mod tests { let cache = message_cache::SourceMessageCache::load(); assert!(cache.get(&path).is_none()); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -7497,11 +7482,13 @@ mod tests { let cache_home = tempfile::TempDir::new().unwrap(); let fresh_cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { - let session_dir = source_home.path().join(".codex/sessions"); + let session_dir = scanner_fixture_path(source_home.path(), ".codex/sessions"); std::fs::create_dir_all(&session_dir).unwrap(); let path = session_dir.join("session.jsonl"); std::fs::write( @@ -7547,7 +7534,10 @@ mod tests { None, ); - std::env::set_var("HOME", fresh_cache_home.path()); + let _fresh_env = EnvGuard::set(&[ + ("HOME", fresh_cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", fresh_cache_home.path().as_os_str()), + ]); let fresh_messages = parse_all_messages_with_pricing( source_home.path().to_str().unwrap(), &["codex".to_string()], @@ -7558,16 +7548,11 @@ mod tests { assert_eq!(resumed_messages.len(), 1); assert_eq!(resumed_messages[0].model_id, "gpt-5.5"); - std::env::set_var("HOME", cache_home.path()); + drop(_fresh_env); assert!(message_cache::SourceMessageCache::load() .get(&path) .is_some()); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -7576,11 +7561,13 @@ mod tests { let cache_home = tempfile::TempDir::new().unwrap(); let fresh_cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { - let session_dir = source_home.path().join(".codex/sessions"); + let session_dir = scanner_fixture_path(source_home.path(), ".codex/sessions"); std::fs::create_dir_all(&session_dir).unwrap(); let path = session_dir.join("session.jsonl"); std::fs::write( @@ -7625,7 +7612,10 @@ mod tests { None, ); - std::env::set_var("HOME", fresh_cache_home.path()); + let _fresh_env = EnvGuard::set(&[ + ("HOME", fresh_cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", fresh_cache_home.path().as_os_str()), + ]); let fresh_messages = parse_all_messages_with_pricing( source_home.path().to_str().unwrap(), &["codex".to_string()], @@ -7635,11 +7625,6 @@ mod tests { assert_eq!(warm_messages, fresh_messages); assert_eq!(warm_messages.len(), 2); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -7647,8 +7632,10 @@ mod tests { fn test_source_cache_does_not_reuse_priced_cost_without_pricing_service() { let temp_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", temp_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", temp_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", temp_home.path().as_os_str()), + ]); { let cursor_cache_dir = source_home.path().join(".config/tokscale/cursor-cache"); std::fs::create_dir_all(&cursor_cache_dir).unwrap(); @@ -7686,11 +7673,6 @@ mod tests { assert_eq!(cached_messages.len(), 1); assert_eq!(cached_messages[0].cost, 0.0); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -7792,7 +7774,7 @@ mod tests { fn test_cost_provenance_matches_materialized_and_streaming_lanes() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let _env = EnvGuard::set(&[("HOME", cache_home.path().as_os_str())]); + let _env = opencode_test_env(cache_home.path(), source_home.path()); let opencode_data_dir = source_home.path().join(".local/share/opencode"); std::fs::create_dir_all(&opencode_data_dir).unwrap(); @@ -8584,8 +8566,11 @@ mod tests { } #[test] + #[serial_test::serial] fn test_parse_all_messages_with_pricing_keeps_gateway_message_under_synthetic_filter() { + let cache_home = tempfile::TempDir::new().unwrap(); let temp_dir = tempfile::TempDir::new().unwrap(); + let _env = opencode_test_env(cache_home.path(), temp_dir.path()); let message_dir = temp_dir .path() .join(".local/share/opencode/storage/message/project-1"); @@ -8643,8 +8628,11 @@ mod tests { } #[test] + #[serial_test::serial] fn test_parse_all_messages_fireworks_provider_kept_under_synthetic_only_filter() { + let cache_home = tempfile::TempDir::new().unwrap(); let temp_dir = tempfile::TempDir::new().unwrap(); + let _env = opencode_test_env(cache_home.path(), temp_dir.path()); let message_dir = temp_dir .path() .join(".local/share/opencode/storage/message/project-1"); @@ -9096,8 +9084,10 @@ mod tests { fn test_streaming_antigravity_cli_keeps_colliding_response_ids_across_conversations() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { let conversations_dir = source_home @@ -9124,11 +9114,6 @@ mod tests { "both conversations reusing responseId \"SHARED\" must survive" ); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } // jcode (`~/.jcode/sessions/session_*.json`) must be discovered by the @@ -9140,8 +9125,10 @@ mod tests { fn test_streaming_jcode_flows_through_lane() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { let sessions_dir = source_home.path().join(".jcode/sessions"); @@ -9170,11 +9157,6 @@ mod tests { assert_eq!(count, 1, "the jcode assistant message must flow through the streaming lane"); assert_eq!(input_sum, 1200); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } // micode (`$XDG_DATA_HOME/micode/*.db`, WAL-mode SQLite) must be discovered @@ -9186,8 +9168,10 @@ mod tests { fn test_streaming_micode_flows_with_authoritative_cost() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { let micode_dir = source_home.path().join(".local/share/mimocode"); @@ -9231,11 +9215,6 @@ mod tests { "authoritative micode cost must survive pricing (got {cost_sum})" ); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } // #742 Part 2: the micode lane is cost-guarded so MiMo Code's authoritative @@ -10063,8 +10042,10 @@ mod tests { fn test_streaming_gjc_flows_with_authoritative_cost() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { let gjc_dir = source_home.path().join(".gjc/agent/sessions"); @@ -10096,11 +10077,6 @@ mod tests { "authoritative gjc cost must reach the sink (got {cost_sum})" ); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } // jcode's `session_*.json` snapshot is a file-lane source whose sibling @@ -10752,8 +10728,10 @@ mod tests { fn test_parse_all_messages_refreshes_cc_mirror_provider_when_variant_metadata_changes() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { let variant_dir = source_home.path().join(".cc-mirror/kimi-code"); @@ -10764,10 +10742,12 @@ mod tests { let variant_path = variant_dir.join("variant.json"); std::fs::write( &variant_path, - format!( - r#"{{"name":"kimi-code","provider":"kimi","configDir":"{}"}}"#, - config_dir.display() - ), + serde_json::json!({ + "name": "kimi-code", + "provider": "kimi", + "configDir": config_dir, + }) + .to_string(), ) .unwrap(); let session_path = project_dir.join("session.jsonl"); @@ -10789,10 +10769,12 @@ mod tests { std::fs::write( &variant_path, - format!( - r#"{{"name":"kimi-code","provider":"minimax","configDir":"{}"}}"#, - config_dir.display() - ), + serde_json::json!({ + "name": "kimi-code", + "provider": "minimax", + "configDir": config_dir, + }) + .to_string(), ) .unwrap(); @@ -10805,11 +10787,6 @@ mod tests { assert_eq!(refreshed_messages[0].client, "cc-mirror/kimi-code"); assert_eq!(refreshed_messages[0].provider_id, "minimax"); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] @@ -10817,8 +10794,10 @@ mod tests { fn test_parse_all_messages_keeps_normal_claude_when_cc_mirror_points_at_claude_config() { let cache_home = tempfile::TempDir::new().unwrap(); let source_home = tempfile::TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - std::env::set_var("HOME", cache_home.path()); + let _env = EnvGuard::set(&[ + ("HOME", cache_home.path().as_os_str()), + ("TOKSCALE_CONFIG_DIR", cache_home.path().as_os_str()), + ]); { let claude_dir = source_home.path().join(".claude"); @@ -10836,10 +10815,12 @@ mod tests { std::fs::create_dir_all(&variant_dir).unwrap(); std::fs::write( variant_dir.join("variant.json"), - format!( - r#"{{"name":"plain-mirror","provider":"mirror","configDir":"{}"}}"#, - claude_dir.display() - ), + serde_json::json!({ + "name": "plain-mirror", + "provider": "mirror", + "configDir": claude_dir, + }) + .to_string(), ) .unwrap(); @@ -10851,11 +10832,6 @@ mod tests { assert_eq!(messages.len(), 1); assert_eq!(messages[0].client, "claude"); } - - match original_home { - Some(home) => std::env::set_var("HOME", home), - None => std::env::remove_var("HOME"), - } } #[test] diff --git a/vendor/tokscale-core/src/message_cache.rs b/vendor/tokscale-core/src/message_cache.rs index 7b5e1ac2..e503e0d9 100644 --- a/vendor/tokscale-core/src/message_cache.rs +++ b/vendor/tokscale-core/src/message_cache.rs @@ -765,8 +765,12 @@ impl SourceMessageCache { .map_err(std::io::Error::other)?; writer.flush()?; writer.get_ref().sync_all()?; + drop(writer); crate::fs_atomic::replace_file(&tmp_path, &final_path)?; - let final_file = File::open(&final_path)?; + let final_file = OpenOptions::new() + .read(true) + .write(true) + .open(&final_path)?; final_file.sync_all()?; Ok(()) })(); @@ -1279,53 +1283,100 @@ mod tests { ); } - fn restore_env_var(key: &str, value: Option>) { - unsafe { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), + struct EnvGuard(Vec<(&'static str, Option)>); + + impl EnvGuard { + fn capture(keys: &[&'static str]) -> Self { + Self( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ) + } + + fn set(&mut self, key: &'static str, value: impl AsRef) { + unsafe { std::env::set_var(key, value) }; + } + + fn remove(&mut self, key: &'static str) { + unsafe { std::env::remove_var(key) }; + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + for (key, previous) in self.0.drain(..) { + match previous { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } } } } /// Pin every env var the cache resolvers consult so the test stays - /// inside `temp_home`. CI runners can leak `XDG_CONFIG_HOME` / - /// `XDG_CACHE_HOME` from the host, in which case `paths::get_cache_dir` - /// resolves outside the sandbox and the legacy fallback never gets - /// exercised. Returns the previous values so the caller can restore. - fn sandbox_cache_env( + /// inside `temp_home`, including on Windows where HOME/XDG do not control + /// the platform known folders. The override also keeps canonical cache + /// tests from reading or writing a real profile. + fn sandbox_cache_env(temp_home: &std::path::Path) -> EnvGuard { + let config_dir = temp_home.join(".config"); + let cache_dir = temp_home.join(".cache"); + let mut guard = EnvGuard::capture(&[ + "HOME", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "TOKSCALE_CONFIG_DIR", + ]); + guard.set("HOME", temp_home); + guard.set("XDG_CONFIG_HOME", &config_dir); + guard.set("XDG_CACHE_HOME", &cache_dir); + guard.set("TOKSCALE_CONFIG_DIR", temp_home); + guard + } + + /// Set legacy roots without the override for the non-Windows migration + /// tests. The returned guard restores every process environment variable + /// on normal return and during unwinding. + fn legacy_cache_env( temp_home: &std::path::Path, - ) -> ( - Option, - Option, - Option, - Option, - ) { - let prev_home = std::env::var_os("HOME"); - let prev_xdg_config = std::env::var_os("XDG_CONFIG_HOME"); - let prev_xdg_cache = std::env::var_os("XDG_CACHE_HOME"); - let prev_override = std::env::var_os("TOKSCALE_CONFIG_DIR"); - unsafe { - std::env::set_var("HOME", temp_home); - std::env::set_var("XDG_CONFIG_HOME", temp_home.join(".config")); - std::env::set_var("XDG_CACHE_HOME", temp_home.join(".cache")); - std::env::remove_var("TOKSCALE_CONFIG_DIR"); + xdg_cache: Option<&std::path::Path>, + ) -> EnvGuard { + let config_dir = temp_home.join(".config"); + let mut guard = EnvGuard::capture(&[ + "HOME", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "TOKSCALE_CONFIG_DIR", + ]); + guard.set("HOME", temp_home); + guard.set("XDG_CONFIG_HOME", &config_dir); + match xdg_cache { + Some(path) => guard.set("XDG_CACHE_HOME", path), + None => guard.remove("XDG_CACHE_HOME"), } - (prev_home, prev_xdg_config, prev_xdg_cache, prev_override) + guard.remove("TOKSCALE_CONFIG_DIR"); + guard + } + + fn restore_cache_env(guard: EnvGuard) { + drop(guard); } - fn restore_cache_env( - prev: ( - Option, - Option, - Option, - Option, - ), - ) { - restore_env_var("HOME", prev.0); - restore_env_var("XDG_CONFIG_HOME", prev.1); - restore_env_var("XDG_CACHE_HOME", prev.2); - restore_env_var("TOKSCALE_CONFIG_DIR", prev.3); + #[test] + #[serial_test::serial] + fn test_env_guard_restores_after_unwind() { + const KEY: &str = "TOKSCALE_MESSAGE_CACHE_ENV_GUARD_SELF_CHECK"; + let mut outer = EnvGuard::capture(&[KEY]); + outer.set(KEY, "before"); + let result = std::panic::catch_unwind(|| { + let mut inner = EnvGuard::capture(&[KEY]); + inner.set(KEY, "during"); + panic!("exercise EnvGuard unwinding"); + }); + assert!(result.is_err()); + assert_eq!(std::env::var_os(KEY), Some("before".into())); } fn write_temp_file(content: &[u8]) -> NamedTempFile { @@ -1359,7 +1410,14 @@ mod tests { let file = write_temp_file(b"aaaa\nbbbb\ncccc\n"); let before = SourceFingerprint::from_path(file.path()).unwrap(); - std::fs::write(file.path(), b"aaaa\nzzzz\ncccc\n").unwrap(); + // Windows can retain the same timestamp for a fast same-size rewrite; + // use a changed-length fixture there instead of sleeping or changing + // the production fingerprint memo semantics. + #[cfg(not(target_os = "windows"))] + let rewritten = b"aaaa\nzzzz\ncccc\n"; + #[cfg(target_os = "windows")] + let rewritten = b"aaaa\nzzzz\ncccc\nchanged-length\n"; + std::fs::write(file.path(), rewritten).unwrap(); let after = SourceFingerprint::from_path(file.path()).unwrap(); assert_ne!(before, after); @@ -1374,6 +1432,11 @@ mod tests { let mut rewritten = original.clone(); rewritten[73 * 1024] = b'z'; + // Keep the unsampled same-size rewrite on Unix. Windows filesystem + // timestamp precision can memoize a rapid rewrite, so make its + // fixture length-changing without adding a sleep. + #[cfg(target_os = "windows")] + rewritten.extend_from_slice(b"changed-length\n"); std::fs::write(file.path(), &rewritten).unwrap(); let after = SourceFingerprint::from_path(file.path()).unwrap(); @@ -1393,7 +1456,7 @@ mod tests { let with_wal = SourceFingerprint::from_sqlite_path(&db_path).unwrap(); assert_ne!(base, with_wal); - std::fs::write(&wal_path, b"wal-2").unwrap(); + std::fs::write(&wal_path, b"wal-2-changed-length").unwrap(); let updated_wal = SourceFingerprint::from_sqlite_path(&db_path).unwrap(); assert_ne!(with_wal, updated_wal); @@ -1435,7 +1498,7 @@ mod tests { SourceFingerprint::from_claude_code_path_with_home(&sidechain_path, None).unwrap(); assert_ne!(base, with_parent); - std::fs::write(&parent_path, b"parent transcript 2\n").unwrap(); + std::fs::write(&parent_path, b"parent transcript 2 with changed length\n").unwrap(); let updated_parent = SourceFingerprint::from_claude_code_path_with_home(&sidechain_path, None).unwrap(); assert_ne!(with_parent, updated_parent); @@ -1468,7 +1531,7 @@ mod tests { SourceFingerprint::from_claude_code_path_with_home(&sidechain_path, None).unwrap(); assert_ne!(base, with_parent); - std::fs::write(&parent_path, b"flat parent 2\n").unwrap(); + std::fs::write(&parent_path, b"flat parent 2 with changed length\n").unwrap(); let updated_parent = SourceFingerprint::from_claude_code_path_with_home(&sidechain_path, None).unwrap(); assert_ne!(with_parent, updated_parent); @@ -1531,10 +1594,12 @@ mod tests { let variant_path = variant_dir.join("variant.json"); std::fs::write( &variant_path, - format!( - r#"{{"name":"kimi-code","provider":"kimi","configDir":"{}"}}"#, - config_dir.display() - ), + serde_json::json!({ + "name": "kimi-code", + "provider": "kimi", + "configDir": config_dir, + }) + .to_string(), ) .unwrap(); let with_kimi = @@ -1542,10 +1607,12 @@ mod tests { std::fs::write( &variant_path, - format!( - r#"{{"name":"kimi-code","provider":"minimax","configDir":"{}"}}"#, - config_dir.display() - ), + serde_json::json!({ + "name": "kimi-code", + "provider": "minimax", + "configDir": config_dir, + }) + .to_string(), ) .unwrap(); let with_minimax = @@ -1571,10 +1638,12 @@ mod tests { let variant_path = variant_dir.join("variant.json"); std::fs::write( &variant_path, - format!( - r#"{{"name":"kimi-code","provider":"kimi","configDir":"{}"}}"#, - config_dir.display() - ), + serde_json::json!({ + "name": "kimi-code", + "provider": "kimi", + "configDir": config_dir, + }) + .to_string(), ) .unwrap(); let with_kimi = @@ -1583,10 +1652,12 @@ mod tests { std::fs::write( &variant_path, - format!( - r#"{{"name":"kimi-code","provider":"minimax","configDir":"{}"}}"#, - config_dir.display() - ), + serde_json::json!({ + "name": "kimi-code", + "provider": "minimax", + "configDir": config_dir, + }) + .to_string(), ) .unwrap(); let with_minimax = @@ -1714,20 +1785,15 @@ mod tests { #[serial_test::serial] fn test_load_ignores_oversized_cache_file() { let temp_home = TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - restore_env_var("HOME", Some(temp_home.path())); + let _env = sandbox_cache_env(temp_home.path()); - { - let cache_file = cache_path().unwrap(); - ensure_cache_dir(cache_file.parent().unwrap()).unwrap(); - let file = File::create(&cache_file).unwrap(); - file.set_len(MAX_CACHE_FILE_BYTES + 1).unwrap(); + let cache_file = cache_path().unwrap(); + ensure_cache_dir(cache_file.parent().unwrap()).unwrap(); + let file = File::create(&cache_file).unwrap(); + file.set_len(MAX_CACHE_FILE_BYTES + 1).unwrap(); - let loaded = SourceMessageCache::load(); - assert!(loaded.entries.is_empty()); - } - - restore_env_var("HOME", original_home); + let loaded = SourceMessageCache::load(); + assert!(loaded.entries.is_empty()); } #[test] @@ -2303,86 +2369,72 @@ mod tests { #[serial_test::serial] fn test_fallback_cache_dir_prefers_runtime_dir() { let runtime_dir = TempDir::new().unwrap(); - let original_xdg_runtime_dir = std::env::var("XDG_RUNTIME_DIR").ok(); - restore_env_var("XDG_RUNTIME_DIR", Some(runtime_dir.path())); - - { - assert_eq!( - fallback_cache_dir(), - Some(runtime_dir.path().join("tokscale")) - ); - } + let mut _env = EnvGuard::capture(&["XDG_RUNTIME_DIR"]); + _env.set("XDG_RUNTIME_DIR", runtime_dir.path()); - restore_env_var("XDG_RUNTIME_DIR", original_xdg_runtime_dir); + assert_eq!( + fallback_cache_dir(), + Some(runtime_dir.path().join("tokscale")) + ); } #[test] #[serial_test::serial] fn test_save_if_dirty_marks_cache_clean() { let temp_home = TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - restore_env_var("HOME", Some(temp_home.path())); + let _env = sandbox_cache_env(temp_home.path()); let mut cache = SourceMessageCache::default(); assert!(!cache.dirty); - { - let file = write_temp_file(b"{}\n"); - let fingerprint = SourceFingerprint::from_path(file.path()).unwrap(); - cache.insert(CachedSourceEntry::new( - file.path(), - fingerprint, - Vec::new(), - Vec::new(), - None, - )); - assert!(cache.dirty); - - cache.save_if_dirty(); - assert!(!cache.dirty); - } + let file = write_temp_file(b"{}\n"); + let fingerprint = SourceFingerprint::from_path(file.path()).unwrap(); + cache.insert(CachedSourceEntry::new( + file.path(), + fingerprint, + Vec::new(), + Vec::new(), + None, + )); + assert!(cache.dirty); - restore_env_var("HOME", original_home); + cache.save_if_dirty(); + assert!(!cache.dirty); } #[test] #[serial_test::serial] fn test_save_if_dirty_merges_concurrent_writers() { let temp_home = TempDir::new().unwrap(); - let original_home = std::env::var("HOME").ok(); - restore_env_var("HOME", Some(temp_home.path())); + let _env = sandbox_cache_env(temp_home.path()); - { - let file_one = write_temp_file(b"{\"id\":1}\n"); - let file_two = write_temp_file(b"{\"id\":2}\n"); + let file_one = write_temp_file(b"{\"id\":1}\n"); + let file_two = write_temp_file(b"{\"id\":2}\n"); - let mut writer_one = SourceMessageCache::load(); - let mut writer_two = SourceMessageCache::load(); + let mut writer_one = SourceMessageCache::load(); + let mut writer_two = SourceMessageCache::load(); - writer_one.insert(CachedSourceEntry::new( - file_one.path(), - SourceFingerprint::from_path(file_one.path()).unwrap(), - Vec::new(), - Vec::new(), - None, - )); - writer_two.insert(CachedSourceEntry::new( - file_two.path(), - SourceFingerprint::from_path(file_two.path()).unwrap(), - Vec::new(), - Vec::new(), - None, - )); - - writer_one.save_if_dirty(); - writer_two.save_if_dirty(); + writer_one.insert(CachedSourceEntry::new( + file_one.path(), + SourceFingerprint::from_path(file_one.path()).unwrap(), + Vec::new(), + Vec::new(), + None, + )); + writer_two.insert(CachedSourceEntry::new( + file_two.path(), + SourceFingerprint::from_path(file_two.path()).unwrap(), + Vec::new(), + Vec::new(), + None, + )); - let loaded = SourceMessageCache::load(); - assert!(loaded.get(file_one.path()).is_some()); - assert!(loaded.get(file_two.path()).is_some()); - } + writer_one.save_if_dirty(); + writer_two.save_if_dirty(); - restore_env_var("HOME", original_home); + let loaded = SourceMessageCache::load(); + assert!(loaded.get(file_one.path()).is_some()); + assert!(loaded.get(file_two.path()).is_some()); } #[test] @@ -2463,18 +2515,11 @@ mod tests { #[test] #[serial_test::serial] + #[cfg(not(target_os = "windows"))] fn load_falls_back_to_legacy_dirs_cache_path() { let temp_home = TempDir::new().unwrap(); let temp_xdg_cache = TempDir::new().unwrap(); - let original_home = std::env::var_os("HOME"); - let original_xdg_cache = std::env::var_os("XDG_CACHE_HOME"); - let original_xdg_config = std::env::var_os("XDG_CONFIG_HOME"); - let original_override = std::env::var_os("TOKSCALE_CONFIG_DIR"); - - restore_env_var("HOME", Some(temp_home.path())); - restore_env_var("XDG_CACHE_HOME", Some(temp_xdg_cache.path())); - restore_env_var("XDG_CONFIG_HOME", Some(temp_home.path().join(".config"))); - restore_env_var("TOKSCALE_CONFIG_DIR", None::<&str>); + let _env = legacy_cache_env(temp_home.path(), Some(temp_xdg_cache.path())); let source = write_temp_file(b"legacy-dirs\n"); let entry = CachedSourceEntry::new( @@ -2498,26 +2543,14 @@ mod tests { let loaded = SourceMessageCache::load(); assert!(loaded.get(source.path()).is_some()); - - restore_env_var("HOME", original_home); - restore_env_var("XDG_CACHE_HOME", original_xdg_cache); - restore_env_var("XDG_CONFIG_HOME", original_xdg_config); - restore_env_var("TOKSCALE_CONFIG_DIR", original_override); } #[test] #[serial_test::serial] + #[cfg(not(target_os = "windows"))] fn load_falls_back_to_legacy_dot_cache_path() { let temp_home = TempDir::new().unwrap(); - let original_home = std::env::var_os("HOME"); - let original_xdg_cache = std::env::var_os("XDG_CACHE_HOME"); - let original_xdg_config = std::env::var_os("XDG_CONFIG_HOME"); - let original_override = std::env::var_os("TOKSCALE_CONFIG_DIR"); - - restore_env_var("HOME", Some(temp_home.path())); - restore_env_var("XDG_CACHE_HOME", None::<&str>); - restore_env_var("XDG_CONFIG_HOME", Some(temp_home.path().join(".config"))); - restore_env_var("TOKSCALE_CONFIG_DIR", None::<&str>); + let _env = legacy_cache_env(temp_home.path(), None); let source = write_temp_file(b"legacy-dot\n"); let entry = CachedSourceEntry::new( @@ -2541,11 +2574,35 @@ mod tests { let loaded = SourceMessageCache::load(); assert!(loaded.get(source.path()).is_some()); + } + + #[cfg(windows)] + #[test] + #[serial_test::serial] + fn legacy_cache_paths_are_ordered_and_override_gated_without_io() { + let temp_home = TempDir::new().unwrap(); + let _env = legacy_cache_env(temp_home.path(), None); + let candidates = legacy_cache_paths(); + assert_eq!(candidates.len(), 2); + assert_eq!( + candidates[0], + dirs::cache_dir() + .expect("Windows exposes a cache directory") + .join("tokscale") + .join(CACHE_FILENAME) + ); + assert_eq!( + candidates[1], + dirs::home_dir() + .expect("Windows exposes a home directory") + .join(".cache") + .join("tokscale") + .join(CACHE_FILENAME) + ); - restore_env_var("HOME", original_home); - restore_env_var("XDG_CACHE_HOME", original_xdg_cache); - restore_env_var("XDG_CONFIG_HOME", original_xdg_config); - restore_env_var("TOKSCALE_CONFIG_DIR", original_override); + let mut override_env = EnvGuard::capture(&["TOKSCALE_CONFIG_DIR"]); + override_env.set("TOKSCALE_CONFIG_DIR", temp_home.path()); + assert!(legacy_cache_paths().is_empty()); } #[cfg(unix)] diff --git a/vendor/tokscale-core/src/pricing/cache.rs b/vendor/tokscale-core/src/pricing/cache.rs index c03b5dce..03916816 100644 --- a/vendor/tokscale-core/src/pricing/cache.rs +++ b/vendor/tokscale-core/src/pricing/cache.rs @@ -138,38 +138,57 @@ mod tests { use super::*; use serial_test::serial; use std::env; + #[cfg(not(target_os = "windows"))] use tempfile::TempDir; - fn restore_env_var(key: &str, value: Option) { - unsafe { - match value { - Some(value) => env::set_var(key, value), - None => env::remove_var(key), + struct EnvGuard(Vec<(&'static str, Option)>); + + impl EnvGuard { + fn capture(keys: &[&'static str]) -> Self { + Self(keys.iter().map(|key| (*key, env::var_os(key))).collect()) + } + + fn set(&mut self, key: &'static str, value: impl AsRef) { + unsafe { env::set_var(key, value) }; + } + + fn remove(&mut self, key: &'static str) { + unsafe { env::remove_var(key) }; + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + for (key, previous) in self.0.drain(..) { + match previous { + Some(value) => env::set_var(key, value), + None => env::remove_var(key), + } + } } } } #[test] #[serial] + #[cfg(not(target_os = "windows"))] fn load_falls_back_to_legacy_dirs_cache_path() { let temp_home = TempDir::new().unwrap(); let temp_xdg_cache = TempDir::new().unwrap(); - let previous_home = env::var_os("HOME"); - let previous_xdg_cache = env::var_os("XDG_CACHE_HOME"); - let previous_xdg_config = env::var_os("XDG_CONFIG_HOME"); - let previous_override = env::var_os("TOKSCALE_CONFIG_DIR"); - unsafe { - env::set_var("HOME", temp_home.path()); - env::set_var("XDG_CACHE_HOME", temp_xdg_cache.path()); - // Pin XDG_CONFIG_HOME so paths::get_cache_dir() stays inside - // the sandboxed HOME on Linux CI runners that set this var - // globally — without the pin, the canonical path resolves - // outside the temp dir and the legacy fallback never gets - // exercised because the binary never tries the right legacy - // root either. - env::set_var("XDG_CONFIG_HOME", temp_home.path().join(".config")); - env::remove_var("TOKSCALE_CONFIG_DIR"); - } + let config_dir = temp_home.path().join(".config"); + let mut _env = EnvGuard::capture(&[ + "HOME", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "TOKSCALE_CONFIG_DIR", + ]); + _env.set("HOME", temp_home.path()); + _env.set("XDG_CACHE_HOME", temp_xdg_cache.path()); + // Pin XDG_CONFIG_HOME so paths::get_cache_dir() stays inside + // the sandboxed HOME on Linux CI runners that set this var globally. + _env.set("XDG_CONFIG_HOME", &config_dir); + _env.remove("TOKSCALE_CONFIG_DIR"); let legacy_path = crate::paths::legacy_dirs_cache_dir() .unwrap() @@ -187,10 +206,53 @@ mod tests { let loaded: Option = load_cache("pricing-litellm.json"); assert_eq!(loaded.unwrap()["ok"], serde_json::json!(true)); + } + + #[cfg(windows)] + #[test] + #[serial] + fn legacy_cache_paths_are_ordered_and_override_gated_without_io() { + let mut _env = EnvGuard::capture(&[ + "HOME", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "TOKSCALE_CONFIG_DIR", + ]); + _env.remove("TOKSCALE_CONFIG_DIR"); + let candidates = legacy_cache_paths("pricing-litellm.json"); + assert_eq!(candidates.len(), 2); + assert_eq!( + candidates[0], + dirs::cache_dir() + .expect("Windows exposes a cache directory") + .join("tokscale") + .join("pricing-litellm.json") + ); + assert_eq!( + candidates[1], + dirs::home_dir() + .expect("Windows exposes a home directory") + .join(".cache") + .join("tokscale") + .join("pricing-litellm.json") + ); - restore_env_var("HOME", previous_home); - restore_env_var("XDG_CACHE_HOME", previous_xdg_cache); - restore_env_var("XDG_CONFIG_HOME", previous_xdg_config); - restore_env_var("TOKSCALE_CONFIG_DIR", previous_override); + _env.set("TOKSCALE_CONFIG_DIR", std::env::temp_dir()); + assert!(legacy_cache_paths("pricing-litellm.json").is_empty()); + } + + #[test] + #[serial] + fn env_guard_restores_after_unwind() { + const KEY: &str = "TOKSCALE_PRICING_CACHE_ENV_GUARD_SELF_CHECK"; + let mut outer = EnvGuard::capture(&[KEY]); + outer.set(KEY, "before"); + let result = std::panic::catch_unwind(|| { + let mut inner = EnvGuard::capture(&[KEY]); + inner.set(KEY, "during"); + panic!("exercise EnvGuard unwinding"); + }); + assert!(result.is_err()); + assert_eq!(env::var_os(KEY), Some("before".into())); } } diff --git a/vendor/tokscale-core/src/scanner.rs b/vendor/tokscale-core/src/scanner.rs index bacf29f8..a0811b5b 100644 --- a/vendor/tokscale-core/src/scanner.rs +++ b/vendor/tokscale-core/src/scanner.rs @@ -12,18 +12,16 @@ use crate::sessions::{normalize_workspace_key, workspace_label_from_key}; use serde::{Deserialize, Serialize}; use serde_json::Value; -/// Emit a one-time `tracing::warn!` if `path` does not start with the user's -/// home directory. The scan is NOT blocked — this is a heads-up only. -fn warn_if_escapes_home(client_id: ClientId, path: &Path) { - if let Some(home) = dirs::home_dir() { - if !path.starts_with(&home) { - tracing::warn!( - client = client_id.as_str(), - path = %path.display(), - home = %home.display(), - "extra scan path is outside $HOME — verify this is intentional" - ); - } +/// Emit a one-time `tracing::warn!` if `path` does not start with the scan's +/// supplied home directory. The scan is NOT blocked — this is a heads-up only. +fn warn_if_escapes_home(home: &Path, client_id: ClientId, path: &Path) { + if !path.starts_with(home) { + tracing::warn!( + client = client_id.as_str(), + path = %path.display(), + home = %home.display(), + "extra scan path is outside $HOME — verify this is intentional" + ); } } @@ -817,7 +815,7 @@ fn scan_all_clients_with_env_strategy_inner( } for (client_id, path) in extra_scan_paths_for(scanner_settings, &enabled) { - warn_if_escapes_home(client_id, &path); + warn_if_escapes_home(Path::new(home_dir), client_id, &path); push_unique_scan_task(&mut tasks, &mut seen_scan_roots, client_id, path); } @@ -830,7 +828,7 @@ fn scan_all_clients_with_env_strategy_inner( if use_env_roots { let extra_dirs_val = std::env::var("TOKSCALE_EXTRA_DIRS").unwrap_or_default(); for (client_id, path) in parse_extra_dirs(&extra_dirs_val, &enabled) { - warn_if_escapes_home(client_id, &PathBuf::from(&path)); + warn_if_escapes_home(Path::new(home_dir), client_id, &PathBuf::from(&path)); push_unique_scan_task(&mut tasks, &mut seen_scan_roots, client_id, path); } } @@ -1125,7 +1123,7 @@ fn scan_all_clients_with_env_strategy_inner( } } #[cfg(target_os = "windows")] - if result.zed_db.is_none() { + if use_env_roots && result.zed_db.is_none() { if let Some(local_app_data) = dirs::data_local_dir() { let windows_path = local_app_data.join("Zed/threads/threads.db"); if windows_path.is_file() { @@ -1233,15 +1231,87 @@ mod tests { use std::io::Write; use tempfile::TempDir; - fn restore_env(var: &str, previous: Option) { - match previous { - Some(value) => unsafe { std::env::set_var(var, value) }, - None => unsafe { std::env::remove_var(var) }, + struct EnvGuard(Vec<(&'static str, Option)>); + + impl EnvGuard { + fn capture(keys: &[&'static str]) -> Self { + Self( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ) + } + + fn set(&mut self, key: &'static str, value: impl AsRef) { + unsafe { std::env::set_var(key, value) }; + } + + fn remove(&mut self, key: &'static str) { + unsafe { std::env::remove_var(key) }; + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + for (key, previous) in self.0.drain(..) { + match previous { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } } } - fn restore_current_dir(previous: &Path) { - std::env::set_current_dir(previous).unwrap(); + fn scan_without_extra_dirs(home_dir: &str, clients: &[String]) -> ScanResult { + let mut extra = EnvGuard::capture(&["TOKSCALE_EXTRA_DIRS"]); + extra.remove("TOKSCALE_EXTRA_DIRS"); + scan_all_clients(home_dir, clients) + } + + struct CwdGuard(PathBuf); + + impl CwdGuard { + fn change(path: &Path) -> Self { + let previous = std::env::current_dir().unwrap(); + std::env::set_current_dir(path).unwrap(); + Self(previous) + } + } + + impl Drop for CwdGuard { + fn drop(&mut self) { + std::env::set_current_dir(&self.0).unwrap(); + } + } + + #[test] + #[serial] + fn test_env_guard_restores_after_unwind() { + const KEY: &str = "TOKSCALE_SCANNER_ENV_GUARD_SELF_CHECK"; + let mut outer = EnvGuard::capture(&[KEY]); + outer.set(KEY, "before"); + let result = std::panic::catch_unwind(|| { + let mut inner = EnvGuard::capture(&[KEY]); + inner.set(KEY, "during"); + panic!("exercise EnvGuard unwinding"); + }); + assert!(result.is_err()); + assert_eq!(std::env::var_os(KEY), Some("before".into())); + } + + #[test] + #[serial] + fn test_cwd_guard_restores_after_unwind() { + let original = std::env::current_dir().unwrap(); + let target = TempDir::new().unwrap(); + let result = std::panic::catch_unwind(|| { + let _guard = CwdGuard::change(target.path()); + panic!("exercise CwdGuard unwinding"); + }); + assert!(result.is_err()); + assert_eq!(std::env::current_dir().unwrap(), original); } fn setup_mock_copilot_dir(home: &Path) { @@ -1645,8 +1715,8 @@ mod tests { #[test] #[serial] fn test_headless_roots_default() { - let previous = std::env::var("TOKSCALE_HEADLESS_DIR").ok(); - unsafe { std::env::remove_var("TOKSCALE_HEADLESS_DIR") }; + let mut _env = EnvGuard::capture(&["TOKSCALE_HEADLESS_DIR"]); + _env.remove("TOKSCALE_HEADLESS_DIR"); let home = "/tmp/tokscale-test-home"; let roots = headless_roots(home); @@ -1659,27 +1729,23 @@ mod tests { assert_eq!(roots.len(), 2); assert!(roots.contains(&config_root)); assert!(roots.contains(&mac_root)); - - restore_env("TOKSCALE_HEADLESS_DIR", previous); } #[test] #[serial] fn test_headless_roots_override() { - let previous = std::env::var("TOKSCALE_HEADLESS_DIR").ok(); - unsafe { std::env::set_var("TOKSCALE_HEADLESS_DIR", "/custom/headless") }; + let mut _env = EnvGuard::capture(&["TOKSCALE_HEADLESS_DIR"]); + _env.set("TOKSCALE_HEADLESS_DIR", "/custom/headless"); let roots = headless_roots("/tmp/home"); assert_eq!(roots, vec![PathBuf::from("/custom/headless")]); - - restore_env("TOKSCALE_HEADLESS_DIR", previous); } #[test] #[serial] fn test_headless_roots_ignore_env_override_when_disabled() { - let previous = std::env::var("TOKSCALE_HEADLESS_DIR").ok(); - unsafe { std::env::set_var("TOKSCALE_HEADLESS_DIR", "/custom/headless") }; + let mut _env = EnvGuard::capture(&["TOKSCALE_HEADLESS_DIR"]); + _env.set("TOKSCALE_HEADLESS_DIR", "/custom/headless"); let roots = headless_roots_with_env_strategy("/tmp/home", false); assert_eq!( @@ -1689,35 +1755,31 @@ mod tests { PathBuf::from("/tmp/home/Library/Application Support/tokscale/headless") ] ); - - restore_env("TOKSCALE_HEADLESS_DIR", previous); } #[test] #[serial] fn test_scan_all_clients_opencode() { - let previous_xdg = std::env::var("XDG_DATA_HOME").ok(); + let mut _xdg = EnvGuard::capture(&["XDG_DATA_HOME"]); let dir = TempDir::new().unwrap(); let home = dir.path(); setup_mock_opencode_dir(home); // Set XDG_DATA_HOME for the test - unsafe { std::env::set_var("XDG_DATA_HOME", home.join(".local/share")) }; + _xdg.set("XDG_DATA_HOME", home.join(".local/share")); - let result = scan_all_clients(home.to_str().unwrap(), &["opencode".to_string()]); + let result = scan_without_extra_dirs(home.to_str().unwrap(), &["opencode".to_string()]); assert_eq!(result.get(ClientId::OpenCode).len(), 1); assert!(result.get(ClientId::Claude).is_empty()); assert!(result.get(ClientId::Codex).is_empty()); assert!(result.get(ClientId::Gemini).is_empty()); - - restore_env("XDG_DATA_HOME", previous_xdg); } #[test] #[serial] fn test_scan_all_clients_opencode_home_override_ignores_xdg_env() { - let previous_xdg = std::env::var("XDG_DATA_HOME").ok(); + let mut _xdg = EnvGuard::capture(&["XDG_DATA_HOME"]); let dir = TempDir::new().unwrap(); let home = dir.path().join("target-home"); @@ -1725,7 +1787,7 @@ mod tests { setup_mock_opencode_dir(&home); fs::create_dir_all(&conflicting_xdg).unwrap(); - unsafe { std::env::set_var("XDG_DATA_HOME", &conflicting_xdg) }; + _xdg.set("XDG_DATA_HOME", &conflicting_xdg); let result = scan_all_clients_with_env_strategy( home.to_str().unwrap(), @@ -1737,8 +1799,6 @@ mod tests { result.opencode_json_dir, Some(home.join(".local/share/opencode/storage/message")) ); - - restore_env("XDG_DATA_HOME", previous_xdg); } #[test] @@ -1910,8 +1970,6 @@ mod tests { #[test] #[serial] fn test_scan_all_clients_with_scanner_settings_merges_user_path() { - let previous_xdg = std::env::var("XDG_DATA_HOME").ok(); - let dir = TempDir::new().unwrap(); let home = dir.path(); // Auto-discoverable channel db inside XDG data dir. @@ -1926,8 +1984,6 @@ mod tests { let outside_db = outside_dir.join("opencode.db"); File::create(&outside_db).unwrap(); - unsafe { std::env::set_var("XDG_DATA_HOME", home.join(".local/share")) }; - let settings = ScannerSettings { opencode_db_paths: vec![outside_db.clone()], ..Default::default() @@ -1935,7 +1991,7 @@ mod tests { let result = scan_all_clients_with_scanner_settings( home.to_str().unwrap(), &["opencode".to_string()], - true, + false, &settings, ); @@ -1956,8 +2012,6 @@ mod tests { outside_db.display(), result.opencode_dbs ); - - restore_env("XDG_DATA_HOME", previous_xdg); } #[test] @@ -1984,7 +2038,7 @@ mod tests { let result = scan_all_clients_with_scanner_settings( home.to_str().unwrap(), &["codex".to_string()], - true, + false, &settings, ); @@ -2090,7 +2144,8 @@ mod tests { #[test] #[serial] fn test_scan_all_clients_auto_discovers_profiles_under_hermes_home() { - let previous = std::env::var("HERMES_HOME").ok(); + let mut _hermes = EnvGuard::capture(&["HERMES_HOME", "TOKSCALE_EXTRA_DIRS"]); + _hermes.remove("TOKSCALE_EXTRA_DIRS"); let dir = TempDir::new().unwrap(); let home = dir.path(); let hermes_home = home.join("custom-hermes-home"); @@ -2104,14 +2159,13 @@ mod tests { let profile_db = profile_dir.join("state.db"); File::create(&profile_db).unwrap(); - unsafe { std::env::set_var("HERMES_HOME", &hermes_home) }; + _hermes.set("HERMES_HOME", &hermes_home); let result = scan_all_clients_with_scanner_settings( home.to_str().unwrap(), &["hermes".to_string()], true, &ScannerSettings::default(), ); - restore_env("HERMES_HOME", previous); assert_eq!(result.hermes_db.as_ref(), Some(&default_db)); assert_eq!(result.hermes_db_paths(), vec![default_db, profile_db]); @@ -2120,7 +2174,8 @@ mod tests { #[test] #[serial] fn test_profile_scoped_hermes_home_isolates_to_own_profile() { - let previous = std::env::var("HERMES_HOME").ok(); + let mut _hermes = EnvGuard::capture(&["HERMES_HOME", "TOKSCALE_EXTRA_DIRS"]); + _hermes.remove("TOKSCALE_EXTRA_DIRS"); let dir = TempDir::new().unwrap(); let home = dir.path(); @@ -2143,14 +2198,13 @@ mod tests { fs::create_dir_all(&nested_dir).unwrap(); File::create(nested_dir.join("state.db")).unwrap(); - unsafe { std::env::set_var("HERMES_HOME", &coder_dir) }; + _hermes.set("HERMES_HOME", &coder_dir); let result = scan_all_clients_with_scanner_settings( home.to_str().unwrap(), &["hermes".to_string()], true, &ScannerSettings::default(), ); - restore_env("HERMES_HOME", previous); assert_eq!(result.hermes_db.as_ref(), Some(&coder_db)); assert_eq!(result.hermes_db_paths(), vec![coder_db]); @@ -2162,7 +2216,8 @@ mod tests { #[test] #[serial] fn test_symlinked_profile_scoped_hermes_home_preserves_isolation() { - let previous = std::env::var("HERMES_HOME").ok(); + let mut _hermes = EnvGuard::capture(&["HERMES_HOME", "TOKSCALE_EXTRA_DIRS"]); + _hermes.remove("TOKSCALE_EXTRA_DIRS"); let dir = TempDir::new().unwrap(); let home = dir.path(); @@ -2187,14 +2242,13 @@ mod tests { std::os::unix::fs::symlink(&coder_dir, &profile_alias).unwrap(); let aliased_db = profile_alias.join("state.db"); - unsafe { std::env::set_var("HERMES_HOME", &profile_alias) }; + _hermes.set("HERMES_HOME", &profile_alias); let result = scan_all_clients_with_scanner_settings( home.to_str().unwrap(), &["hermes".to_string()], true, &ScannerSettings::default(), ); - restore_env("HERMES_HOME", previous); assert_eq!(result.hermes_db.as_ref(), Some(&aliased_db)); assert_eq!(result.hermes_db_paths(), vec![aliased_db]); @@ -2266,9 +2320,10 @@ mod tests { #[test] #[serial] fn test_scan_all_clients_with_scanner_settings_dedups_settings_and_env_extra_paths() { - let previous = std::env::var("TOKSCALE_EXTRA_DIRS").ok(); + let mut _extra = EnvGuard::capture(&["TOKSCALE_EXTRA_DIRS", "CODEX_HOME"]); let dir = TempDir::new().unwrap(); let home = dir.path(); + _extra.set("CODEX_HOME", home.join(".codex")); let default_root = home.join(".codex/sessions"); fs::create_dir_all(&default_root).unwrap(); @@ -2278,12 +2333,10 @@ mod tests { fs::create_dir_all(&extra_root).unwrap(); File::create(extra_root.join("extra.jsonl")).unwrap(); - unsafe { - std::env::set_var( + _extra.set( "TOKSCALE_EXTRA_DIRS", format!("codex:{}", extra_root.join("..").join("sessions").display()), - ) - }; + ); let settings: ScannerSettings = serde_json::from_value(serde_json::json!({ "extraScanPaths": { @@ -2300,7 +2353,6 @@ mod tests { ); assert_eq!(result.get(ClientId::Codex).len(), 2); - restore_env("TOKSCALE_EXTRA_DIRS", previous); } #[test] @@ -2319,8 +2371,6 @@ mod tests { // 2. ["opencode"] → both auto + user-configured dbs present // 3. ["synthetic"] → both present (synthetic enables all) // 4. [] → both present (empty filter = all clients) - let previous_xdg = std::env::var("XDG_DATA_HOME").ok(); - let dir = TempDir::new().unwrap(); let home = dir.path(); @@ -2337,8 +2387,6 @@ mod tests { let outside_db = outside_dir.join("opencode.db"); File::create(&outside_db).unwrap(); - unsafe { std::env::set_var("XDG_DATA_HOME", home.join(".local/share")) }; - let settings = ScannerSettings { opencode_db_paths: vec![outside_db.clone()], ..Default::default() @@ -2346,7 +2394,7 @@ mod tests { let scan = |clients: &[&str]| { let owned: Vec = clients.iter().map(|s| s.to_string()).collect(); - scan_all_clients_with_scanner_settings(home.to_str().unwrap(), &owned, true, &settings) + scan_all_clients_with_scanner_settings(home.to_str().unwrap(), &owned, false, &settings) }; // 1. clients=["claude"] — OpenCode disabled, dbs must stay empty. @@ -2399,14 +2447,12 @@ mod tests { "empty client filter must merge user-configured paths, got {:?}", all_clients.opencode_dbs ); - - restore_env("XDG_DATA_HOME", previous_xdg); } #[test] #[serial] fn test_scan_all_clients_opencode_picks_up_channel_suffixed_dbs() { - let previous_xdg = std::env::var("XDG_DATA_HOME").ok(); + let mut _xdg = EnvGuard::capture(&["XDG_DATA_HOME"]); let dir = TempDir::new().unwrap(); let home = dir.path(); @@ -2420,9 +2466,9 @@ mod tests { File::create(data_dir.join("opencode.db-wal")).unwrap(); File::create(data_dir.join("opencode-stable.db-shm")).unwrap(); - unsafe { std::env::set_var("XDG_DATA_HOME", home.join(".local/share")) }; + _xdg.set("XDG_DATA_HOME", home.join(".local/share")); - let result = scan_all_clients(home.to_str().unwrap(), &["opencode".to_string()]); + let result = scan_without_extra_dirs(home.to_str().unwrap(), &["opencode".to_string()]); let names: Vec = result .opencode_dbs @@ -2438,8 +2484,6 @@ mod tests { ], "expected all channel dbs, got {names:?}" ); - - restore_env("XDG_DATA_HOME", previous_xdg); } #[test] @@ -2448,7 +2492,11 @@ mod tests { let home = dir.path(); setup_mock_pi_dir(home); - let result = scan_all_clients(home.to_str().unwrap(), &["pi".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["pi".to_string()], + false, + ); assert_eq!(result.get(ClientId::Pi).len(), 1); assert!(result.get(ClientId::OpenCode).is_empty()); assert!(result.get(ClientId::Claude).is_empty()); @@ -2460,7 +2508,11 @@ mod tests { let home = dir.path(); setup_mock_omp_dir(home); - let result = scan_all_clients(home.to_str().unwrap(), &["pi".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["pi".to_string()], + false, + ); assert_eq!(result.get(ClientId::Pi).len(), 1); assert!(result.get(ClientId::Pi)[0].ends_with("2026-04-06T03-04-28Z_omp_ses_001.jsonl")); assert!(result.get(ClientId::OpenCode).is_empty()); @@ -2473,41 +2525,43 @@ mod tests { setup_mock_pi_dir(home); setup_mock_omp_dir(home); - let result = scan_all_clients(home.to_str().unwrap(), &["pi".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["pi".to_string()], + false, + ); assert_eq!(result.get(ClientId::Pi).len(), 2); } #[test] #[serial] fn test_scan_all_clients_zed_xdg_db() { - let previous_xdg = std::env::var("XDG_DATA_HOME").ok(); + let mut _xdg = EnvGuard::capture(&["XDG_DATA_HOME"]); let dir = TempDir::new().unwrap(); let home = dir.path(); let zed_db = setup_mock_zed_xdg_db(home); - unsafe { std::env::set_var("XDG_DATA_HOME", home.join(".local/share")) }; + _xdg.set("XDG_DATA_HOME", home.join(".local/share")); - let result = scan_all_clients(home.to_str().unwrap(), &["zed".to_string()]); + let result = scan_without_extra_dirs(home.to_str().unwrap(), &["zed".to_string()]); assert_eq!(result.zed_db.as_ref(), Some(&zed_db)); - restore_env("XDG_DATA_HOME", previous_xdg); } #[cfg(target_os = "macos")] #[test] #[serial] fn test_scan_all_clients_zed_macos_fallback() { - let previous_xdg = std::env::var("XDG_DATA_HOME").ok(); + let mut _xdg = EnvGuard::capture(&["XDG_DATA_HOME"]); let dir = TempDir::new().unwrap(); let home = dir.path(); let zed_db = setup_mock_zed_macos_db(home); - unsafe { std::env::remove_var("XDG_DATA_HOME") }; + _xdg.remove("XDG_DATA_HOME"); - let result = scan_all_clients(home.to_str().unwrap(), &["zed".to_string()]); + let result = scan_without_extra_dirs(home.to_str().unwrap(), &["zed".to_string()]); assert_eq!(result.zed_db.as_ref(), Some(&zed_db)); - restore_env("XDG_DATA_HOME", previous_xdg); } #[test] @@ -2516,7 +2570,11 @@ mod tests { let home = dir.path(); setup_mock_claude_dir(home); - let result = scan_all_clients(home.to_str().unwrap(), &["claude".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["claude".to_string()], + false, + ); assert_eq!(result.get(ClientId::Claude).len(), 1); assert!(result.get(ClientId::OpenCode).is_empty()); } @@ -2539,7 +2597,11 @@ mod tests { .write_all(b"{}\n") .unwrap(); - let result = scan_all_clients(home.to_str().unwrap(), &["claude".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["claude".to_string()], + false, + ); assert!( result.get(ClientId::Claude).iter().any(|p| p == &agent), "nested workflow agent transcript must be discovered, got {:?}", @@ -2554,7 +2616,11 @@ mod tests { setup_mock_claude_dir(home); let transcript = setup_mock_claude_transcripts_dir(home); - let result = scan_all_clients(home.to_str().unwrap(), &["claude".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["claude".to_string()], + false, + ); assert_eq!(result.get(ClientId::Claude).len(), 2); assert!( @@ -2575,7 +2641,11 @@ mod tests { let home = dir.path(); let transcript = setup_mock_claude_transcripts_dir(home); - let result = scan_all_clients(home.to_str().unwrap(), &["claude".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["claude".to_string()], + false, + ); assert_eq!(result.get(ClientId::Claude), &vec![transcript]); assert!(result.get(ClientId::OpenCode).is_empty()); @@ -2594,16 +2664,22 @@ mod tests { let variant_file = variant_dir.join("variant.json"); fs::write( &variant_file, - format!( - r#"{{"name":"kimi-code","provider":"kimi","configDir":"{}"}}"#, - config_dir.display() - ), + serde_json::json!({ + "name": "kimi-code", + "provider": "kimi", + "configDir": config_dir, + }) + .to_string(), ) .unwrap(); let variant_session = project_dir.join("variant-session.jsonl"); File::create(&variant_session).unwrap(); - let result = scan_all_clients(home.to_str().unwrap(), &["claude".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["claude".to_string()], + false, + ); assert_eq!(result.get(ClientId::Claude).len(), 2); assert!( @@ -2628,14 +2704,20 @@ mod tests { fs::create_dir_all(&variant_dir).unwrap(); fs::write( variant_dir.join("variant.json"), - format!( - r#"{{"name":"plain-mirror","provider":"mirror","configDir":"{}"}}"#, - normal_claude_dir.display() - ), + serde_json::json!({ + "name": "plain-mirror", + "provider": "mirror", + "configDir": normal_claude_dir, + }) + .to_string(), ) .unwrap(); - let result = scan_all_clients(home.to_str().unwrap(), &["claude".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["claude".to_string()], + false, + ); assert_eq!( result.get(ClientId::Claude).len(), @@ -2650,7 +2732,11 @@ mod tests { let home = dir.path(); setup_mock_gemini_dir(home); - let result = scan_all_clients(home.to_str().unwrap(), &["gemini".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["gemini".to_string()], + false, + ); assert_eq!(result.get(ClientId::Gemini).len(), 1); assert!(result.get(ClientId::OpenCode).is_empty()); } @@ -2663,7 +2749,11 @@ mod tests { fs::create_dir_all(&gemini_path).unwrap(); File::create(gemini_path.join("session-abc.jsonl")).unwrap(); - let result = scan_all_clients(home.to_str().unwrap(), &["gemini".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["gemini".to_string()], + false, + ); assert_eq!(result.get(ClientId::Gemini).len(), 1); assert!(result.get(ClientId::Gemini)[0].ends_with("session-abc.jsonl")); } @@ -2687,7 +2777,7 @@ mod tests { #[test] #[serial] fn test_scan_all_clients_copilot_includes_explicit_exporter_file() { - let previous = std::env::var("COPILOT_OTEL_FILE_EXPORTER_PATH").ok(); + let mut _exporter = EnvGuard::capture(&["COPILOT_OTEL_FILE_EXPORTER_PATH"]); let dir = TempDir::new().unwrap(); let home = dir.path(); @@ -2696,13 +2786,11 @@ mod tests { let explicit_file = explicit_dir.join("copilot-explicit.jsonl"); File::create(&explicit_file).unwrap(); - unsafe { std::env::set_var("COPILOT_OTEL_FILE_EXPORTER_PATH", &explicit_file) }; + _exporter.set("COPILOT_OTEL_FILE_EXPORTER_PATH", &explicit_file); - let result = scan_all_clients(home.to_str().unwrap(), &["copilot".to_string()]); + let result = scan_without_extra_dirs(home.to_str().unwrap(), &["copilot".to_string()]); assert_eq!(result.get(ClientId::Copilot), &vec![explicit_file]); - - restore_env("COPILOT_OTEL_FILE_EXPORTER_PATH", previous); } #[test] @@ -2711,7 +2799,11 @@ mod tests { let home = dir.path(); setup_mock_openclaw_dir(home); - let result = scan_all_clients(home.to_str().unwrap(), &["openclaw".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["openclaw".to_string()], + false, + ); assert_eq!(result.get(ClientId::OpenClaw).len(), 3); assert!(result .get(ClientId::OpenClaw) @@ -2737,7 +2829,11 @@ mod tests { File::create(openclaw_sessions.join("session-archived.jsonl.deleted.1700000000000")) .unwrap(); - let result = scan_all_clients(home.to_str().unwrap(), &["openclaw".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["openclaw".to_string()], + false, + ); assert_eq!(result.get(ClientId::OpenClaw).len(), 1); assert!(result.get(ClientId::OpenClaw)[0] .ends_with("session-archived.jsonl.deleted.1700000000000")); @@ -2776,19 +2872,20 @@ mod tests { File::create(project_b_data.join("crush.db")).unwrap(); let registry_path = dir.path().join("projects.json"); - let projects_json = format!( - r#"{{ + let projects_json = serde_json::json!({ "projects": [ - {{ "path": "{}", "data_dir": ".crush" }}, - {{ "path": "{}", "data_dir": "{}" }}, - {{ "path": "{}", "data_dir": ".crush" }} - ] -}}"#, - project_a.display(), - dir.path().join("project-b").display(), - project_b_data.display(), - dir.path().join("missing-project").display(), - ); + { "path": project_a, "data_dir": ".crush" }, + { + "path": dir.path().join("project-b"), + "data_dir": project_b_data, + }, + { + "path": dir.path().join("missing-project"), + "data_dir": ".crush", + }, + ], + }) + .to_string(); setup_mock_crush_registry(®istry_path, &projects_json); let result = scan_crush_registry(®istry_path); @@ -2797,12 +2894,12 @@ mod tests { vec![ CrushDbSource { db_path: project_a.join(".crush").join("crush.db"), - workspace_key: Some(project_a.display().to_string()), + workspace_key: normalize_workspace_key(&project_a.display().to_string()), workspace_label: Some("project-a".to_string()), }, CrushDbSource { db_path: project_b_data.join("crush.db"), - workspace_key: Some(dir.path().join("project-b").display().to_string()), + workspace_key: normalize_workspace_key(&dir.path().join("project-b").display().to_string()), workspace_label: Some("project-b".to_string()), }, ] @@ -2817,17 +2914,15 @@ mod tests { File::create(valid_project.join(".crush").join("crush.db")).unwrap(); let registry_path = dir.path().join("projects.json"); - let projects_json = format!( - r#"{{ + let projects_json = serde_json::json!({ "projects": [ - {{ "path": "{}", "data_dir": ".crush" }}, - {{ "path": 123, "data_dir": ".crush" }}, - {{ "data_dir": ".crush" }}, - "not-an-object" - ] -}}"#, - valid_project.display() - ); + { "path": valid_project, "data_dir": ".crush" }, + { "path": 123, "data_dir": ".crush" }, + { "data_dir": ".crush" }, + "not-an-object", + ], + }) + .to_string(); setup_mock_crush_registry(®istry_path, &projects_json); let result = scan_crush_registry(®istry_path); @@ -2835,7 +2930,7 @@ mod tests { result, vec![CrushDbSource { db_path: valid_project.join(".crush").join("crush.db"), - workspace_key: Some(valid_project.display().to_string()), + workspace_key: normalize_workspace_key(&valid_project.display().to_string()), workspace_label: Some("valid-project".to_string()), }] ); @@ -2844,8 +2939,7 @@ mod tests { #[test] #[serial] fn test_discover_crush_dbs_ignores_cwd_without_override() { - let previous_xdg = std::env::var("XDG_DATA_HOME").ok(); - let previous_dir = std::env::current_dir().unwrap(); + let mut _xdg = EnvGuard::capture(&["XDG_DATA_HOME"]); let dir = TempDir::new().unwrap(); let home = dir.path().join("home"); @@ -2863,20 +2957,17 @@ mod tests { ) .unwrap(); - unsafe { std::env::set_var("XDG_DATA_HOME", &xdg) }; - std::env::set_current_dir(&nested).unwrap(); + _xdg.set("XDG_DATA_HOME", &xdg); + let _cwd = CwdGuard::change(&nested); let result = discover_crush_dbs(home.to_str().unwrap(), false); assert!(result.is_empty()); - - restore_current_dir(&previous_dir); - restore_env("XDG_DATA_HOME", previous_xdg); } #[test] #[serial] fn test_scan_all_clients_crush_populates_crush_db_paths() { - let previous_xdg = std::env::var("XDG_DATA_HOME").ok(); + let mut _xdg = EnvGuard::capture(&["XDG_DATA_HOME"]); let dir = TempDir::new().unwrap(); let home = dir.path().join("home"); @@ -2889,37 +2980,37 @@ mod tests { File::create(data_dir.join("crush.db")).unwrap(); let registry_path = xdg.join("crush").join("projects.json"); - let projects_json = format!( - r#"{{ - "projects": [ - {{ "path": "{}", "data_dir": ".crush" }} - ] -}}"#, - project.display() - ); + let projects_json = serde_json::json!({ + "projects": [{ "path": project, "data_dir": ".crush" }], + }) + .to_string(); setup_mock_crush_registry(®istry_path, &projects_json); - unsafe { std::env::set_var("XDG_DATA_HOME", &xdg) }; + _xdg.set("XDG_DATA_HOME", &xdg); - let result = scan_all_clients(home.to_str().unwrap(), &["crush".to_string()]); + let result = scan_without_extra_dirs(home.to_str().unwrap(), &["crush".to_string()]); assert_eq!( result.crush_dbs, vec![CrushDbSource { db_path: data_dir.join("crush.db"), - workspace_key: Some(project.display().to_string()), + workspace_key: normalize_workspace_key(&project.display().to_string()), workspace_label: Some("project".to_string()), }] ); assert!(result.get(ClientId::Crush).is_empty()); - - restore_env("XDG_DATA_HOME", previous_xdg); } #[test] #[serial] fn test_scan_all_clients_headless_paths() { - let previous_headless = std::env::var("TOKSCALE_HEADLESS_DIR").ok(); - unsafe { std::env::remove_var("TOKSCALE_HEADLESS_DIR") }; + let mut _headless = EnvGuard::capture(&[ + "TOKSCALE_HEADLESS_DIR", + "CODEX_HOME", + "GEMINI_CLI_HOME", + ]); + _headless.remove("TOKSCALE_HEADLESS_DIR"); + _headless.remove("CODEX_HOME"); + _headless.remove("GEMINI_CLI_HOME"); let dir = TempDir::new().unwrap(); let home = dir.path(); @@ -2933,7 +3024,7 @@ mod tests { fs::create_dir_all(mac_root.join("codex")).unwrap(); File::create(mac_root.join("codex").join("codex.jsonl")).unwrap(); - let result = scan_all_clients( + let result = scan_without_extra_dirs( home.to_str().unwrap(), &[ "claude".to_string(), @@ -2945,32 +3036,28 @@ mod tests { assert!(result.get(ClientId::Claude).is_empty()); assert_eq!(result.get(ClientId::Codex).len(), 1); assert!(result.get(ClientId::Gemini).is_empty()); - - restore_env("TOKSCALE_HEADLESS_DIR", previous_headless); } #[test] #[serial] fn test_scan_all_clients_codex_with_env() { - let previous_codex = std::env::var("CODEX_HOME").ok(); + let mut _codex = EnvGuard::capture(&["CODEX_HOME"]); let dir = TempDir::new().unwrap(); let home = dir.path(); setup_mock_codex_dir(home); // Set CODEX_HOME environment variable - unsafe { std::env::set_var("CODEX_HOME", home.join(".codex")) }; + _codex.set("CODEX_HOME", home.join(".codex")); - let result = scan_all_clients(home.to_str().unwrap(), &["codex".to_string()]); + let result = scan_without_extra_dirs(home.to_str().unwrap(), &["codex".to_string()]); assert_eq!(result.get(ClientId::Codex).len(), 1); - - restore_env("CODEX_HOME", previous_codex); } #[test] #[serial] fn test_scan_all_clients_codex_home_override_ignores_codex_home_env() { - let previous_codex = std::env::var("CODEX_HOME").ok(); + let mut _codex = EnvGuard::capture(&["CODEX_HOME"]); let dir = TempDir::new().unwrap(); let home = dir.path().join("target-home"); @@ -2978,7 +3065,7 @@ mod tests { setup_mock_codex_dir(&home); fs::create_dir_all(&conflicting).unwrap(); - unsafe { std::env::set_var("CODEX_HOME", &conflicting) }; + _codex.set("CODEX_HOME", &conflicting); let result = scan_all_clients_with_env_strategy( home.to_str().unwrap(), @@ -2988,44 +3075,38 @@ mod tests { assert_eq!(result.get(ClientId::Codex).len(), 1); assert!(result.get(ClientId::Codex)[0].ends_with("session.jsonl")); assert!(result.get(ClientId::Codex)[0].starts_with(home.join(".codex"))); - - restore_env("CODEX_HOME", previous_codex); } #[test] #[serial] fn test_scan_all_clients_codex_archived_sessions() { - let previous_codex = std::env::var("CODEX_HOME").ok(); + let mut _codex = EnvGuard::capture(&["CODEX_HOME"]); let dir = TempDir::new().unwrap(); let home = dir.path(); setup_mock_codex_archived_dir(home); - unsafe { std::env::set_var("CODEX_HOME", home.join(".codex")) }; + _codex.set("CODEX_HOME", home.join(".codex")); - let result = scan_all_clients(home.to_str().unwrap(), &["codex".to_string()]); + let result = scan_without_extra_dirs(home.to_str().unwrap(), &["codex".to_string()]); assert_eq!(result.get(ClientId::Codex).len(), 1); assert!(result.get(ClientId::Codex)[0].ends_with("archived.jsonl")); - - restore_env("CODEX_HOME", previous_codex); } #[test] #[serial] fn test_scan_all_clients_codex_sessions_and_archived() { - let previous_codex = std::env::var("CODEX_HOME").ok(); + let mut _codex = EnvGuard::capture(&["CODEX_HOME"]); let dir = TempDir::new().unwrap(); let home = dir.path(); setup_mock_codex_dir(home); setup_mock_codex_archived_dir(home); - unsafe { std::env::set_var("CODEX_HOME", home.join(".codex")) }; + _codex.set("CODEX_HOME", home.join(".codex")); - let result = scan_all_clients(home.to_str().unwrap(), &["codex".to_string()]); + let result = scan_without_extra_dirs(home.to_str().unwrap(), &["codex".to_string()]); assert_eq!(result.get(ClientId::Codex).len(), 2); - - restore_env("CODEX_HOME", previous_codex); } #[test] @@ -3034,7 +3115,11 @@ mod tests { let home = dir.path(); setup_mock_kimi_dir(home); - let result = scan_all_clients(home.to_str().unwrap(), &["kimi".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["kimi".to_string()], + false, + ); assert_eq!(result.get(ClientId::Kimi).len(), 1); assert!(result.get(ClientId::Kimi)[0].ends_with("wire.jsonl")); assert!(result.get(ClientId::OpenCode).is_empty()); @@ -3081,7 +3166,11 @@ mod tests { let home = dir.path(); setup_mock_roocode_dir(home); - let result = scan_all_clients(home.to_str().unwrap(), &["roocode".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["roocode".to_string()], + false, + ); assert_eq!(result.get(ClientId::RooCode).len(), 2); assert!(result .get(ClientId::RooCode) @@ -3095,7 +3184,11 @@ mod tests { let home = dir.path(); setup_mock_kilocode_dir(home); - let result = scan_all_clients(home.to_str().unwrap(), &["kilocode".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["kilocode".to_string()], + false, + ); assert_eq!(result.get(ClientId::KiloCode).len(), 2); assert!(result .get(ClientId::KiloCode) @@ -3109,7 +3202,11 @@ mod tests { let home = dir.path(); setup_mock_cline_dir(home); - let result = scan_all_clients(home.to_str().unwrap(), &["cline".to_string()]); + let result = scan_all_clients_with_env_strategy( + home.to_str().unwrap(), + &["cline".to_string()], + false, + ); assert_eq!(result.get(ClientId::Cline).len(), 4); assert!(result .get(ClientId::Cline) @@ -3169,7 +3266,7 @@ mod tests { #[test] #[serial] fn test_scan_all_clients_with_extra_dirs() { - let previous = std::env::var("TOKSCALE_EXTRA_DIRS").ok(); + let mut _extra = EnvGuard::capture(&["TOKSCALE_EXTRA_DIRS"]); let dir = TempDir::new().unwrap(); let home = dir.path(); @@ -3183,18 +3280,14 @@ mod tests { fs::create_dir_all(&extra_project).unwrap(); File::create(extra_project.join("extra-session.jsonl")).unwrap(); - unsafe { - std::env::set_var( + _extra.set( "TOKSCALE_EXTRA_DIRS", format!("claude:{}", extra_dir.path().to_string_lossy()), - ) - }; + ); let result = scan_all_clients(home.to_str().unwrap(), &["claude".to_string()]); // 1 from default path + 1 from extra dir assert_eq!(result.get(ClientId::Claude).len(), 2); - - restore_env("TOKSCALE_EXTRA_DIRS", previous); } fn setup_mock_codebuff_chat(base: &Path, channel: &str, chat_id: &str) -> PathBuf { @@ -3215,8 +3308,8 @@ mod tests { #[test] #[serial] fn test_scan_all_clients_codebuff_walks_all_three_channels_by_default() { - let previous = std::env::var("CODEBUFF_DATA_DIR").ok(); - unsafe { std::env::remove_var("CODEBUFF_DATA_DIR") }; + let mut _codebuff = EnvGuard::capture(&["CODEBUFF_DATA_DIR"]); + _codebuff.remove("CODEBUFF_DATA_DIR"); let dir = TempDir::new().unwrap(); let home = dir.path(); @@ -3224,35 +3317,31 @@ mod tests { setup_mock_codebuff_chat(home, "manicode-dev", "2025-12-14T11-00-00.000Z"); setup_mock_codebuff_chat(home, "manicode-staging", "2025-12-14T12-00-00.000Z"); - let result = scan_all_clients(home.to_str().unwrap(), &["codebuff".to_string()]); + let result = scan_without_extra_dirs(home.to_str().unwrap(), &["codebuff".to_string()]); assert_eq!(result.get(ClientId::Codebuff).len(), 3); - - restore_env("CODEBUFF_DATA_DIR", previous); } #[test] #[serial] fn test_scan_all_clients_codebuff_empty_env_var_falls_back_to_default_channels() { - let previous = std::env::var("CODEBUFF_DATA_DIR").ok(); + let mut _codebuff = EnvGuard::capture(&["CODEBUFF_DATA_DIR"]); // Regression: a whitespace-only override used to produce zero scan // roots because the `Some(_)` branch was taken and then skipped. - unsafe { std::env::set_var("CODEBUFF_DATA_DIR", " ") }; + _codebuff.set("CODEBUFF_DATA_DIR", " "); let dir = TempDir::new().unwrap(); let home = dir.path(); setup_mock_codebuff_chat(home, "manicode", "2025-12-14T10-00-00.000Z"); setup_mock_codebuff_chat(home, "manicode-dev", "2025-12-14T11-00-00.000Z"); - let result = scan_all_clients(home.to_str().unwrap(), &["codebuff".to_string()]); + let result = scan_without_extra_dirs(home.to_str().unwrap(), &["codebuff".to_string()]); assert_eq!(result.get(ClientId::Codebuff).len(), 2); - - restore_env("CODEBUFF_DATA_DIR", previous); } #[test] #[serial] fn test_scan_all_clients_codebuff_honours_explicit_env_override() { - let previous = std::env::var("CODEBUFF_DATA_DIR").ok(); + let mut _codebuff = EnvGuard::capture(&["CODEBUFF_DATA_DIR"]); let dir = TempDir::new().unwrap(); let home = dir.path(); @@ -3268,26 +3357,19 @@ mod tests { fs::create_dir_all(&override_chat_dir).unwrap(); File::create(override_chat_dir.join("chat-messages.json")).unwrap(); - unsafe { - std::env::set_var( - "CODEBUFF_DATA_DIR", - override_root.to_string_lossy().as_ref(), - ) - }; + _codebuff.set("CODEBUFF_DATA_DIR", &override_root); - let result = scan_all_clients(home.to_str().unwrap(), &["codebuff".to_string()]); + let result = scan_without_extra_dirs(home.to_str().unwrap(), &["codebuff".to_string()]); assert_eq!(result.get(ClientId::Codebuff).len(), 1); assert!(result.get(ClientId::Codebuff)[0] .to_string_lossy() .contains("custom-codebuff")); - - restore_env("CODEBUFF_DATA_DIR", previous); } #[test] #[serial] fn test_scan_all_clients_ignores_extra_dirs_when_env_roots_disabled() { - let previous = std::env::var("TOKSCALE_EXTRA_DIRS").ok(); + let mut _extra = EnvGuard::capture(&["TOKSCALE_EXTRA_DIRS"]); let dir = TempDir::new().unwrap(); let home = dir.path(); @@ -3298,12 +3380,10 @@ mod tests { fs::create_dir_all(&extra_project).unwrap(); File::create(extra_project.join("extra-session.jsonl")).unwrap(); - unsafe { - std::env::set_var( + _extra.set( "TOKSCALE_EXTRA_DIRS", format!("claude:{}", extra_dir.path().to_string_lossy()), - ) - }; + ); let result = scan_all_clients_with_env_strategy( home.to_str().unwrap(), @@ -3311,8 +3391,6 @@ mod tests { false, ); assert_eq!(result.get(ClientId::Claude).len(), 1); - - restore_env("TOKSCALE_EXTRA_DIRS", previous); } /// Verify that an extra scan path outside $HOME does not abort the scan. @@ -3320,17 +3398,10 @@ mod tests { #[test] #[serial] fn test_extra_scan_path_outside_home_does_not_block_scan() { - // Use a tempdir that is guaranteed to be outside the real $HOME - // (tempfile creates dirs under /tmp on Unix, %TEMP% on Windows). + let fake_home = TempDir::new().unwrap(); let outside_home = TempDir::new().unwrap(); let outside_path = outside_home.path(); - - // Ensure it is truly outside home (skip the test if somehow inside). - if let Some(home) = dirs::home_dir() { - if outside_path.starts_with(&home) { - return; // unexpected environment — skip rather than false-fail - } - } + assert!(!outside_path.starts_with(fake_home.path())); // Populate with a valid session file so the scanner has something to find. let session_dir = outside_path.join("sessions"); @@ -3338,23 +3409,19 @@ mod tests { File::create(session_dir.join("session-abc123.json")).unwrap(); // Set TOKSCALE_EXTRA_DIRS to point claude at the outside path. - let previous = std::env::var("TOKSCALE_EXTRA_DIRS").ok(); - unsafe { - std::env::set_var( + let mut _extra = EnvGuard::capture(&["TOKSCALE_EXTRA_DIRS"]); + _extra.set( "TOKSCALE_EXTRA_DIRS", format!("claude:{}", outside_path.to_string_lossy()), - ) - }; + ); // The scan must complete without panicking. - let fake_home = TempDir::new().unwrap(); let _result = scan_all_clients_with_env_strategy( fake_home.path().to_str().unwrap(), &["claude".to_string()], true, // use_env_roots = true so TOKSCALE_EXTRA_DIRS is picked up ); - restore_env("TOKSCALE_EXTRA_DIRS", previous); // No assertion on result.get(ClientId::Claude) — the outside dir might // not match the expected file patterns. The test goal is only liveness: // the scan must not panic when an extra path escapes $HOME. diff --git a/vendor/tokscale-core/src/sessions/claudecode.rs b/vendor/tokscale-core/src/sessions/claudecode.rs index 45eadf7f..bf82d250 100644 --- a/vendor/tokscale-core/src/sessions/claudecode.rs +++ b/vendor/tokscale-core/src/sessions/claudecode.rs @@ -1576,6 +1576,10 @@ mod tests { use std::io::Write; use tempfile::{NamedTempFile, TempDir}; + fn parse_claude_file(path: &std::path::Path) -> Vec { + super::parse_claude_file_with_home(path, None) + } + #[test] fn is_human_turn_counts_html_user_prompt() { let line = r#"{"type":"user","message":{"content":"
hello
"}}"#; @@ -1672,10 +1676,12 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write( variant_dir.join("variant.json"), - format!( - r#"{{"name":"{variant}","provider":"{provider}","configDir":"{}"}}"#, - config_dir.display() - ), + serde_json::json!({ + "name": variant, + "provider": provider, + "configDir": config_dir, + }) + .to_string(), ) .unwrap(); std::fs::write(&path, content).unwrap(); diff --git a/vendor/tokscale-core/src/sessions/opencode.rs b/vendor/tokscale-core/src/sessions/opencode.rs index a7fee382..5e5e6841 100644 --- a/vendor/tokscale-core/src/sessions/opencode.rs +++ b/vendor/tokscale-core/src/sessions/opencode.rs @@ -482,10 +482,28 @@ mod tests { struct EnvGuard(Vec<(&'static str, Option)>); + impl EnvGuard { + fn capture(keys: &[&'static str]) -> Self { + Self( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ) + } + + fn set(&mut self, key: &'static str, value: impl AsRef) { + unsafe { std::env::set_var(key, value) }; + } + + fn remove(&mut self, key: &'static str) { + unsafe { std::env::remove_var(key) }; + } + } + impl Drop for EnvGuard { fn drop(&mut self) { - for (key, previous) in self.0.drain(..) { - unsafe { + unsafe { + for (key, previous) in self.0.drain(..) { match previous { Some(value) => std::env::set_var(key, value), None => std::env::remove_var(key), @@ -1541,24 +1559,21 @@ mod tests { #[test] #[serial_test::serial] + #[cfg(not(target_os = "windows"))] fn migration_record_falls_back_to_legacy_path() { - use std::env; - let temp_home = tempfile::tempdir().unwrap(); let temp_xdg_cache = tempfile::tempdir().unwrap(); - let prev_home = env::var_os("HOME"); - let prev_xdg_cache = env::var_os("XDG_CACHE_HOME"); - let prev_override = env::var_os("TOKSCALE_CONFIG_DIR"); - let _guard = EnvGuard(vec![ - ("TOKSCALE_CONFIG_DIR", prev_override), - ("XDG_CACHE_HOME", prev_xdg_cache), - ("HOME", prev_home), + let config_dir = temp_home.path().join(".config"); + let mut _guard = EnvGuard::capture(&[ + "TOKSCALE_CONFIG_DIR", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "HOME", ]); - unsafe { - env::set_var("HOME", temp_home.path()); - env::set_var("XDG_CACHE_HOME", temp_xdg_cache.path()); - env::remove_var("TOKSCALE_CONFIG_DIR"); - } + _guard.set("HOME", temp_home.path()); + _guard.set("XDG_CACHE_HOME", temp_xdg_cache.path()); + _guard.set("XDG_CONFIG_HOME", &config_dir); + _guard.remove("TOKSCALE_CONFIG_DIR"); let legacy_path = crate::paths::legacy_dirs_cache_dir() .unwrap() @@ -1574,6 +1589,54 @@ mod tests { assert!(loaded.migration_complete); assert_eq!(loaded.json_file_count, 2); } + + #[cfg(windows)] + #[test] + #[serial_test::serial] + fn legacy_migration_paths_are_ordered_and_override_gated_without_io() { + let mut _guard = EnvGuard::capture(&[ + "TOKSCALE_CONFIG_DIR", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", + "HOME", + ]); + _guard.remove("TOKSCALE_CONFIG_DIR"); + let candidates = legacy_migration_cache_paths(); + assert_eq!(candidates.len(), 2); + assert_eq!( + candidates[0], + dirs::cache_dir() + .expect("Windows exposes a cache directory") + .join("tokscale") + .join(MIGRATION_CACHE_FILENAME) + ); + assert_eq!( + candidates[1], + dirs::home_dir() + .expect("Windows exposes a home directory") + .join(".cache") + .join("tokscale") + .join(MIGRATION_CACHE_FILENAME) + ); + + _guard.set("TOKSCALE_CONFIG_DIR", std::env::temp_dir()); + assert!(legacy_migration_cache_paths().is_empty()); + } + + #[test] + #[serial_test::serial] + fn env_guard_restores_after_unwind() { + const KEY: &str = "TOKSCALE_OPENCODE_ENV_GUARD_SELF_CHECK"; + let mut outer = EnvGuard::capture(&[KEY]); + outer.set(KEY, "before"); + let result = std::panic::catch_unwind(|| { + let mut inner = EnvGuard::capture(&[KEY]); + inner.set(KEY, "during"); + panic!("exercise EnvGuard unwinding"); + }); + assert!(result.is_err()); + assert_eq!(std::env::var_os(KEY), Some("before".into())); + } } #[cfg(test)]