use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
#[derive(Debug, Clone)]
pub struct HelperPaths {
pub home_dir: PathBuf,
pub codex_dir: PathBuf,
pub codex_session_dir: PathBuf,
pub codex_archived_session_dir: PathBuf,
pub claude_dir: PathBuf,
pub claude_session_dir: PathBuf,
pub copilot_dir: PathBuf,
pub copilot_session_dir: PathBuf,
pub cursor_dir: PathBuf,
pub cursor_tracking_db: PathBuf,
pub cursor_chats_dir: PathBuf,
pub gemini_dir: PathBuf,
pub gemini_session_dir: PathBuf,
pub grok_dir: PathBuf,
pub grok_session_dir: PathBuf,
pub opencode_dir: PathBuf,
pub opencode_db: PathBuf,
pub hermes_db: PathBuf,
pub cache_dir: PathBuf,
}
impl HelperPaths {
pub fn codex_session_dirs(&self) -> [&Path; 2] {
[&self.codex_session_dir, &self.codex_archived_session_dir]
}
}
pub fn resolve_paths() -> Result<HelperPaths> {
let home_dir =
home::home_dir().ok_or_else(|| anyhow::anyhow!("Unable to resolve user home directory"))?;
let xdg_config = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute());
let xdg_data = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute());
let grok_home_env = std::env::var_os("GROK_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty());
let grok_home = resolve_grok_home(&home_dir, grok_home_env.as_deref());
let hermes_home_env = std::env::var_os("HERMES_HOME")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty());
let local_appdata = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty());
let hermes_home = resolve_hermes_home(
&home_dir,
hermes_home_env.as_deref(),
local_appdata.as_deref(),
cfg!(target_os = "windows"),
);
Ok(build_paths(
&home_dir,
xdg_config.as_deref(),
xdg_data.as_deref(),
Some(&hermes_home),
Some(&grok_home),
))
}
fn resolve_grok_home(home_dir: &Path, grok_home: Option<&Path>) -> PathBuf {
grok_home
.map(Path::to_path_buf)
.unwrap_or_else(|| home_dir.join(".grok"))
}
fn resolve_hermes_home(
home_dir: &Path,
hermes_home: Option<&Path>,
local_appdata: Option<&Path>,
is_windows: bool,
) -> PathBuf {
if let Some(home) = hermes_home {
return home.to_path_buf();
}
if is_windows {
return local_appdata
.map(Path::to_path_buf)
.unwrap_or_else(|| home_dir.join("AppData").join("Local"))
.join("hermes");
}
home_dir.join(".hermes")
}
pub fn resolve_paths_from_home(home_dir: &Path) -> HelperPaths {
build_paths(home_dir, None, None, None, None)
}
fn build_paths(
home_dir: &Path,
xdg_config: Option<&Path>,
xdg_data: Option<&Path>,
hermes_home: Option<&Path>,
grok_home: Option<&Path>,
) -> HelperPaths {
let codex_dir = home_dir.join(".codex");
let codex_session_dir = codex_dir.join("sessions");
let codex_archived_session_dir = codex_dir.join("archived_sessions");
let claude_dir = home_dir.join(".claude");
let claude_session_dir = claude_dir.join("projects");
let copilot_dir = home_dir.join(".copilot");
let copilot_session_dir = copilot_dir.join("session-state");
let cursor_dir = xdg_config
.map(Path::to_path_buf)
.unwrap_or_else(|| home_dir.join(".config"))
.join("cursor");
let cursor_data_dir = home_dir.join(".cursor");
let cursor_tracking_db = cursor_data_dir
.join("ai-tracking")
.join("ai-code-tracking.db");
let cursor_chats_dir = cursor_data_dir.join("chats");
let gemini_dir = home_dir.join(".gemini");
let gemini_session_dir = gemini_dir.join("tmp");
let grok_dir = resolve_grok_home(home_dir, grok_home);
let grok_session_dir = grok_dir.join("sessions");
let opencode_dir = xdg_data
.map(Path::to_path_buf)
.unwrap_or_else(|| home_dir.join(".local").join("share"))
.join("opencode");
let opencode_db = opencode_dir.join("opencode.db");
let hermes_db = hermes_home
.map(Path::to_path_buf)
.unwrap_or_else(|| home_dir.join(".hermes"))
.join("state.db");
let cache_dir = home_dir.join(".vct");
HelperPaths {
home_dir: home_dir.to_path_buf(),
codex_dir,
codex_session_dir,
codex_archived_session_dir,
claude_dir,
claude_session_dir,
copilot_dir,
copilot_session_dir,
cursor_dir,
cursor_tracking_db,
cursor_chats_dir,
gemini_dir,
gemini_session_dir,
grok_dir,
grok_session_dir,
opencode_dir,
opencode_db,
hermes_db,
cache_dir,
}
}
pub fn network_disabled() -> bool {
std::env::var_os("VCT_OFFLINE").is_some_and(|v| !v.is_empty())
}
pub fn get_current_user() -> String {
static CACHE: OnceLock<String> = OnceLock::new();
CACHE
.get_or_init(|| {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "unknown".to_string())
})
.clone()
}
static MACHINE_ID_CACHE: OnceLock<String> = OnceLock::new();
fn get_home_dir() -> Result<PathBuf> {
home::home_dir().ok_or_else(|| anyhow::anyhow!("Unable to resolve user home directory"))
}
pub fn get_machine_id() -> &'static str {
MACHINE_ID_CACHE.get_or_init(|| {
if let Ok(id) = std::fs::read_to_string("/etc/machine-id") {
return id.trim().to_string();
}
if let Ok(hostname) = hostname::get()
&& let Some(hostname_str) = hostname.to_str()
{
return hostname_str.to_string();
}
"unknown-machine-id".to_string()
})
}
pub fn get_cache_dir() -> Result<PathBuf> {
let home_dir = get_home_dir()?;
let cache_dir = home_dir.join(".vct");
if !cache_dir.exists() {
fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?;
}
Ok(cache_dir)
}
pub fn get_pricing_cache_path(date: &str) -> Result<PathBuf> {
Ok(get_pricing_cache_path_in(&get_cache_dir()?, date))
}
pub fn get_pricing_cache_path_in(dir: &Path, date: &str) -> PathBuf {
dir.join(format!("model_pricing_{}.json", date))
}
pub fn get_claude_usage_cache_path() -> Result<PathBuf> {
Ok(get_cache_dir()?.join("claude_usage.json"))
}
pub fn get_codex_usage_cache_path() -> Result<PathBuf> {
Ok(get_cache_dir()?.join("codex_usage.json"))
}
pub fn get_copilot_usage_cache_path() -> Result<PathBuf> {
Ok(get_cache_dir()?.join("copilot_usage.json"))
}
pub fn get_cursor_usage_cache_path() -> Result<PathBuf> {
Ok(get_cache_dir()?.join("cursor_usage.json"))
}
pub fn get_grok_usage_cache_path() -> Result<PathBuf> {
Ok(get_cache_dir()?.join("grok_usage.json"))
}
pub fn get_config_path() -> Result<PathBuf> {
Ok(get_cache_dir()?.join("config.toml"))
}
pub fn get_self_version_cache_path() -> Result<PathBuf> {
Ok(get_cache_dir()?.join("version.json"))
}
pub fn get_copilot_config_path() -> Result<PathBuf> {
Ok(resolve_paths()?.copilot_dir.join("config.json"))
}
pub fn get_cursor_auth_path() -> Result<PathBuf> {
Ok(resolve_paths()?.cursor_dir.join("auth.json"))
}
pub fn get_grok_auth_path() -> Result<PathBuf> {
Ok(resolve_paths()?.grok_dir.join("auth.json"))
}
pub fn get_claude_credentials_path() -> Result<PathBuf> {
Ok(resolve_paths()?.claude_dir.join(".credentials.json"))
}
pub fn find_pricing_cache_for_date(date: &str) -> Option<PathBuf> {
find_pricing_cache_for_date_in(&get_cache_dir().ok()?, date)
}
pub fn find_pricing_cache_for_date_in(dir: &Path, date: &str) -> Option<PathBuf> {
let cache_path = get_pricing_cache_path_in(dir, date);
cache_path.exists().then_some(cache_path)
}
pub fn list_pricing_cache_files() -> Result<Vec<(String, PathBuf)>> {
Ok(list_pricing_cache_files_in(&get_cache_dir()?))
}
pub fn list_pricing_cache_files_in(dir: &Path) -> Vec<(String, PathBuf)> {
let mut cache_files = Vec::new();
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if filename.starts_with("model_pricing_") && filename.ends_with(".json") {
cache_files.push((filename.to_string(), path));
}
}
}
}
cache_files
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn resolve_paths_from_home_composes_all_provider_paths() {
let tmp = TempDir::new().unwrap();
let home = tmp.path();
let p = resolve_paths_from_home(home);
assert_eq!(p.home_dir.as_path(), home);
assert!(p.codex_dir.ends_with(".codex"));
assert!(p.claude_dir.ends_with(".claude"));
assert!(p.copilot_dir.ends_with(".copilot"));
assert!(p.gemini_dir.ends_with(".gemini"));
assert!(p.grok_dir.ends_with(".grok"));
assert!(p.cache_dir.ends_with(".vct"));
assert_eq!(p.codex_session_dir, home.join(".codex").join("sessions"));
assert_eq!(
p.codex_archived_session_dir,
home.join(".codex").join("archived_sessions")
);
assert_eq!(
p.codex_session_dirs(),
[
p.codex_session_dir.as_path(),
p.codex_archived_session_dir.as_path()
]
);
assert_eq!(p.claude_session_dir, home.join(".claude").join("projects"));
assert_eq!(
p.copilot_session_dir,
home.join(".copilot").join("session-state")
);
assert_eq!(p.gemini_session_dir, home.join(".gemini").join("tmp"));
assert_eq!(p.grok_session_dir, home.join(".grok").join("sessions"));
assert_eq!(p.opencode_db, p.opencode_dir.join("opencode.db"));
assert!(p.opencode_dir.ends_with("opencode"));
assert_eq!(p.hermes_db, home.join(".hermes").join("state.db"));
assert_eq!(p.cursor_dir, home.join(".config").join("cursor"));
assert!(p.cursor_tracking_db.ends_with("ai-code-tracking.db"));
assert!(p.cursor_chats_dir.ends_with("chats"));
for d in [
&p.codex_dir,
&p.claude_dir,
&p.copilot_dir,
&p.gemini_dir,
&p.grok_dir,
&p.cache_dir,
&p.cursor_chats_dir,
&p.opencode_dir,
] {
assert!(d.starts_with(home), "{d:?} should be under {home:?}");
}
}
#[test]
fn resolve_grok_home_honors_env_and_default() {
let home = Path::new("/home/u");
let explicit = Path::new("/opt/data/grok");
assert_eq!(resolve_grok_home(home, Some(explicit)), explicit);
assert_eq!(resolve_grok_home(home, None), home.join(".grok"));
}
#[test]
fn resolve_hermes_home_honors_env_and_platform_defaults() {
let home = Path::new("/home/u");
let explicit = Path::new("/opt/data/hermes");
assert_eq!(
resolve_hermes_home(home, Some(explicit), None, false),
explicit
);
assert_eq!(
resolve_hermes_home(home, Some(explicit), Some(Path::new("/x")), true),
explicit
);
assert_eq!(
resolve_hermes_home(home, None, None, false),
home.join(".hermes")
);
let local = Path::new("/c/Users/u/AppData/Local");
assert_eq!(
resolve_hermes_home(home, None, Some(local), true),
local.join("hermes")
);
assert_eq!(
resolve_hermes_home(home, None, None, true),
home.join("AppData").join("Local").join("hermes")
);
}
#[test]
fn resolve_paths_from_home_is_deterministic() {
let tmp = TempDir::new().unwrap();
let a = resolve_paths_from_home(tmp.path());
let b = resolve_paths_from_home(tmp.path());
assert_eq!(a.home_dir, b.home_dir);
assert_eq!(a.cache_dir, b.cache_dir);
assert_eq!(a.codex_dir, b.codex_dir);
}
#[test]
fn helper_paths_debug_and_clone() {
let tmp = TempDir::new().unwrap();
let p = resolve_paths_from_home(tmp.path());
let dbg = format!("{p:?}");
assert!(dbg.contains("home_dir"));
assert!(dbg.contains("cache_dir"));
let p2 = p.clone();
assert_eq!(p.home_dir, p2.home_dir);
}
#[test]
fn resolve_paths_succeeds_on_the_running_host() {
assert!(resolve_paths().is_ok());
}
#[test]
fn pricing_cache_helpers_use_the_given_dir() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let path = get_pricing_cache_path_in(dir, "2024-01-15");
assert_eq!(path, dir.join("model_pricing_2024-01-15.json"));
assert!(find_pricing_cache_for_date_in(dir, "2024-01-15").is_none());
std::fs::write(&path, "{}").unwrap();
assert_eq!(
find_pricing_cache_for_date_in(dir, "2024-01-15"),
Some(path.clone())
);
std::fs::write(dir.join("unrelated.json"), "{}").unwrap();
let listed = list_pricing_cache_files_in(dir);
assert_eq!(listed.len(), 1);
assert!(listed[0].0.starts_with("model_pricing_"));
assert!(listed[0].0.ends_with(".json"));
}
#[test]
fn get_current_user_is_non_empty() {
let user = get_current_user();
assert!(!user.is_empty());
assert!(!user.contains('\0'));
assert!(user.len() < 256);
}
#[test]
fn get_machine_id_is_stable_and_non_empty() {
let a = get_machine_id();
let b = get_machine_id();
assert!(!a.is_empty());
assert!(!a.contains('\0'));
assert_eq!(a, b, "machine id is cached across calls");
}
}