From 93642255fada004ac95b697fcaf6f6704d8754fd Mon Sep 17 00:00:00 2001 From: Nanako Tsai Date: Fri, 17 Jul 2026 22:06:19 +0800 Subject: [PATCH] fix(vendor): recover Windows portability patches Recover the eleven validated tokscale-core commits accumulated in the Windows downstream so this repository remains the canonical source for future syncs.\n\nRelease cache file handles before Windows atomic replacement, preserve explicit-home scanner boundaries, and isolate core fixtures from inherited environment and profile state. Record the downstream provenance while keeping cache schema 29 and all parser, FFI, and public API contracts unchanged.\n\nVerification: focused and hostile-environment Rust suites, cargo test --workspace, cargo clippy --workspace --all-targets, make build, Swift selftest/smoke, knowledge check, diff check, byte-for-byte downstream fidelity, and fresh verifier confirmation. --- vendor/README.md | 8 + vendor/tokscale-core/src/clients.rs | 178 +++--- vendor/tokscale-core/src/lib.rs | 580 +++++++++--------- vendor/tokscale-core/src/message_cache.rs | 367 ++++++----- vendor/tokscale-core/src/pricing/cache.rs | 112 +++- vendor/tokscale-core/src/scanner.rs | 537 +++++++++------- .../tokscale-core/src/sessions/claudecode.rs | 14 +- vendor/tokscale-core/src/sessions/opencode.rs | 95 ++- 8 files changed, 1063 insertions(+), 828 deletions(-) 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)]