1use std::path::{Path, PathBuf};
32
33use super::data_dir::{ensure_dir_permissions, has_data_files, lean_ctx_data_dir};
34
35fn env_path(name: &str) -> Option<PathBuf> {
37 std::env::var(name)
38 .ok()
39 .map(|v| v.trim().to_string())
40 .filter(|v| !v.is_empty())
41 .map(PathBuf::from)
42}
43
44fn xdg_base(env_name: &str, home_fallback: &str) -> Result<PathBuf, String> {
48 if let Some(p) = env_path(env_name) {
49 return Ok(p);
50 }
51 dirs::home_dir()
52 .map(|h| h.join(home_fallback))
53 .ok_or_else(|| "Cannot determine home directory".to_string())
54}
55
56fn resolve(
59 category_override: Option<PathBuf>,
60 single: Option<PathBuf>,
61 xdg_base_dir: &Path,
62) -> PathBuf {
63 category_override
64 .or(single)
65 .unwrap_or_else(|| xdg_base_dir.join("lean-ctx"))
66}
67
68pub(crate) fn single_dir_override() -> Option<PathBuf> {
86 if let Some(p) = env_path("LEAN_CTX_DATA_DIR")
87 && !is_standard_xdg_data_dir(&p)
88 {
89 return Some(p);
90 }
91 let home = dirs::home_dir()?;
92 let xdg_config_base = xdg_base("XDG_CONFIG_HOME", ".config").ok()?;
93 single_dir_override_fs(&home, &xdg_config_base)
94}
95
96fn is_standard_xdg_data_dir(p: &Path) -> bool {
100 xdg_base("XDG_DATA_HOME", ".local/share")
101 .map(|base| base.join("lean-ctx"))
102 .is_ok_and(|standard| standard.as_path() == p)
103}
104
105pub(crate) fn data_pin_diverges_config(pin: &Path) -> bool {
113 !is_standard_xdg_data_dir(pin)
114}
115
116fn single_dir_override_fs(home: &Path, xdg_config_base: &Path) -> Option<PathBuf> {
118 if crate::core::layout_pin::is_xdg_pinned_in(xdg_config_base) {
122 return None;
123 }
124 let legacy = home.join(".lean-ctx");
125 if legacy.exists() && has_data_files(&legacy) {
126 return Some(legacy);
127 }
128 let mixed = xdg_config_base.join("lean-ctx");
129 if mixed.exists() && has_data_files(&mixed) {
130 return Some(mixed);
131 }
132 None
133}
134
135#[cfg_attr(test, allow(clippy::unnecessary_wraps))]
139fn category_dir(cat_env: &str, xdg_env: &str, home_fallback: &str) -> Result<PathBuf, String> {
140 let category_override = env_path(cat_env);
141
142 #[cfg(test)]
145 {
146 if let Some(p) = category_override {
147 ensure_dir_permissions(&p);
148 return Ok(p);
149 }
150 let _ = (xdg_env, home_fallback);
154 Ok(super::data_dir::test_sandbox_dir())
155 }
156 #[cfg(not(test))]
157 {
158 let base = xdg_base(xdg_env, home_fallback)?;
159 let dir = resolve(category_override, single_dir_override(), &base);
160 ensure_dir_permissions(&dir);
161 Ok(dir)
162 }
163}
164
165pub fn config_dir() -> Result<PathBuf, String> {
168 category_dir("LEAN_CTX_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")
169}
170
171pub fn config_dir_member(sub: &str) -> Result<PathBuf, String> {
184 let canonical = config_dir()?.join(sub);
185 #[cfg(not(test))]
186 adopt_legacy_config_member(sub, &canonical);
187 Ok(canonical)
188}
189
190fn legacy_adoption_source(legacy: &Path, canonical: &Path) -> Option<PathBuf> {
194 if canonical.exists() || legacy == canonical || !legacy.exists() {
195 return None;
196 }
197 Some(legacy.to_path_buf())
198}
199
200fn relocate(src: &Path, dst: &Path) -> std::io::Result<()> {
205 if std::fs::rename(src, dst).is_ok() {
206 return Ok(());
207 }
208 if src.is_dir() {
209 std::fs::create_dir_all(dst)?;
210 for entry in std::fs::read_dir(src)? {
211 let entry = entry?;
212 relocate(&entry.path(), &dst.join(entry.file_name()))?;
213 }
214 std::fs::remove_dir_all(src)?;
215 } else {
216 std::fs::copy(src, dst)?;
217 std::fs::remove_file(src)?;
218 }
219 Ok(())
220}
221
222#[cfg(not(test))]
226fn adopt_legacy_config_member(sub: &str, canonical: &Path) {
227 let Some(legacy_base) = dirs::config_dir() else {
228 return;
229 };
230 let legacy = legacy_base.join("lean-ctx").join(sub);
231 let Some(src) = legacy_adoption_source(&legacy, canonical) else {
232 return;
233 };
234 if let Some(parent) = canonical.parent()
235 && std::fs::create_dir_all(parent).is_err()
236 {
237 return;
238 }
239 let _ = relocate(&src, canonical);
240}
241
242pub fn data_dir() -> Result<PathBuf, String> {
249 lean_ctx_data_dir()
250}
251
252pub fn state_dir() -> Result<PathBuf, String> {
255 category_dir("LEAN_CTX_STATE_DIR", "XDG_STATE_HOME", ".local/state")
256}
257
258pub fn cache_dir() -> Result<PathBuf, String> {
261 category_dir("LEAN_CTX_CACHE_DIR", "XDG_CACHE_HOME", ".cache")
262}
263
264pub fn runtime_dir() -> Result<PathBuf, String> {
270 if let Some(base) = env_path("XDG_RUNTIME_DIR") {
271 return Ok(base.join("lean-ctx"));
272 }
273 state_dir()
274}
275
276fn raw_category_dir(cat_env: &str, xdg_env: &str, home_fallback: &str) -> Result<PathBuf, String> {
284 if let Some(p) = env_path(cat_env) {
285 return Ok(p);
286 }
287 Ok(xdg_base(xdg_env, home_fallback)?.join("lean-ctx"))
288}
289
290pub(crate) fn config_split_target() -> Result<PathBuf, String> {
292 raw_category_dir("LEAN_CTX_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")
293}
294
295pub(crate) fn xdg_config_lean_ctx_dir() -> Option<PathBuf> {
300 xdg_base("XDG_CONFIG_HOME", ".config")
301 .ok()
302 .map(|b| b.join("lean-ctx"))
303}
304
305pub(crate) fn data_split_target() -> Result<PathBuf, String> {
307 raw_category_dir("LEAN_CTX_DATA_DIR", "XDG_DATA_HOME", ".local/share")
308}
309
310pub(crate) fn state_split_target() -> Result<PathBuf, String> {
312 raw_category_dir("LEAN_CTX_STATE_DIR", "XDG_STATE_HOME", ".local/state")
313}
314
315pub(crate) fn cache_split_target() -> Result<PathBuf, String> {
317 raw_category_dir("LEAN_CTX_CACHE_DIR", "XDG_CACHE_HOME", ".cache")
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn resolve_prefers_override_then_single_then_xdg() {
326 let over = PathBuf::from("/over/ride");
327 let single = PathBuf::from("/single/dir");
328 let base = PathBuf::from("/xdg/base");
329
330 assert_eq!(
331 resolve(Some(over.clone()), Some(single.clone()), &base),
332 over
333 );
334 assert_eq!(resolve(None, Some(single.clone()), &base), single);
335 assert_eq!(
336 resolve(None, None, &base),
337 PathBuf::from("/xdg/base/lean-ctx")
338 );
339 }
340
341 #[test]
342 fn single_dir_fs_detects_legacy_with_data() {
343 let home = tempfile::tempdir().unwrap();
344 let xdg = tempfile::tempdir().unwrap();
345 let legacy = home.path().join(".lean-ctx");
346 std::fs::create_dir_all(&legacy).unwrap();
347 std::fs::write(legacy.join("stats.json"), "{}").unwrap();
348
349 assert_eq!(
350 single_dir_override_fs(home.path(), xdg.path()),
351 Some(legacy)
352 );
353 }
354
355 #[test]
356 fn single_dir_fs_detects_mixed_with_data() {
357 let home = tempfile::tempdir().unwrap();
358 let xdg = tempfile::tempdir().unwrap();
359 let mixed = xdg.path().join("lean-ctx");
360 std::fs::create_dir_all(&mixed).unwrap();
361 std::fs::write(mixed.join("stats.json"), "{}").unwrap();
364
365 assert_eq!(single_dir_override_fs(home.path(), xdg.path()), Some(mixed));
366 }
367
368 #[test]
369 fn single_dir_fs_ignores_config_only_dir() {
370 let home = tempfile::tempdir().unwrap();
373 let xdg = tempfile::tempdir().unwrap();
374 let mixed = xdg.path().join("lean-ctx");
375 std::fs::create_dir_all(&mixed).unwrap();
376 std::fs::write(mixed.join("config.toml"), "").unwrap();
377 std::fs::write(mixed.join("shell-hook.zsh"), "").unwrap();
378
379 assert_eq!(single_dir_override_fs(home.path(), xdg.path()), None);
380 }
381
382 #[test]
383 fn single_dir_fs_prefers_legacy_over_mixed() {
384 let home = tempfile::tempdir().unwrap();
385 let xdg = tempfile::tempdir().unwrap();
386 let legacy = home.path().join(".lean-ctx");
387 std::fs::create_dir_all(&legacy).unwrap();
388 std::fs::write(legacy.join("sessions"), "x").unwrap();
389 let mixed = xdg.path().join("lean-ctx");
390 std::fs::create_dir_all(&mixed).unwrap();
391 std::fs::write(mixed.join("stats.json"), "{}").unwrap();
392
393 assert_eq!(
394 single_dir_override_fs(home.path(), xdg.path()),
395 Some(legacy)
396 );
397 }
398
399 #[test]
400 fn xdg_pinned_install_ignores_stray_legacy_marker() {
401 let home = tempfile::tempdir().unwrap();
405 let xdg = tempfile::tempdir().unwrap();
406 crate::core::layout_pin::write_xdg_pin_in(xdg.path()).unwrap();
407
408 let legacy = home.path().join(".lean-ctx");
409 std::fs::create_dir_all(&legacy).unwrap();
410 std::fs::write(legacy.join("stats.json"), "{}").unwrap();
411
412 assert_eq!(single_dir_override_fs(home.path(), xdg.path()), None);
413 }
414
415 #[test]
416 fn xdg_pinned_install_ignores_stray_mixed_marker() {
417 let home = tempfile::tempdir().unwrap();
420 let xdg = tempfile::tempdir().unwrap();
421 crate::core::layout_pin::write_xdg_pin_in(xdg.path()).unwrap();
422
423 let mixed = xdg.path().join("lean-ctx");
424 std::fs::write(mixed.join("stats.json"), "{}").unwrap();
425
426 assert_eq!(single_dir_override_fs(home.path(), xdg.path()), None);
427 }
428
429 #[test]
430 fn single_dir_fs_ignores_empty_dirs() {
431 let home = tempfile::tempdir().unwrap();
432 let xdg = tempfile::tempdir().unwrap();
433 std::fs::create_dir_all(home.path().join(".lean-ctx")).unwrap();
434 std::fs::create_dir_all(xdg.path().join("lean-ctx")).unwrap();
435
436 assert_eq!(single_dir_override_fs(home.path(), xdg.path()), None);
437 }
438
439 #[test]
440 fn single_dir_fs_ignores_non_marker_files() {
441 let home = tempfile::tempdir().unwrap();
442 let xdg = tempfile::tempdir().unwrap();
443 let mixed = xdg.path().join("lean-ctx");
444 std::fs::create_dir_all(&mixed).unwrap();
445 std::fs::write(mixed.join("random.txt"), "x").unwrap();
446
447 assert_eq!(single_dir_override_fs(home.path(), xdg.path()), None);
448 }
449
450 #[test]
451 fn xdg_base_honors_env_then_home_fallback() {
452 let _lock = crate::core::data_dir::test_env_lock();
453 let tmp = tempfile::tempdir().unwrap();
454 crate::test_env::set_var("XDG_CONFIG_HOME", tmp.path());
455 let from_env = xdg_base("XDG_CONFIG_HOME", ".config").unwrap();
456 crate::test_env::remove_var("XDG_CONFIG_HOME");
457 assert_eq!(from_env, tmp.path());
458
459 let fallback = xdg_base("LEAN_CTX_NONEXISTENT_XDG_VAR", ".cache").unwrap();
461 assert!(fallback.ends_with(".cache"), "got: {}", fallback.display());
462 }
463
464 #[test]
465 fn legacy_adoption_source_only_when_canonical_absent() {
466 let tmp = tempfile::tempdir().unwrap();
467 let legacy = tmp.path().join("legacy");
468 let canonical = tmp.path().join("canonical");
469
470 assert_eq!(legacy_adoption_source(&legacy, &canonical), None);
472
473 std::fs::create_dir_all(&legacy).unwrap();
475 assert_eq!(
476 legacy_adoption_source(&legacy, &canonical),
477 Some(legacy.clone())
478 );
479
480 std::fs::create_dir_all(&canonical).unwrap();
482 assert_eq!(legacy_adoption_source(&legacy, &canonical), None);
483 }
484
485 #[test]
486 fn relocate_moves_file_then_directory() {
487 let tmp = tempfile::tempdir().unwrap();
488
489 let src_file = tmp.path().join("providers.toml");
491 std::fs::write(&src_file, "id = \"x\"\n").unwrap();
492 let dst_file = tmp.path().join("config/lean-ctx/providers.toml");
493 std::fs::create_dir_all(dst_file.parent().unwrap()).unwrap();
494 relocate(&src_file, &dst_file).unwrap();
495 assert!(!src_file.exists(), "source file must be moved, not copied");
496 assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), "id = \"x\"\n");
497
498 let src_dir = tmp.path().join("personas");
500 std::fs::create_dir_all(src_dir.join("nested")).unwrap();
501 std::fs::write(src_dir.join("a.toml"), "a").unwrap();
502 std::fs::write(src_dir.join("nested/b.toml"), "b").unwrap();
503 let dst_dir = tmp.path().join("config/lean-ctx/personas");
504 std::fs::create_dir_all(dst_dir.parent().unwrap()).unwrap();
505 relocate(&src_dir, &dst_dir).unwrap();
506 assert!(!src_dir.exists(), "source dir must be moved");
507 assert_eq!(
508 std::fs::read_to_string(dst_dir.join("a.toml")).unwrap(),
509 "a"
510 );
511 assert_eq!(
512 std::fs::read_to_string(dst_dir.join("nested/b.toml")).unwrap(),
513 "b"
514 );
515 }
516
517 #[test]
518 fn single_dir_override_honors_data_dir_env() {
519 let _lock = crate::core::data_dir::test_env_lock();
520 let tmp = tempfile::tempdir().unwrap();
521 crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
522 let got = single_dir_override();
523 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
524 assert_eq!(got, Some(tmp.path().to_path_buf()));
526 }
527
528 #[test]
529 fn is_standard_xdg_data_dir_matches_xdg_data_home() {
530 let _lock = crate::core::data_dir::test_env_lock();
531 let data_home = tempfile::tempdir().unwrap();
532 crate::test_env::set_var("XDG_DATA_HOME", data_home.path());
533 let is_std = is_standard_xdg_data_dir(&data_home.path().join("lean-ctx"));
534 let is_custom = is_standard_xdg_data_dir(Path::new("/some/custom/lean-ctx"));
535 crate::test_env::remove_var("XDG_DATA_HOME");
536 assert!(is_std, "$XDG_DATA_HOME/lean-ctx is the standard data dir");
537 assert!(!is_custom, "a custom path is not the standard data dir");
538 }
539
540 #[test]
541 fn standard_data_pin_does_not_collapse_categories() {
542 let _lock = crate::core::data_dir::test_env_lock();
546 let home = tempfile::tempdir().unwrap();
547 let xdg_config = tempfile::tempdir().unwrap();
548 let xdg_data = tempfile::tempdir().unwrap();
549 let data_pin = xdg_data.path().join("lean-ctx");
550 crate::test_env::set_var("HOME", home.path());
551 crate::test_env::set_var("XDG_CONFIG_HOME", xdg_config.path());
552 crate::test_env::set_var("XDG_DATA_HOME", xdg_data.path());
553 crate::test_env::set_var("LEAN_CTX_DATA_DIR", &data_pin);
554
555 let got = single_dir_override();
556
557 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
558 crate::test_env::remove_var("XDG_DATA_HOME");
559 crate::test_env::remove_var("XDG_CONFIG_HOME");
560 crate::test_env::remove_var("HOME");
561
562 assert_eq!(got, None);
563 }
564
565 #[test]
566 fn config_dir_honors_explicit_override() {
567 let _lock = crate::core::data_dir::test_env_lock();
568 let tmp = tempfile::tempdir().unwrap();
569 crate::test_env::set_var("LEAN_CTX_CONFIG_DIR", tmp.path());
570 let got = config_dir().unwrap();
571 crate::test_env::remove_var("LEAN_CTX_CONFIG_DIR");
572 assert_eq!(got, tmp.path());
573 }
574
575 #[test]
576 fn state_and_cache_dirs_honor_explicit_overrides() {
577 let _lock = crate::core::data_dir::test_env_lock();
578 let state = tempfile::tempdir().unwrap();
579 let cache = tempfile::tempdir().unwrap();
580 crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
581 crate::test_env::set_var("LEAN_CTX_CACHE_DIR", cache.path());
582 let got_state = state_dir().unwrap();
583 let got_cache = cache_dir().unwrap();
584 crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
585 crate::test_env::remove_var("LEAN_CTX_CACHE_DIR");
586 assert_eq!(got_state, state.path());
587 assert_eq!(got_cache, cache.path());
588 }
589
590 #[test]
591 fn data_dir_matches_lean_ctx_data_dir() {
592 let _guard = crate::core::data_dir::isolated_data_dir();
593 assert_eq!(
594 data_dir().unwrap(),
595 crate::core::data_dir::lean_ctx_data_dir().unwrap()
596 );
597 }
598
599 #[test]
600 fn runtime_dir_honors_xdg_runtime_dir() {
601 let _lock = crate::core::data_dir::test_env_lock();
602 let tmp = tempfile::tempdir().unwrap();
603 crate::test_env::set_var("XDG_RUNTIME_DIR", tmp.path());
604 let got = runtime_dir().unwrap();
605 crate::test_env::remove_var("XDG_RUNTIME_DIR");
606 assert_eq!(got, tmp.path().join("lean-ctx"));
607 }
608}