use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
pub static HOME: LazyLock<PathBuf> =
LazyLock::new(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")));
pub static CLAUDE_CONFIG_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
std::env::var_os("CLAUDE_CONFIG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| claude_config_dir_in(&HOME))
});
pub static CLAUDE_PROJECTS_ROOT: LazyLock<PathBuf> =
LazyLock::new(|| CLAUDE_CONFIG_DIR.join("projects"));
pub static CODEX_HOME: LazyLock<PathBuf> = LazyLock::new(|| {
std::env::var_os("CODEX_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| HOME.join(".codex"))
});
pub static CODEX_SESSIONS_ROOT: LazyLock<PathBuf> = LazyLock::new(|| CODEX_HOME.join("sessions"));
pub static CURSOR_HOME: LazyLock<PathBuf> = LazyLock::new(|| {
std::env::var_os("CURSOR_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| HOME.join(".cursor"))
});
pub static CURSOR_PROJECTS_ROOT: LazyLock<PathBuf> = LazyLock::new(|| CURSOR_HOME.join("projects"));
pub static PI_AGENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
std::env::var_os("PI_CODING_AGENT_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| HOME.join(".pi").join("agent"))
});
pub static PI_SESSIONS_ROOT: LazyLock<PathBuf> = LazyLock::new(|| {
std::env::var_os("PI_CODING_AGENT_SESSION_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| PI_AGENT_DIR.join("sessions"))
});
pub static GEMINI_HOME: LazyLock<PathBuf> = LazyLock::new(|| {
std::env::var_os("GEMINI_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| HOME.join(".gemini"))
});
pub static GEMINI_CHATS_ROOT: LazyLock<PathBuf> = LazyLock::new(|| GEMINI_HOME.join("tmp"));
pub static WINDSURF_USER_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
if let Some(dir) = std::env::var_os("WINDSURF_USER_DIR") {
return PathBuf::from(dir);
}
let base = if cfg!(target_os = "macos") {
HOME.join("Library").join("Application Support")
} else if cfg!(target_os = "windows") {
std::env::var_os("APPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| HOME.join("AppData").join("Roaming"))
} else {
dirs::config_dir().unwrap_or_else(|| HOME.join(".config"))
};
base.join("Windsurf").join("User")
});
pub static WINDSURF_WORKSPACE_STORAGE: LazyLock<PathBuf> =
LazyLock::new(|| WINDSURF_USER_DIR.join("workspaceStorage"));
pub static OPENCODE_DATA_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
std::env::var_os("OPENCODE_DATA_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
dirs::data_dir()
.unwrap_or_else(|| HOME.join(".local").join("share"))
.join("opencode")
})
});
pub static OPENCODE_CONFIG_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
std::env::var_os("OPENCODE_CONFIG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
dirs::config_dir()
.unwrap_or_else(|| HOME.join(".config"))
.join("opencode")
})
});
pub static CLAUDE_MAC_COWORK_ROOT: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
cfg!(target_os = "macos").then(|| {
HOME.join("Library")
.join("Application Support")
.join("Claude")
.join("local-agent-mode-sessions")
})
});
pub static CLAUDE_MAC_CODE_ROOT: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
cfg!(target_os = "macos").then(|| {
HOME.join("Library")
.join("Application Support")
.join("Claude")
.join("claude-code-sessions")
})
});
pub static CACHE_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
dirs::cache_dir()
.unwrap_or_else(|| HOME.join(".cache"))
.join("cctop")
});
pub static COST_CACHE_FILE: LazyLock<PathBuf> = LazyLock::new(|| CACHE_DIR.join("cost-cache.json"));
pub static PRICING_CACHE_FILE: LazyLock<PathBuf> =
LazyLock::new(|| CACHE_DIR.join("litellm-pricing.json"));
pub static UI_PREFS_FILE: LazyLock<PathBuf> = LazyLock::new(|| CACHE_DIR.join("ui-prefs.json"));
pub const LITELLM_URL: &str =
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
pub const PRICING_CACHE_MAX_AGE_SECS: u64 = 24 * 60 * 60;
pub static COMPACT_THRESHOLD: LazyLock<f64> = LazyLock::new(|| {
std::env::var("CLAUDE_AUTOCOMPACT_PCT_OVERRIDE")
.ok()
.and_then(|v| v.parse::<f64>().ok())
.map(|p| p / 100.0)
.unwrap_or(0.835)
});
pub const MAX_JSONL_LINE_BYTES: usize = 512 * 1024;
pub const MAX_TOOL_DETAILS: usize = 200;
pub const MAX_SESSION_TOOL_DETAILS: usize = 400;
pub const MAX_TOOL_DETAIL_CHARS: usize = 800;
pub const MAX_DIFF_LINES: usize = 60;
pub const MAX_DIFF_LINE_CHARS: usize = 300;
#[derive(Debug, Clone)]
pub struct OtherHome {
pub home: PathBuf,
pub user: String,
}
fn all_users_wanted() -> bool {
match std::env::var("CCTOP_ALL_USERS") {
Ok(v) => !matches!(
v.trim().to_ascii_lowercase().as_str(),
"" | "0" | "false" | "no"
),
Err(_) => running_as_root(),
}
}
fn named_homes() -> Vec<(PathBuf, String)> {
let Some(list) = std::env::var_os("CCTOP_HOMES") else {
return Vec::new();
};
std::env::split_paths(&list)
.filter(|p| !p.as_os_str().is_empty())
.map(|path| {
let user = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
(path, user)
})
.collect()
}
#[cfg(unix)]
fn running_as_root() -> bool {
unsafe { libc::geteuid() == 0 }
}
#[cfg(not(unix))]
fn running_as_root() -> bool {
false
}
pub static OTHER_HOMES: LazyLock<Vec<OtherHome>> = LazyLock::new(|| {
let named = named_homes();
if named.is_empty() && !all_users_wanted() {
return Vec::new();
}
let mut seen: HashSet<PathBuf> = HashSet::from([HOME.clone()]);
let mut out = Vec::new();
for (home, user) in named {
push_home(&mut out, &mut seen, home, user);
}
if !all_users_wanted() {
return out;
}
if let Ok(passwd) = std::fs::read_to_string("/etc/passwd") {
for (home, user) in passwd_homes(&passwd) {
push_home(&mut out, &mut seen, home, user);
}
}
for parent in ["/home", "/Users"].map(PathBuf::from) {
for name in list_dir(&parent) {
push_home(&mut out, &mut seen, parent.join(&name), name);
}
}
out
});
const UID_MIN: u32 = if cfg!(target_os = "macos") { 500 } else { 1000 };
fn passwd_homes(text: &str) -> Vec<(PathBuf, String)> {
text.lines()
.filter_map(|line| {
let fields: Vec<&str> = line.split(':').collect();
let [user, _, uid, _, _, home, ..] = fields[..] else {
return None;
};
let uid: u32 = uid.parse().ok()?;
let person = uid == 0 || uid >= UID_MIN;
(person && !user.is_empty() && !home.is_empty())
.then(|| (PathBuf::from(home), user.to_string()))
})
.collect()
}
fn push_home(out: &mut Vec<OtherHome>, seen: &mut HashSet<PathBuf>, home: PathBuf, user: String) {
if home.parent().is_none() || !seen.insert(home.clone()) || !home.is_dir() {
return;
}
out.push(OtherHome { home, user });
}
pub fn roots_across_homes(primary: &Path, derive: impl Fn(&Path) -> PathBuf) -> Vec<PathBuf> {
let mut roots = vec![primary.to_path_buf()];
roots.extend(OTHER_HOMES.iter().map(|o| derive(&o.home)));
roots
}
pub fn claude_config_dir_in(home: &Path) -> PathBuf {
home.join(".claude")
}
pub fn claude_projects_roots() -> Vec<PathBuf> {
roots_across_homes(&CLAUDE_PROJECTS_ROOT, |h| {
claude_config_dir_in(h).join("projects")
})
}
pub fn codex_sessions_roots() -> Vec<PathBuf> {
roots_across_homes(&CODEX_SESSIONS_ROOT, |h| h.join(".codex").join("sessions"))
}
pub fn cursor_projects_roots() -> Vec<PathBuf> {
roots_across_homes(&CURSOR_PROJECTS_ROOT, |h| {
h.join(".cursor").join("projects")
})
}
pub fn pi_sessions_roots() -> Vec<PathBuf> {
roots_across_homes(&PI_SESSIONS_ROOT, |h| {
h.join(".pi").join("agent").join("sessions")
})
}
pub fn gemini_chats_roots() -> Vec<PathBuf> {
roots_across_homes(&GEMINI_CHATS_ROOT, |h| h.join(".gemini").join("tmp"))
}
fn data_dir_in(home: &Path) -> PathBuf {
if cfg!(target_os = "macos") {
home.join("Library").join("Application Support")
} else {
home.join(".local").join("share")
}
}
fn config_dir_in(home: &Path) -> PathBuf {
if cfg!(target_os = "macos") {
home.join("Library").join("Application Support")
} else {
home.join(".config")
}
}
pub fn opencode_data_roots() -> Vec<PathBuf> {
roots_across_homes(&OPENCODE_DATA_DIR, |h| data_dir_in(h).join("opencode"))
}
pub fn windsurf_workspace_roots() -> Vec<PathBuf> {
roots_across_homes(&WINDSURF_WORKSPACE_STORAGE, |h| {
config_dir_in(h)
.join("Windsurf")
.join("User")
.join("workspaceStorage")
})
}
pub fn claude_mac_roots(primary: &Option<PathBuf>, leaf: &str) -> Vec<PathBuf> {
let Some(primary) = primary.as_ref() else {
return Vec::new();
};
roots_across_homes(primary, |h| {
h.join("Library")
.join("Application Support")
.join("Claude")
.join(leaf)
})
}
pub fn owner_of(path: &Path) -> Option<&'static str> {
OTHER_HOMES
.iter()
.find(|o| path.starts_with(&o.home))
.map(|o| o.user.as_str())
}
pub const CLAUDE_DEFAULT_CTX: u64 = 200_000;
pub const CLAUDE_1M_CTX: u64 = 1_000_000;
pub const CODEX_DEFAULT_CTX: u64 = 258_400;
pub fn is_full_uuid(s: &str) -> bool {
let b = s.as_bytes();
if b.len() != 36 {
return false;
}
for (i, c) in b.iter().enumerate() {
match i {
8 | 13 | 18 | 23 => {
if *c != b'-' {
return false;
}
}
_ => {
if !c.is_ascii_hexdigit() || c.is_ascii_uppercase() {
return false;
}
}
}
}
true
}
pub fn trailing_uuid(stem: &str) -> Option<&str> {
if stem.len() < 36 {
return None;
}
let tail = &stem[stem.len() - 36..];
is_full_uuid(tail).then_some(tail)
}
pub fn dir_exists(p: &Path) -> bool {
p.is_dir()
}
pub fn list_dir(p: &Path) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(p) else {
return Vec::new();
};
let mut out: Vec<String> = rd
.flatten()
.filter_map(|e| e.file_name().into_string().ok())
.collect();
out.sort();
out
}
pub fn rglob(dir: &Path, ext: &str) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else {
continue;
};
for entry in rd.flatten() {
let Ok(ft) = entry.file_type() else { continue };
let path = entry.path();
if ft.is_dir() {
stack.push(path);
} else if ft.is_file() && path.to_string_lossy().ends_with(ext) {
out.push(path);
}
}
}
out
}
pub fn file_mtime_ms(p: &Path) -> u64 {
std::fs::metadata(p)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uuid_validation() {
assert!(is_full_uuid("7026d578-8cba-4880-b464-9700f1b77b71"));
assert!(!is_full_uuid("7026D578-8CBA-4880-B464-9700F1B77B71")); assert!(!is_full_uuid("7026d578-8cba-4880-b464-9700f1b77b7")); assert!(!is_full_uuid("7026d5788cba4880b4649700f1b77b71")); }
#[test]
fn passwd_parsing_takes_the_home_field() {
let homes = passwd_homes(
"root:x:0:0:root:/root:/bin/bash\n\
ana:x:1000:1000:Ana,,,:/home/ana:/bin/zsh\n\
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin\n\
# comment\n\
truncated:x:1001",
);
assert_eq!(
homes,
vec![
(PathBuf::from("/root"), "root".to_string()),
(PathBuf::from("/home/ana"), "ana".to_string()),
]
);
}
#[test]
fn homes_are_deduped_and_must_exist() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("ana");
std::fs::create_dir(&real).unwrap();
let mut out = Vec::new();
let mut seen = HashSet::new();
push_home(&mut out, &mut seen, real.clone(), "ana".into());
push_home(&mut out, &mut seen, real, "ana-again".into());
push_home(&mut out, &mut seen, dir.path().join("gone"), "gone".into());
push_home(&mut out, &mut seen, PathBuf::from("/"), "sync".into());
let users: Vec<&str> = out.iter().map(|o| o.user.as_str()).collect();
assert_eq!(users, ["ana"]);
}
#[test]
fn uuid_extraction() {
let stem = "rollout-2026-06-29T10-59-07-019f1075-3f22-7ad0-b496-73dcda6a7a25";
assert_eq!(
trailing_uuid(stem),
Some("019f1075-3f22-7ad0-b496-73dcda6a7a25")
);
}
}