lean_ctx/core/
data_dir.rs1use std::path::PathBuf;
2
3const DATA_MARKERS: &[&str] = &["stats.json", "sessions", "vectors", "graphs", "knowledge"];
11
12pub fn lean_ctx_data_dir() -> Result<PathBuf, String> {
25 if let Ok(dir) = std::env::var("LEAN_CTX_DATA_DIR") {
26 let trimmed = dir.trim();
27 if !trimmed.is_empty() {
28 let p = PathBuf::from(trimmed);
29 ensure_dir_permissions(&p);
30 return Ok(p);
31 }
32 }
33
34 #[cfg(test)]
41 {
42 Ok(test_sandbox_dir())
43 }
44
45 #[cfg(not(test))]
46 {
47 resolve_home_data_dir()
48 }
49}
50
51#[cfg(test)]
57pub(crate) fn test_sandbox_dir() -> PathBuf {
58 static TEST_SANDBOX: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
59 TEST_SANDBOX
60 .get_or_init(|| {
61 let d = std::env::temp_dir().join(format!("lean-ctx-testdata-{}", std::process::id()));
62 let _ = std::fs::create_dir_all(&d);
63 d
64 })
65 .clone()
66}
67
68fn resolve_home_data_dir() -> Result<PathBuf, String> {
71 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
72
73 let legacy = home.join(".lean-ctx");
75 if legacy.exists() && has_data_files(&legacy) {
76 ensure_dir_permissions(&legacy);
77 return Ok(legacy);
78 }
79
80 let xdg_config = std::env::var("XDG_CONFIG_HOME")
84 .ok()
85 .filter(|s| !s.trim().is_empty())
86 .map_or_else(|| home.join(".config"), PathBuf::from);
87 let mixed_config = xdg_config.join("lean-ctx");
88 if mixed_config.exists() && has_data_files(&mixed_config) {
89 ensure_dir_permissions(&mixed_config);
90 return Ok(mixed_config);
91 }
92
93 if legacy.exists() {
96 ensure_dir_permissions(&legacy);
97 return Ok(legacy);
98 }
99
100 let xdg_data = std::env::var("XDG_DATA_HOME")
104 .ok()
105 .filter(|s| !s.trim().is_empty())
106 .map_or_else(|| home.join(".local").join("share"), PathBuf::from);
107 let data_dir = xdg_data.join("lean-ctx");
108 ensure_dir_permissions(&data_dir);
109 Ok(data_dir)
110}
111
112pub(crate) fn has_data_files(dir: &std::path::Path) -> bool {
113 DATA_MARKERS.iter().any(|f| dir.join(f).exists())
114}
115
116pub fn all_data_dirs_with_stats() -> Vec<PathBuf> {
119 let mut dirs = Vec::new();
120 if let Some(home) = dirs::home_dir() {
121 let legacy = home.join(".lean-ctx");
122 if legacy.join("stats.json").exists() {
123 dirs.push(legacy);
124 }
125 let xdg = std::env::var("XDG_CONFIG_HOME")
126 .ok()
127 .filter(|s| !s.trim().is_empty())
128 .map_or_else(|| home.join(".config"), PathBuf::from)
129 .join("lean-ctx");
130 if xdg.join("stats.json").exists() && !dirs.contains(&xdg) {
131 dirs.push(xdg);
132 }
133 }
134 dirs
135}
136
137pub fn migrate_if_split() -> Option<u64> {
140 let dirs = all_data_dirs_with_stats();
141 if dirs.len() < 2 {
142 return None;
143 }
144
145 let primary = lean_ctx_data_dir().ok()?;
146 let secondary = dirs.iter().find(|d| **d != primary)?;
147
148 let sec_content = std::fs::read_to_string(secondary.join("stats.json")).ok()?;
149 let sec_store: serde_json::Value = serde_json::from_str(&sec_content).ok()?;
150 let sec_commands = sec_store["total_commands"].as_u64().unwrap_or(0);
151 if sec_commands == 0 {
152 return None;
153 }
154
155 let primary_path = primary.join("stats.json");
156 if !primary_path.exists() {
157 let _ = std::fs::create_dir_all(&primary);
158 let _ = std::fs::copy(secondary.join("stats.json"), &primary_path);
159 let _ = std::fs::remove_file(secondary.join("stats.json"));
160 let tokens = sec_store["total_input_tokens"]
161 .as_u64()
162 .unwrap_or(0)
163 .saturating_sub(sec_store["total_output_tokens"].as_u64().unwrap_or(0));
164 return Some(tokens);
165 }
166
167 None
168}
169
170#[cfg(unix)]
171pub(crate) fn ensure_dir_permissions(path: &std::path::Path) {
172 use std::os::unix::fs::PermissionsExt;
173 if path.is_dir() {
174 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
175 }
176}
177
178#[cfg(not(unix))]
179pub(crate) fn ensure_dir_permissions(_path: &std::path::Path) {}
180
181pub fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
182 use std::sync::{Mutex, OnceLock};
183 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
184 let mutex = LOCK.get_or_init(|| Mutex::new(()));
185 mutex
186 .lock()
187 .unwrap_or_else(std::sync::PoisonError::into_inner)
188}
189
190#[cfg(test)]
198pub struct IsolatedDataDir {
199 tmp: tempfile::TempDir,
200 _guard: std::sync::MutexGuard<'static, ()>,
201}
202
203#[cfg(test)]
204impl IsolatedDataDir {
205 pub fn path(&self) -> &std::path::Path {
206 self.tmp.path()
207 }
208}
209
210#[cfg(test)]
213const ISOLATED_ENV_VARS: &[&str] = &[
214 "LEAN_CTX_DATA_DIR",
215 "LEAN_CTX_CONFIG_DIR",
216 "LEAN_CTX_STATE_DIR",
217 "LEAN_CTX_CACHE_DIR",
218];
219
220#[cfg(test)]
221impl Drop for IsolatedDataDir {
222 fn drop(&mut self) {
223 for var in ISOLATED_ENV_VARS {
226 std::env::remove_var(var);
227 }
228 }
229}
230
231#[cfg(test)]
232pub fn isolated_data_dir() -> IsolatedDataDir {
233 let guard = test_env_lock();
234 let tmp = tempfile::tempdir().expect("tempdir for isolated data dir");
235 for var in ISOLATED_ENV_VARS {
236 std::env::set_var(var, tmp.path());
237 }
238 IsolatedDataDir { tmp, _guard: guard }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn has_data_files_empty_dir() {
247 let dir = std::env::temp_dir().join("test_data_dir_empty");
248 let _ = std::fs::remove_dir_all(&dir);
249 let _ = std::fs::create_dir_all(&dir);
250 assert!(!has_data_files(&dir));
251 let _ = std::fs::remove_dir_all(&dir);
252 }
253
254 #[test]
255 fn has_data_files_with_stats() {
256 let dir = std::env::temp_dir().join("test_data_dir_stats");
257 let _ = std::fs::remove_dir_all(&dir);
258 let _ = std::fs::create_dir_all(&dir);
259 std::fs::write(dir.join("stats.json"), "{}").unwrap();
260 assert!(has_data_files(&dir));
261 let _ = std::fs::remove_dir_all(&dir);
262 }
263
264 #[test]
265 fn has_data_files_ignores_config_only() {
266 let dir = std::env::temp_dir().join("test_data_dir_config_only");
270 let _ = std::fs::remove_dir_all(&dir);
271 let _ = std::fs::create_dir_all(&dir);
272 std::fs::write(dir.join("config.toml"), "").unwrap();
273 std::fs::write(dir.join("env.sh"), "").unwrap();
274 assert!(!has_data_files(&dir), "config-only dir is not a data dir");
275 let _ = std::fs::remove_dir_all(&dir);
276 }
277
278 #[test]
279 fn fresh_install_defaults_data_to_xdg_data_home() {
280 let _lock = test_env_lock();
283 let xdg_config = tempfile::tempdir().unwrap();
284 let xdg_data = tempfile::tempdir().unwrap();
285 std::env::set_var("LEAN_CTX_DATA_DIR", "");
286 std::env::set_var("XDG_CONFIG_HOME", xdg_config.path());
287 std::env::set_var("XDG_DATA_HOME", xdg_data.path());
288
289 let result = resolve_home_data_dir().unwrap();
290
291 std::env::remove_var("LEAN_CTX_DATA_DIR");
292 std::env::remove_var("XDG_CONFIG_HOME");
293 std::env::remove_var("XDG_DATA_HOME");
294
295 let legacy = dirs::home_dir().unwrap().join(".lean-ctx");
298 if !legacy.exists() {
299 assert_eq!(result, xdg_data.path().join("lean-ctx"));
300 }
301 }
302
303 #[test]
304 fn has_data_files_with_sessions() {
305 let dir = std::env::temp_dir().join("test_data_dir_sessions");
306 let _ = std::fs::remove_dir_all(&dir);
307 let _ = std::fs::create_dir_all(&dir);
308 let _ = std::fs::create_dir_all(dir.join("sessions"));
309 assert!(has_data_files(&dir));
310 let _ = std::fs::remove_dir_all(&dir);
311 }
312
313 #[test]
314 fn lean_ctx_data_dir_env_override() {
315 let _lock = test_env_lock();
316 let dir = std::env::temp_dir().join("test_data_dir_env");
317 let _ = std::fs::remove_dir_all(&dir);
318 let _ = std::fs::create_dir_all(&dir);
319 std::env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
320 let result = lean_ctx_data_dir().unwrap();
321 assert_eq!(result, dir);
322 std::env::remove_var("LEAN_CTX_DATA_DIR");
323 let _ = std::fs::remove_dir_all(&dir);
324 }
325
326 #[test]
327 fn has_data_files_is_false_for_empty_dir() {
328 let dir = std::env::temp_dir().join("test_data_dir_no_data");
329 let _ = std::fs::remove_dir_all(&dir);
330 let _ = std::fs::create_dir_all(&dir);
331 std::fs::write(dir.join("random.txt"), "not a marker").unwrap();
332 assert!(!has_data_files(&dir));
333 let _ = std::fs::remove_dir_all(&dir);
334 }
335
336 #[test]
337 fn xdg_override_with_data_wins() {
338 let _lock = test_env_lock();
339
340 let xdg_base = std::env::temp_dir().join("test_xdg_override_wins");
341 let _ = std::fs::remove_dir_all(&xdg_base);
342 let xdg_dir = xdg_base.join("lean-ctx");
343 let _ = std::fs::create_dir_all(&xdg_dir);
344 std::fs::write(xdg_dir.join("stats.json"), r#"{"total_commands":1}"#).unwrap();
345
346 std::env::set_var("LEAN_CTX_DATA_DIR", "");
347 std::env::set_var("XDG_CONFIG_HOME", xdg_base.to_str().unwrap());
348
349 let result = resolve_home_data_dir().unwrap();
352
353 std::env::remove_var("LEAN_CTX_DATA_DIR");
354 std::env::remove_var("XDG_CONFIG_HOME");
355
356 let home = dirs::home_dir().unwrap();
357 let legacy = home.join(".lean-ctx");
358 if !has_data_files(&legacy) {
359 assert_eq!(
360 result, xdg_dir,
361 "XDG with data should win when legacy has no data"
362 );
363 }
364
365 let _ = std::fs::remove_dir_all(&xdg_base);
366 }
367}