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 persistent settings file path (`~/.vct/config.toml`).
391///
392/// As a side effect of resolving the cache directory, the directory is created
393/// if missing.
394///
395/// # Errors
396///
397/// Returns an error if the cache directory cannot be resolved or created.
398pub fn get_config_path() -> Result<PathBuf> {
399    Ok(get_cache_dir()?.join("config.toml"))
400}
401
402/// Returns this tool's own version record path (`~/.vct/version.json`).
403///
404/// Holds `{ latest_version, last_checked_at, dismissed_version }`, written by
405/// the update check as groundwork for a future auto-update prompt. As a side
406/// effect of resolving the cache directory, the directory is created if missing.
407///
408/// # Errors
409///
410/// Returns an error if the cache directory cannot be resolved or created.
411pub fn get_self_version_cache_path() -> Result<PathBuf> {
412    Ok(get_cache_dir()?.join("version.json"))
413}
414
415/// Returns the Copilot CLI config path (`~/.copilot/config.json`).
416///
417/// This file is JSONC (has `//` comments); callers must strip comments before
418/// parsing it as JSON.
419///
420/// # Errors
421///
422/// Returns an error if the user's home directory cannot be determined.
423pub fn get_copilot_config_path() -> Result<PathBuf> {
424    Ok(resolve_paths()?.copilot_dir.join("config.json"))
425}
426
427/// Returns the Cursor CLI OAuth credentials path
428/// (`$XDG_CONFIG_HOME/cursor/auth.json` or `~/.config/cursor/auth.json`).
429///
430/// # Errors
431///
432/// Returns an error if the user's home directory cannot be determined.
433pub fn get_cursor_auth_path() -> Result<PathBuf> {
434    Ok(resolve_paths()?.cursor_dir.join("auth.json"))
435}
436
437/// Returns the Claude OAuth credentials path (`~/.claude/.credentials.json`).
438///
439/// # Errors
440///
441/// Returns an error if the user's home directory cannot be determined.
442pub fn get_claude_credentials_path() -> Result<PathBuf> {
443    Ok(resolve_paths()?.claude_dir.join(".credentials.json"))
444}
445
446/// Returns the pricing cache path for `date` only if that file exists.
447///
448/// Yields `None` when the file is absent or when the cache directory cannot
449/// be resolved.
450pub fn find_pricing_cache_for_date(date: &str) -> Option<PathBuf> {
451    find_pricing_cache_for_date_in(&get_cache_dir().ok()?, date)
452}
453
454/// Returns the pricing cache path for `date` under an explicit cache dir, only
455/// if that file exists.
456///
457/// The env-free counterpart of [`find_pricing_cache_for_date`].
458pub fn find_pricing_cache_for_date_in(dir: &Path, date: &str) -> Option<PathBuf> {
459    let cache_path = get_pricing_cache_path_in(dir, date);
460    cache_path.exists().then_some(cache_path)
461}
462
463/// Lists every `model_pricing_*.json` file in the cache directory.
464///
465/// Each element is the `(filename, full_path)` pair for a file matching the
466/// `model_pricing_*.json` naming scheme; other directory entries are ignored.
467/// If the directory cannot be read, an empty `Vec` is returned rather than an
468/// error.
469///
470/// # Errors
471///
472/// Returns an error only if the cache directory cannot be resolved or created.
473pub fn list_pricing_cache_files() -> Result<Vec<(String, PathBuf)>> {
474    Ok(list_pricing_cache_files_in(&get_cache_dir()?))
475}
476
477/// Lists every `model_pricing_*.json` file in an explicit cache dir.
478///
479/// The env-free counterpart of [`list_pricing_cache_files`]; a missing or
480/// unreadable directory yields an empty `Vec`.
481pub fn list_pricing_cache_files_in(dir: &Path) -> Vec<(String, PathBuf)> {
482    let mut cache_files = Vec::new();
483
484    if let Ok(entries) = fs::read_dir(dir) {
485        for entry in entries.flatten() {
486            let path = entry.path();
487            if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
488                // Match pattern: model_pricing_YYYY-MM-DD.json
489                if filename.starts_with("model_pricing_") && filename.ends_with(".json") {
490                    cache_files.push((filename.to_string(), path));
491                }
492            }
493        }
494    }
495
496    cache_files
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use tempfile::TempDir;
503
504    #[test]
505    fn resolve_paths_from_home_composes_all_provider_paths() {
506        let tmp = TempDir::new().unwrap();
507        let home = tmp.path();
508        let p = resolve_paths_from_home(home);
509
510        assert_eq!(p.home_dir.as_path(), home);
511        assert!(p.codex_dir.ends_with(".codex"));
512        assert!(p.claude_dir.ends_with(".claude"));
513        assert!(p.copilot_dir.ends_with(".copilot"));
514        assert!(p.gemini_dir.ends_with(".gemini"));
515        assert!(p.grok_dir.ends_with(".grok"));
516        assert!(p.cache_dir.ends_with(".vct"));
517
518        assert_eq!(p.codex_session_dir, home.join(".codex").join("sessions"));
519        assert_eq!(p.claude_session_dir, home.join(".claude").join("projects"));
520        assert_eq!(
521            p.copilot_session_dir,
522            home.join(".copilot").join("session-state")
523        );
524        assert_eq!(p.gemini_session_dir, home.join(".gemini").join("tmp"));
525        assert_eq!(p.grok_session_dir, home.join(".grok").join("sessions"));
526        assert_eq!(p.opencode_db, p.opencode_dir.join("opencode.db"));
527        assert!(p.opencode_dir.ends_with("opencode"));
528        assert_eq!(p.hermes_db, home.join(".hermes").join("state.db"));
529
530        // Cursor config dir uses the non-XDG default (`~/.config/cursor`); its
531        // session data lives under `~/.cursor`.
532        assert_eq!(p.cursor_dir, home.join(".config").join("cursor"));
533        assert!(p.cursor_tracking_db.ends_with("ai-code-tracking.db"));
534        assert!(p.cursor_chats_dir.ends_with("chats"));
535
536        for d in [
537            &p.codex_dir,
538            &p.claude_dir,
539            &p.copilot_dir,
540            &p.gemini_dir,
541            &p.grok_dir,
542            &p.cache_dir,
543            &p.cursor_chats_dir,
544            &p.opencode_dir,
545        ] {
546            assert!(d.starts_with(home), "{d:?} should be under {home:?}");
547        }
548    }
549
550    #[test]
551    fn resolve_grok_home_honors_env_and_default() {
552        let home = Path::new("/home/u");
553        let explicit = Path::new("/opt/data/grok");
554
555        assert_eq!(resolve_grok_home(home, Some(explicit)), explicit);
556        assert_eq!(resolve_grok_home(home, None), home.join(".grok"));
557    }
558
559    #[test]
560    fn resolve_hermes_home_honors_env_and_platform_defaults() {
561        let home = Path::new("/home/u");
562
563        // HERMES_HOME wins on every platform.
564        let explicit = Path::new("/opt/data/hermes");
565        assert_eq!(
566            resolve_hermes_home(home, Some(explicit), None, false),
567            explicit
568        );
569        assert_eq!(
570            resolve_hermes_home(home, Some(explicit), Some(Path::new("/x")), true),
571            explicit
572        );
573
574        // POSIX default: ~/.hermes.
575        assert_eq!(
576            resolve_hermes_home(home, None, None, false),
577            home.join(".hermes")
578        );
579
580        // Windows default: %LOCALAPPDATA%\hermes, else ~/AppData/Local/hermes.
581        let local = Path::new("/c/Users/u/AppData/Local");
582        assert_eq!(
583            resolve_hermes_home(home, None, Some(local), true),
584            local.join("hermes")
585        );
586        assert_eq!(
587            resolve_hermes_home(home, None, None, true),
588            home.join("AppData").join("Local").join("hermes")
589        );
590    }
591
592    #[test]
593    fn resolve_paths_from_home_is_deterministic() {
594        let tmp = TempDir::new().unwrap();
595        let a = resolve_paths_from_home(tmp.path());
596        let b = resolve_paths_from_home(tmp.path());
597        assert_eq!(a.home_dir, b.home_dir);
598        assert_eq!(a.cache_dir, b.cache_dir);
599        assert_eq!(a.codex_dir, b.codex_dir);
600    }
601
602    #[test]
603    fn helper_paths_debug_and_clone() {
604        let tmp = TempDir::new().unwrap();
605        let p = resolve_paths_from_home(tmp.path());
606        let dbg = format!("{p:?}");
607        assert!(dbg.contains("home_dir"));
608        assert!(dbg.contains("cache_dir"));
609        let p2 = p.clone();
610        assert_eq!(p.home_dir, p2.home_dir);
611    }
612
613    #[test]
614    fn resolve_paths_succeeds_on_the_running_host() {
615        // Sanity: production resolution works wherever HOME is set (dev + CI).
616        assert!(resolve_paths().is_ok());
617    }
618
619    #[test]
620    fn pricing_cache_helpers_use_the_given_dir() {
621        let tmp = TempDir::new().unwrap();
622        let dir = tmp.path();
623
624        let path = get_pricing_cache_path_in(dir, "2024-01-15");
625        assert_eq!(path, dir.join("model_pricing_2024-01-15.json"));
626
627        // Absent → None; present → Some.
628        assert!(find_pricing_cache_for_date_in(dir, "2024-01-15").is_none());
629        std::fs::write(&path, "{}").unwrap();
630        assert_eq!(
631            find_pricing_cache_for_date_in(dir, "2024-01-15"),
632            Some(path.clone())
633        );
634
635        // Listing returns only `model_pricing_*.json` files.
636        std::fs::write(dir.join("unrelated.json"), "{}").unwrap();
637        let listed = list_pricing_cache_files_in(dir);
638        assert_eq!(listed.len(), 1);
639        assert!(listed[0].0.starts_with("model_pricing_"));
640        assert!(listed[0].0.ends_with(".json"));
641    }
642
643    #[test]
644    fn get_current_user_is_non_empty() {
645        let user = get_current_user();
646        assert!(!user.is_empty());
647        assert!(!user.contains('\0'));
648        assert!(user.len() < 256);
649    }
650
651    #[test]
652    fn get_machine_id_is_stable_and_non_empty() {
653        let a = get_machine_id();
654        let b = get_machine_id();
655        assert!(!a.is_empty());
656        assert!(!a.contains('\0'));
657        assert_eq!(a, b, "machine id is cached across calls");
658    }
659}