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