Skip to main content

vct_core/utils/
paths.rs

1//! Filesystem path resolution: the per-provider session directories under the
2//! user's home, the tool's own cache directory, and the dated pricing-cache
3//! file naming scheme.
4
5use anyhow::{Context, Result};
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::OnceLock;
9
10/// Resolved on-disk locations for every provider's session logs plus the
11/// tool's cache directory.
12///
13/// Construct one with [`resolve_paths`]; the fields are derived from the
14/// user's home directory and are not validated to exist. The `*_session_dir`
15/// fields point at the subtree a directory walker scans for that provider.
16#[derive(Debug, Clone)]
17pub struct HelperPaths {
18    /// The user's home directory, the root for every other path.
19    pub home_dir: PathBuf,
20    /// Codex root (`~/.codex`).
21    pub codex_dir: PathBuf,
22    /// Codex session logs (`~/.codex/sessions`).
23    pub codex_session_dir: PathBuf,
24    /// Claude Code root (`~/.claude`).
25    pub claude_dir: PathBuf,
26    /// Claude Code session logs (`~/.claude/projects`).
27    pub claude_session_dir: PathBuf,
28    /// Copilot CLI root (`~/.copilot`).
29    pub copilot_dir: PathBuf,
30    /// Copilot CLI session state (`~/.copilot/session-state`).
31    pub copilot_session_dir: PathBuf,
32    /// Cursor CLI config root (`$XDG_CONFIG_HOME/cursor` or `~/.config/cursor`).
33    ///
34    /// Holds the OAuth credentials (`auth.json`) used by the quota panel. This
35    /// is **not** where Cursor stores session data — that lives under `~/.cursor`
36    /// (see `cursor_tracking_db` / `cursor_chats_dir`).
37    pub cursor_dir: PathBuf,
38    /// Cursor AI-code tracking database (`~/.cursor/ai-tracking/ai-code-tracking.db`).
39    ///
40    /// Maps each conversation to the model that authored its code, used for
41    /// per-model attribution in the `analysis` view.
42    pub cursor_tracking_db: PathBuf,
43    /// Cursor chat session stores root (`~/.cursor/chats`).
44    ///
45    /// Each conversation is a `chats/<projectHash>/<conversationId>/store.db`
46    /// SQLite blob store parsed for `analysis` tool metrics.
47    pub cursor_chats_dir: PathBuf,
48    /// Gemini CLI root (`~/.gemini`).
49    pub gemini_dir: PathBuf,
50    /// Gemini CLI session logs (`~/.gemini/tmp`).
51    pub gemini_session_dir: PathBuf,
52    /// Grok CLI root (`$GROK_HOME` or `~/.grok`).
53    pub grok_dir: PathBuf,
54    /// Grok CLI session logs (`$GROK_HOME/sessions` or `~/.grok/sessions`).
55    pub grok_session_dir: PathBuf,
56    /// OpenCode data root (`$XDG_DATA_HOME/opencode` or `~/.local/share/opencode`).
57    pub opencode_dir: PathBuf,
58    /// OpenCode SQLite database (`<opencode_dir>/opencode.db`).
59    pub opencode_db: PathBuf,
60    /// Hermes SQLite database (`~/.hermes/state.db`).
61    pub hermes_db: PathBuf,
62    /// This tool's cache directory (`~/.vct`).
63    pub cache_dir: PathBuf,
64}
65
66/// Builds a [`HelperPaths`] from the current user's home directory.
67///
68/// The returned paths are computed by joining well-known suffixes onto the
69/// home directory; none of them are checked for existence here.
70///
71/// # Errors
72///
73/// Returns an error if the user's home directory cannot be determined.
74pub fn resolve_paths() -> Result<HelperPaths> {
75    let home_dir =
76        home::home_dir().ok_or_else(|| anyhow::anyhow!("Unable to resolve user home directory"))?;
77
78    // Cursor credentials honour `$XDG_CONFIG_HOME`; OpenCode's DB honours
79    // `$XDG_DATA_HOME`. Both fall back to the home-relative default when the
80    // env var is unset or not absolute.
81    let xdg_config = std::env::var_os("XDG_CONFIG_HOME")
82        .map(PathBuf::from)
83        .filter(|p| p.is_absolute());
84    let xdg_data = std::env::var_os("XDG_DATA_HOME")
85        .map(PathBuf::from)
86        .filter(|p| p.is_absolute());
87    let grok_home_env = std::env::var_os("GROK_HOME")
88        .map(PathBuf::from)
89        .filter(|p| !p.as_os_str().is_empty());
90    let grok_home = resolve_grok_home(&home_dir, grok_home_env.as_deref());
91
92    // Hermes honours `$HERMES_HOME`, else the platform-native default
93    // (`%LOCALAPPDATA%\hermes` on Windows, `~/.hermes` on POSIX), matching its
94    // own `get_hermes_home`.
95    let hermes_home_env = std::env::var_os("HERMES_HOME")
96        .map(PathBuf::from)
97        .filter(|p| !p.as_os_str().is_empty());
98    let local_appdata = std::env::var_os("LOCALAPPDATA")
99        .map(PathBuf::from)
100        .filter(|p| !p.as_os_str().is_empty());
101    let hermes_home = resolve_hermes_home(
102        &home_dir,
103        hermes_home_env.as_deref(),
104        local_appdata.as_deref(),
105        cfg!(target_os = "windows"),
106    );
107
108    Ok(build_paths(
109        &home_dir,
110        xdg_config.as_deref(),
111        xdg_data.as_deref(),
112        Some(&hermes_home),
113        Some(&grok_home),
114    ))
115}
116
117/// Resolves the Grok CLI home directory. An explicit `GROK_HOME` wins;
118/// otherwise Grok uses `~/.grok`.
119fn resolve_grok_home(home_dir: &Path, grok_home: Option<&Path>) -> PathBuf {
120    grok_home
121        .map(Path::to_path_buf)
122        .unwrap_or_else(|| home_dir.join(".grok"))
123}
124
125/// Resolves the Hermes home directory the way Hermes's `get_hermes_home` does:
126/// an explicit `HERMES_HOME` wins, otherwise the platform-native default
127/// (`%LOCALAPPDATA%\hermes` on Windows — falling back to `~/AppData/Local/hermes`
128/// when `LOCALAPPDATA` is unset — and `~/.hermes` on POSIX). Env values are
129/// injected rather than read here so the resolution stays testable.
130fn resolve_hermes_home(
131    home_dir: &Path,
132    hermes_home: Option<&Path>,
133    local_appdata: Option<&Path>,
134    is_windows: bool,
135) -> PathBuf {
136    if let Some(home) = hermes_home {
137        return home.to_path_buf();
138    }
139    if is_windows {
140        return local_appdata
141            .map(Path::to_path_buf)
142            .unwrap_or_else(|| home_dir.join("AppData").join("Local"))
143            .join("hermes");
144    }
145    home_dir.join(".hermes")
146}
147
148/// Builds a [`HelperPaths`] rooted at an explicit home directory, ignoring the
149/// environment entirely.
150///
151/// Uses the non-XDG default layout (`~/.config/cursor`, `~/.local/share/opencode`),
152/// which is exactly what [`resolve_paths`] falls back to when the XDG vars are
153/// unset. This is the seam tests use to point every provider path at a temp
154/// directory without mutating process-global `HOME`/`XDG_*` state.
155pub fn resolve_paths_from_home(home_dir: &Path) -> HelperPaths {
156    build_paths(home_dir, None, None, None, None)
157}
158
159/// Pure path composition shared by [`resolve_paths`] and
160/// [`resolve_paths_from_home`].
161///
162/// `xdg_config` / `xdg_data`, when `Some`, override the base of the Cursor
163/// config dir and the OpenCode data dir respectively; `hermes_home` and
164/// `grok_home`, when `Some`, are the resolved provider home directories. All
165/// otherwise derive from `home_dir`.
166fn build_paths(
167    home_dir: &Path,
168    xdg_config: Option<&Path>,
169    xdg_data: Option<&Path>,
170    hermes_home: Option<&Path>,
171    grok_home: Option<&Path>,
172) -> HelperPaths {
173    let codex_dir = home_dir.join(".codex");
174    let codex_session_dir = codex_dir.join("sessions");
175    let claude_dir = home_dir.join(".claude");
176    let claude_session_dir = claude_dir.join("projects");
177    let copilot_dir = home_dir.join(".copilot");
178    // Copilot CLI writes each session as a directory under
179    // `session-state/<sessionId>/`, with the event log at `events.jsonl`
180    // plus sibling folders (`rewind-snapshots/`, `checkpoints/`, `files/`).
181    // The per-session filter (see `is_copilot_session_file`) is responsible
182    // for picking only the `events.jsonl` file from each session tree and
183    // ignoring the snapshot/checkpoint artifacts.
184    let copilot_session_dir = copilot_dir.join("session-state");
185    // Cursor keeps its CLI OAuth credentials under the XDG config directory,
186    // honouring `$XDG_CONFIG_HOME` and falling back to `~/.config`.
187    let cursor_dir = xdg_config
188        .map(Path::to_path_buf)
189        .unwrap_or_else(|| home_dir.join(".config"))
190        .join("cursor");
191    // Cursor session data (distinct from the config dir above) lives under
192    // `~/.cursor`: a global tracking DB plus one blob-store DB per conversation.
193    let cursor_data_dir = home_dir.join(".cursor");
194    let cursor_tracking_db = cursor_data_dir
195        .join("ai-tracking")
196        .join("ai-code-tracking.db");
197    let cursor_chats_dir = cursor_data_dir.join("chats");
198    let gemini_dir = home_dir.join(".gemini");
199    let gemini_session_dir = gemini_dir.join("tmp");
200    let grok_dir = resolve_grok_home(home_dir, grok_home);
201    let grok_session_dir = grok_dir.join("sessions");
202    // OpenCode keeps a single SQLite database under the XDG data directory,
203    // honouring `$XDG_DATA_HOME` and falling back to `~/.local/share`.
204    let opencode_dir = xdg_data
205        .map(Path::to_path_buf)
206        .unwrap_or_else(|| home_dir.join(".local").join("share"))
207        .join("opencode");
208    let opencode_db = opencode_dir.join("opencode.db");
209    // Hermes keeps a single SQLite database under its home dir (`$HERMES_HOME`
210    // or the platform default), falling back to `~/.hermes`.
211    let hermes_db = hermes_home
212        .map(Path::to_path_buf)
213        .unwrap_or_else(|| home_dir.join(".hermes"))
214        .join("state.db");
215    let cache_dir = home_dir.join(".vct");
216
217    HelperPaths {
218        home_dir: home_dir.to_path_buf(),
219        codex_dir,
220        codex_session_dir,
221        claude_dir,
222        claude_session_dir,
223        copilot_dir,
224        copilot_session_dir,
225        cursor_dir,
226        cursor_tracking_db,
227        cursor_chats_dir,
228        gemini_dir,
229        gemini_session_dir,
230        grok_dir,
231        grok_session_dir,
232        opencode_dir,
233        opencode_db,
234        hermes_db,
235        cache_dir,
236    }
237}
238
239/// Whether all network access is disabled via `VCT_OFFLINE`.
240///
241/// When set to a non-empty value the tool stays fully offline: the pricing
242/// fetch, the Cursor usage API, and the update check each skip the network and
243/// degrade to a cache/empty/local result. The integration tests set this (plus
244/// an isolated `HOME`) so `cargo test` never reaches an external API.
245pub fn network_disabled() -> bool {
246    std::env::var_os("VCT_OFFLINE").is_some_and(|v| !v.is_empty())
247}
248
249/// Returns the current username from the environment (cached after first call).
250///
251/// Reads `USER`, falling back to `USERNAME` (Windows), and finally to the
252/// literal `"unknown"` if neither is set. The lookup is memoized so a large
253/// scan does not re-read the environment once per parsed file.
254pub fn get_current_user() -> String {
255    static CACHE: OnceLock<String> = OnceLock::new();
256    CACHE
257        .get_or_init(|| {
258            std::env::var("USER")
259                .or_else(|_| std::env::var("USERNAME"))
260                .unwrap_or_else(|_| "unknown".to_string())
261        })
262        .clone()
263}
264
265static MACHINE_ID_CACHE: OnceLock<String> = OnceLock::new();
266
267/// Returns the user's home directory.
268///
269/// # Errors
270///
271/// Returns an error if the home directory cannot be determined.
272fn get_home_dir() -> Result<PathBuf> {
273    home::home_dir().ok_or_else(|| anyhow::anyhow!("Unable to resolve user home directory"))
274}
275
276/// Returns a unique machine identifier (cached after first call)
277///
278/// Tries `/etc/machine-id` on Linux, falls back to hostname, then to a placeholder.
279pub fn get_machine_id() -> &'static str {
280    MACHINE_ID_CACHE.get_or_init(|| {
281        // Try to read /etc/machine-id on Linux
282        if let Ok(id) = std::fs::read_to_string("/etc/machine-id") {
283            return id.trim().to_string();
284        }
285
286        // Fallback to hostname
287        if let Ok(hostname) = hostname::get()
288            && let Some(hostname_str) = hostname.to_str()
289        {
290            return hostname_str.to_string();
291        }
292
293        "unknown-machine-id".to_string()
294    })
295}
296
297/// Returns the tool's cache directory (`~/.vct`), creating it
298/// (and any missing parents) if it does not already exist.
299///
300/// # Errors
301///
302/// Returns an error if the home directory cannot be determined or if the
303/// cache directory cannot be created.
304pub fn get_cache_dir() -> Result<PathBuf> {
305    let home_dir = get_home_dir()?;
306    let cache_dir = home_dir.join(".vct");
307
308    // Create directory if it doesn't exist
309    if !cache_dir.exists() {
310        fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?;
311    }
312
313    Ok(cache_dir)
314}
315
316/// Returns the pricing cache file path for `date`.
317///
318/// The path is `~/.vct/model_pricing_<date>.json`, where
319/// `date` is expected in `YYYY-MM-DD` form. As a side effect of resolving the
320/// cache directory, the directory is created if missing.
321///
322/// # Errors
323///
324/// Returns an error if the cache directory cannot be resolved or created.
325pub fn get_pricing_cache_path(date: &str) -> Result<PathBuf> {
326    Ok(get_pricing_cache_path_in(&get_cache_dir()?, date))
327}
328
329/// Returns the pricing cache file path for `date` under an explicit cache dir.
330///
331/// The env-free counterpart of [`get_pricing_cache_path`]: it only composes the
332/// path (`<dir>/model_pricing_<date>.json`) and never resolves the home
333/// directory or creates the directory, so tests can point it at a temp dir.
334pub fn get_pricing_cache_path_in(dir: &Path, date: &str) -> PathBuf {
335    dir.join(format!("model_pricing_{}.json", date))
336}
337
338/// Returns the Claude usage cache path
339/// (`~/.vct/claude_usage.json`).
340///
341/// As a side effect of resolving the cache directory, the directory is
342/// created if missing.
343///
344/// # Errors
345///
346/// Returns an error if the cache directory cannot be resolved or created.
347pub fn get_claude_usage_cache_path() -> Result<PathBuf> {
348    Ok(get_cache_dir()?.join("claude_usage.json"))
349}
350
351/// Returns the Codex usage cache path
352/// (`~/.vct/codex_usage.json`).
353///
354/// As a side effect of resolving the cache directory, the directory is
355/// created if missing.
356///
357/// # Errors
358///
359/// Returns an error if the cache directory cannot be resolved or created.
360pub fn get_codex_usage_cache_path() -> Result<PathBuf> {
361    Ok(get_cache_dir()?.join("codex_usage.json"))
362}
363
364/// Returns the Copilot usage cache path
365/// (`~/.vct/copilot_usage.json`).
366///
367/// As a side effect of resolving the cache directory, the directory is
368/// created if missing.
369///
370/// # Errors
371///
372/// Returns an error if the cache directory cannot be resolved or created.
373pub fn get_copilot_usage_cache_path() -> Result<PathBuf> {
374    Ok(get_cache_dir()?.join("copilot_usage.json"))
375}
376
377/// Returns the Cursor usage cache path
378/// (`~/.vct/cursor_usage.json`).
379///
380/// As a side effect of resolving the cache directory, the directory is
381/// created if missing.
382///
383/// # Errors
384///
385/// Returns an error if the cache directory cannot be resolved or created.
386pub fn get_cursor_usage_cache_path() -> Result<PathBuf> {
387    Ok(get_cache_dir()?.join("cursor_usage.json"))
388}
389
390/// Returns the Grok usage cache path
391/// (`~/.vct/grok_usage.json`).
392///
393/// As a side effect of resolving the cache directory, the directory is
394/// created if missing.
395///
396/// # Errors
397///
398/// Returns an error if the cache directory cannot be resolved or created.
399pub fn get_grok_usage_cache_path() -> Result<PathBuf> {
400    Ok(get_cache_dir()?.join("grok_usage.json"))
401}
402
403/// Returns the persistent settings file path (`~/.vct/config.toml`).
404///
405/// As a side effect of resolving the cache directory, the directory is created
406/// if missing.
407///
408/// # Errors
409///
410/// Returns an error if the cache directory cannot be resolved or created.
411pub fn get_config_path() -> Result<PathBuf> {
412    Ok(get_cache_dir()?.join("config.toml"))
413}
414
415/// Returns this tool's own version record path (`~/.vct/version.json`).
416///
417/// Holds `{ latest_version, last_checked_at, dismissed_version }`, written by
418/// the update check as groundwork for a future auto-update prompt. As a side
419/// effect of resolving the cache directory, the directory is created if missing.
420///
421/// # Errors
422///
423/// Returns an error if the cache directory cannot be resolved or created.
424pub fn get_self_version_cache_path() -> Result<PathBuf> {
425    Ok(get_cache_dir()?.join("version.json"))
426}
427
428/// Returns the Copilot CLI config path (`~/.copilot/config.json`).
429///
430/// This file is JSONC (has `//` comments); callers must strip comments before
431/// parsing it as JSON.
432///
433/// # Errors
434///
435/// Returns an error if the user's home directory cannot be determined.
436pub fn get_copilot_config_path() -> Result<PathBuf> {
437    Ok(resolve_paths()?.copilot_dir.join("config.json"))
438}
439
440/// Returns the Cursor CLI OAuth credentials path
441/// (`$XDG_CONFIG_HOME/cursor/auth.json` or `~/.config/cursor/auth.json`).
442///
443/// # Errors
444///
445/// Returns an error if the user's home directory cannot be determined.
446pub fn get_cursor_auth_path() -> Result<PathBuf> {
447    Ok(resolve_paths()?.cursor_dir.join("auth.json"))
448}
449
450/// Returns the Grok CLI OAuth credentials path
451/// (`$GROK_HOME/auth.json` or `~/.grok/auth.json`).
452///
453/// # Errors
454///
455/// Returns an error if the user's home directory cannot be determined.
456pub fn get_grok_auth_path() -> Result<PathBuf> {
457    Ok(resolve_paths()?.grok_dir.join("auth.json"))
458}
459
460/// Returns the Claude OAuth credentials path (`~/.claude/.credentials.json`).
461///
462/// # Errors
463///
464/// Returns an error if the user's home directory cannot be determined.
465pub fn get_claude_credentials_path() -> Result<PathBuf> {
466    Ok(resolve_paths()?.claude_dir.join(".credentials.json"))
467}
468
469/// Returns the pricing cache path for `date` only if that file exists.
470///
471/// Yields `None` when the file is absent or when the cache directory cannot
472/// be resolved.
473pub fn find_pricing_cache_for_date(date: &str) -> Option<PathBuf> {
474    find_pricing_cache_for_date_in(&get_cache_dir().ok()?, date)
475}
476
477/// Returns the pricing cache path for `date` under an explicit cache dir, only
478/// if that file exists.
479///
480/// The env-free counterpart of [`find_pricing_cache_for_date`].
481pub fn find_pricing_cache_for_date_in(dir: &Path, date: &str) -> Option<PathBuf> {
482    let cache_path = get_pricing_cache_path_in(dir, date);
483    cache_path.exists().then_some(cache_path)
484}
485
486/// Lists every `model_pricing_*.json` file in the cache directory.
487///
488/// Each element is the `(filename, full_path)` pair for a file matching the
489/// `model_pricing_*.json` naming scheme; other directory entries are ignored.
490/// If the directory cannot be read, an empty `Vec` is returned rather than an
491/// error.
492///
493/// # Errors
494///
495/// Returns an error only if the cache directory cannot be resolved or created.
496pub fn list_pricing_cache_files() -> Result<Vec<(String, PathBuf)>> {
497    Ok(list_pricing_cache_files_in(&get_cache_dir()?))
498}
499
500/// Lists every `model_pricing_*.json` file in an explicit cache dir.
501///
502/// The env-free counterpart of [`list_pricing_cache_files`]; a missing or
503/// unreadable directory yields an empty `Vec`.
504pub fn list_pricing_cache_files_in(dir: &Path) -> Vec<(String, PathBuf)> {
505    let mut cache_files = Vec::new();
506
507    if let Ok(entries) = fs::read_dir(dir) {
508        for entry in entries.flatten() {
509            let path = entry.path();
510            if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
511                // Match pattern: model_pricing_YYYY-MM-DD.json
512                if filename.starts_with("model_pricing_") && filename.ends_with(".json") {
513                    cache_files.push((filename.to_string(), path));
514                }
515            }
516        }
517    }
518
519    cache_files
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use tempfile::TempDir;
526
527    #[test]
528    fn resolve_paths_from_home_composes_all_provider_paths() {
529        let tmp = TempDir::new().unwrap();
530        let home = tmp.path();
531        let p = resolve_paths_from_home(home);
532
533        assert_eq!(p.home_dir.as_path(), home);
534        assert!(p.codex_dir.ends_with(".codex"));
535        assert!(p.claude_dir.ends_with(".claude"));
536        assert!(p.copilot_dir.ends_with(".copilot"));
537        assert!(p.gemini_dir.ends_with(".gemini"));
538        assert!(p.grok_dir.ends_with(".grok"));
539        assert!(p.cache_dir.ends_with(".vct"));
540
541        assert_eq!(p.codex_session_dir, home.join(".codex").join("sessions"));
542        assert_eq!(p.claude_session_dir, home.join(".claude").join("projects"));
543        assert_eq!(
544            p.copilot_session_dir,
545            home.join(".copilot").join("session-state")
546        );
547        assert_eq!(p.gemini_session_dir, home.join(".gemini").join("tmp"));
548        assert_eq!(p.grok_session_dir, home.join(".grok").join("sessions"));
549        assert_eq!(p.opencode_db, p.opencode_dir.join("opencode.db"));
550        assert!(p.opencode_dir.ends_with("opencode"));
551        assert_eq!(p.hermes_db, home.join(".hermes").join("state.db"));
552
553        // Cursor config dir uses the non-XDG default (`~/.config/cursor`); its
554        // session data lives under `~/.cursor`.
555        assert_eq!(p.cursor_dir, home.join(".config").join("cursor"));
556        assert!(p.cursor_tracking_db.ends_with("ai-code-tracking.db"));
557        assert!(p.cursor_chats_dir.ends_with("chats"));
558
559        for d in [
560            &p.codex_dir,
561            &p.claude_dir,
562            &p.copilot_dir,
563            &p.gemini_dir,
564            &p.grok_dir,
565            &p.cache_dir,
566            &p.cursor_chats_dir,
567            &p.opencode_dir,
568        ] {
569            assert!(d.starts_with(home), "{d:?} should be under {home:?}");
570        }
571    }
572
573    #[test]
574    fn resolve_grok_home_honors_env_and_default() {
575        let home = Path::new("/home/u");
576        let explicit = Path::new("/opt/data/grok");
577
578        assert_eq!(resolve_grok_home(home, Some(explicit)), explicit);
579        assert_eq!(resolve_grok_home(home, None), home.join(".grok"));
580    }
581
582    #[test]
583    fn resolve_hermes_home_honors_env_and_platform_defaults() {
584        let home = Path::new("/home/u");
585
586        // HERMES_HOME wins on every platform.
587        let explicit = Path::new("/opt/data/hermes");
588        assert_eq!(
589            resolve_hermes_home(home, Some(explicit), None, false),
590            explicit
591        );
592        assert_eq!(
593            resolve_hermes_home(home, Some(explicit), Some(Path::new("/x")), true),
594            explicit
595        );
596
597        // POSIX default: ~/.hermes.
598        assert_eq!(
599            resolve_hermes_home(home, None, None, false),
600            home.join(".hermes")
601        );
602
603        // Windows default: %LOCALAPPDATA%\hermes, else ~/AppData/Local/hermes.
604        let local = Path::new("/c/Users/u/AppData/Local");
605        assert_eq!(
606            resolve_hermes_home(home, None, Some(local), true),
607            local.join("hermes")
608        );
609        assert_eq!(
610            resolve_hermes_home(home, None, None, true),
611            home.join("AppData").join("Local").join("hermes")
612        );
613    }
614
615    #[test]
616    fn resolve_paths_from_home_is_deterministic() {
617        let tmp = TempDir::new().unwrap();
618        let a = resolve_paths_from_home(tmp.path());
619        let b = resolve_paths_from_home(tmp.path());
620        assert_eq!(a.home_dir, b.home_dir);
621        assert_eq!(a.cache_dir, b.cache_dir);
622        assert_eq!(a.codex_dir, b.codex_dir);
623    }
624
625    #[test]
626    fn helper_paths_debug_and_clone() {
627        let tmp = TempDir::new().unwrap();
628        let p = resolve_paths_from_home(tmp.path());
629        let dbg = format!("{p:?}");
630        assert!(dbg.contains("home_dir"));
631        assert!(dbg.contains("cache_dir"));
632        let p2 = p.clone();
633        assert_eq!(p.home_dir, p2.home_dir);
634    }
635
636    #[test]
637    fn resolve_paths_succeeds_on_the_running_host() {
638        // Sanity: production resolution works wherever HOME is set (dev + CI).
639        assert!(resolve_paths().is_ok());
640    }
641
642    #[test]
643    fn pricing_cache_helpers_use_the_given_dir() {
644        let tmp = TempDir::new().unwrap();
645        let dir = tmp.path();
646
647        let path = get_pricing_cache_path_in(dir, "2024-01-15");
648        assert_eq!(path, dir.join("model_pricing_2024-01-15.json"));
649
650        // Absent → None; present → Some.
651        assert!(find_pricing_cache_for_date_in(dir, "2024-01-15").is_none());
652        std::fs::write(&path, "{}").unwrap();
653        assert_eq!(
654            find_pricing_cache_for_date_in(dir, "2024-01-15"),
655            Some(path.clone())
656        );
657
658        // Listing returns only `model_pricing_*.json` files.
659        std::fs::write(dir.join("unrelated.json"), "{}").unwrap();
660        let listed = list_pricing_cache_files_in(dir);
661        assert_eq!(listed.len(), 1);
662        assert!(listed[0].0.starts_with("model_pricing_"));
663        assert!(listed[0].0.ends_with(".json"));
664    }
665
666    #[test]
667    fn get_current_user_is_non_empty() {
668        let user = get_current_user();
669        assert!(!user.is_empty());
670        assert!(!user.contains('\0'));
671        assert!(user.len() < 256);
672    }
673
674    #[test]
675    fn get_machine_id_is_stable_and_non_empty() {
676        let a = get_machine_id();
677        let b = get_machine_id();
678        assert!(!a.is_empty());
679        assert!(!a.contains('\0'));
680        assert_eq!(a, b, "machine id is cached across calls");
681    }
682}