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}