use std::path::PathBuf;
const DATA_MARKERS: &[&str] = &["stats.json", "sessions", "vectors", "graphs", "knowledge"];
pub fn lean_ctx_data_dir() -> Result<PathBuf, String> {
if let Ok(dir) = std::env::var("LEAN_CTX_DATA_DIR") {
let trimmed = dir.trim();
if !trimmed.is_empty() {
let p = PathBuf::from(trimmed);
ensure_dir_permissions(&p);
return Ok(p);
}
}
#[cfg(test)]
{
Ok(test_sandbox_dir())
}
#[cfg(not(test))]
{
resolve_home_data_dir()
}
}
#[cfg(test)]
pub(crate) fn test_sandbox_dir() -> PathBuf {
static TEST_SANDBOX: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
TEST_SANDBOX
.get_or_init(|| {
let d = std::env::temp_dir().join(format!("lean-ctx-testdata-{}", std::process::id()));
let _ = std::fs::create_dir_all(&d);
d
})
.clone()
}
fn resolve_home_data_dir() -> Result<PathBuf, String> {
let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
let legacy = home.join(".lean-ctx");
if legacy.exists() && has_data_files(&legacy) {
ensure_dir_permissions(&legacy);
return Ok(legacy);
}
let xdg_config = std::env::var("XDG_CONFIG_HOME")
.ok()
.filter(|s| !s.trim().is_empty())
.map_or_else(|| home.join(".config"), PathBuf::from);
let mixed_config = xdg_config.join("lean-ctx");
if mixed_config.exists() && has_data_files(&mixed_config) {
ensure_dir_permissions(&mixed_config);
return Ok(mixed_config);
}
if legacy.exists() {
ensure_dir_permissions(&legacy);
return Ok(legacy);
}
let xdg_data = std::env::var("XDG_DATA_HOME")
.ok()
.filter(|s| !s.trim().is_empty())
.map_or_else(|| home.join(".local").join("share"), PathBuf::from);
let data_dir = xdg_data.join("lean-ctx");
ensure_dir_permissions(&data_dir);
Ok(data_dir)
}
pub(crate) fn has_data_files(dir: &std::path::Path) -> bool {
DATA_MARKERS.iter().any(|f| dir.join(f).exists())
}
pub fn all_data_dirs_with_stats() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(home) = dirs::home_dir() {
let legacy = home.join(".lean-ctx");
if legacy.join("stats.json").exists() {
dirs.push(legacy);
}
let xdg_config = std::env::var("XDG_CONFIG_HOME")
.ok()
.filter(|s| !s.trim().is_empty())
.map_or_else(|| home.join(".config"), PathBuf::from)
.join("lean-ctx");
if xdg_config.join("stats.json").exists() && !dirs.contains(&xdg_config) {
dirs.push(xdg_config);
}
let xdg_data = std::env::var("XDG_DATA_HOME")
.ok()
.filter(|s| !s.trim().is_empty())
.map_or_else(|| home.join(".local").join("share"), PathBuf::from)
.join("lean-ctx");
if xdg_data.join("stats.json").exists() && !dirs.contains(&xdg_data) {
dirs.push(xdg_data);
}
}
dirs
}
#[cfg(unix)]
pub(crate) fn ensure_dir_permissions(path: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
if path.is_dir() {
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
}
}
#[cfg(not(unix))]
pub(crate) fn ensure_dir_permissions(_path: &std::path::Path) {}
pub fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
use std::sync::{Mutex, OnceLock};
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
let mutex = LOCK.get_or_init(|| Mutex::new(()));
mutex
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
pub struct IsolatedDataDir {
tmp: tempfile::TempDir,
_guard: std::sync::MutexGuard<'static, ()>,
}
#[cfg(test)]
impl IsolatedDataDir {
pub fn path(&self) -> &std::path::Path {
self.tmp.path()
}
}
#[cfg(test)]
const ISOLATED_ENV_VARS: &[&str] = &[
"LEAN_CTX_DATA_DIR",
"LEAN_CTX_CONFIG_DIR",
"LEAN_CTX_STATE_DIR",
"LEAN_CTX_CACHE_DIR",
];
#[cfg(test)]
impl Drop for IsolatedDataDir {
fn drop(&mut self) {
for var in ISOLATED_ENV_VARS {
std::env::remove_var(var);
}
}
}
#[cfg(test)]
pub fn isolated_data_dir() -> IsolatedDataDir {
let guard = test_env_lock();
let tmp = tempfile::tempdir().expect("tempdir for isolated data dir");
for var in ISOLATED_ENV_VARS {
std::env::set_var(var, tmp.path());
}
IsolatedDataDir { tmp, _guard: guard }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn has_data_files_empty_dir() {
let dir = std::env::temp_dir().join("test_data_dir_empty");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
assert!(!has_data_files(&dir));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn has_data_files_with_stats() {
let dir = std::env::temp_dir().join("test_data_dir_stats");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("stats.json"), "{}").unwrap();
assert!(has_data_files(&dir));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn has_data_files_ignores_config_only() {
let dir = std::env::temp_dir().join("test_data_dir_config_only");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("config.toml"), "").unwrap();
std::fs::write(dir.join("env.sh"), "").unwrap();
assert!(!has_data_files(&dir), "config-only dir is not a data dir");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn fresh_install_defaults_data_to_xdg_data_home() {
let _lock = test_env_lock();
let xdg_config = tempfile::tempdir().unwrap();
let xdg_data = tempfile::tempdir().unwrap();
std::env::set_var("LEAN_CTX_DATA_DIR", "");
std::env::set_var("XDG_CONFIG_HOME", xdg_config.path());
std::env::set_var("XDG_DATA_HOME", xdg_data.path());
let result = resolve_home_data_dir().unwrap();
std::env::remove_var("LEAN_CTX_DATA_DIR");
std::env::remove_var("XDG_CONFIG_HOME");
std::env::remove_var("XDG_DATA_HOME");
let legacy = dirs::home_dir().unwrap().join(".lean-ctx");
if !legacy.exists() {
assert_eq!(result, xdg_data.path().join("lean-ctx"));
}
}
#[test]
fn has_data_files_with_sessions() {
let dir = std::env::temp_dir().join("test_data_dir_sessions");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
let _ = std::fs::create_dir_all(dir.join("sessions"));
assert!(has_data_files(&dir));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn lean_ctx_data_dir_env_override() {
let _lock = test_env_lock();
let dir = std::env::temp_dir().join("test_data_dir_env");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
std::env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
let result = lean_ctx_data_dir().unwrap();
assert_eq!(result, dir);
std::env::remove_var("LEAN_CTX_DATA_DIR");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn has_data_files_is_false_for_empty_dir() {
let dir = std::env::temp_dir().join("test_data_dir_no_data");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
std::fs::write(dir.join("random.txt"), "not a marker").unwrap();
assert!(!has_data_files(&dir));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn xdg_override_with_data_wins() {
let _lock = test_env_lock();
let xdg_base = std::env::temp_dir().join("test_xdg_override_wins");
let _ = std::fs::remove_dir_all(&xdg_base);
let xdg_dir = xdg_base.join("lean-ctx");
let _ = std::fs::create_dir_all(&xdg_dir);
std::fs::write(xdg_dir.join("stats.json"), r#"{"total_commands":1}"#).unwrap();
std::env::set_var("LEAN_CTX_DATA_DIR", "");
std::env::set_var("XDG_CONFIG_HOME", xdg_base.to_str().unwrap());
let result = resolve_home_data_dir().unwrap();
std::env::remove_var("LEAN_CTX_DATA_DIR");
std::env::remove_var("XDG_CONFIG_HOME");
let home = dirs::home_dir().unwrap();
let legacy = home.join(".lean-ctx");
if !has_data_files(&legacy) {
assert_eq!(
result, xdg_dir,
"XDG with data should win when legacy has no data"
);
}
let _ = std::fs::remove_dir_all(&xdg_base);
}
}