1use std::path::{Path, PathBuf};
34
35use super::data_dir::{ensure_dir_permissions, has_data_files, lean_ctx_data_dir};
36
37fn env_path(name: &str) -> Option<PathBuf> {
39 std::env::var(name)
40 .ok()
41 .map(|v| v.trim().to_string())
42 .filter(|v| !v.is_empty())
43 .map(PathBuf::from)
44}
45
46const DIR_OVERRIDES: [&str; 4] = [
49 "LEAN_CTX_CONFIG_DIR",
50 "LEAN_CTX_DATA_DIR",
51 "LEAN_CTX_STATE_DIR",
52 "LEAN_CTX_CACHE_DIR",
53];
54
55#[must_use]
60pub(crate) fn dir_override_active() -> bool {
61 DIR_OVERRIDES.iter().any(|name| env_path(name).is_some())
62}
63
64pub(crate) fn xdg_base(env_name: &str, home_fallback: &str) -> Result<PathBuf, String> {
68 if let Some(p) = env_path(env_name) {
69 return Ok(p);
70 }
71 dirs::home_dir()
72 .map(|h| h.join(home_fallback))
73 .ok_or_else(|| "Cannot determine home directory".to_string())
74}
75
76fn resolve(
79 category_override: Option<PathBuf>,
80 single: Option<PathBuf>,
81 xdg_base_dir: &Path,
82) -> PathBuf {
83 category_override
84 .or(single)
85 .unwrap_or_else(|| xdg_base_dir.join("lean-ctx"))
86}
87
88pub(crate) fn single_dir_override() -> Option<PathBuf> {
106 if let Some(p) = env_path("LEAN_CTX_DATA_DIR")
107 && !is_standard_xdg_data_dir(&p)
108 {
109 return Some(p);
110 }
111 let home = dirs::home_dir()?;
112 let xdg_config_base = xdg_base("XDG_CONFIG_HOME", ".config").ok()?;
113 let xdg_data_base = xdg_base("XDG_DATA_HOME", ".local/share").ok();
114 single_dir_override_fs(&home, &xdg_config_base, xdg_data_base.as_deref())
115}
116
117fn is_standard_xdg_data_dir(p: &Path) -> bool {
121 xdg_base("XDG_DATA_HOME", ".local/share")
122 .map(|base| base.join("lean-ctx"))
123 .is_ok_and(|standard| standard.as_path() == p)
124}
125
126pub fn data_pin_diverges_config(pin: &Path) -> bool {
134 !is_standard_xdg_data_dir(pin)
135}
136
137pub(crate) fn single_dir_override_fs(
139 home: &Path,
140 xdg_config_base: &Path,
141 xdg_data_base: Option<&Path>,
142) -> Option<PathBuf> {
143 if crate::core::layout_pin::is_xdg_pinned_in(xdg_config_base) {
147 return None;
148 }
149 let legacy = home.join(".lean-ctx");
150 if legacy.exists() && has_data_files(&legacy) {
151 return Some(legacy);
152 }
153 let mixed = xdg_config_base.join("lean-ctx");
154 if mixed.exists() && has_data_files(&mixed) {
155 if let Some(data_base) = xdg_data_base
162 && has_data_files(&data_base.join("lean-ctx"))
163 {
164 return None;
165 }
166 return Some(mixed);
167 }
168 None
169}
170
171fn category_dir(cat_env: &str, xdg_env: &str, home_fallback: &str) -> Result<PathBuf, String> {
173 let dir = resolve_category_dir(cat_env, xdg_env, home_fallback)?;
174 ensure_dir_permissions(&dir);
175 Ok(dir)
176}
177
178#[cfg_attr(test, allow(clippy::unnecessary_wraps))]
182fn resolve_category_dir(
183 cat_env: &str,
184 xdg_env: &str,
185 home_fallback: &str,
186) -> Result<PathBuf, String> {
187 let category_override = env_path(cat_env);
188
189 if super::data_dir::sandboxed() {
192 if let Some(p) = category_override {
193 return Ok(p);
194 }
195 return Ok(super::data_dir::test_sandbox_dir());
199 }
200 let base = xdg_base(xdg_env, home_fallback)?;
201 Ok(resolve(category_override, single_dir_override(), &base))
202}
203
204pub fn config_dir() -> Result<PathBuf, String> {
207 category_dir("LEAN_CTX_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")
208}
209
210pub fn config_dir_read_only() -> Result<PathBuf, String> {
212 resolve_category_dir("LEAN_CTX_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")
213}
214
215pub fn config_dir_member(sub: &str) -> Result<PathBuf, String> {
228 let canonical = config_dir()?.join(sub);
229 if !super::data_dir::sandboxed() {
230 adopt_legacy_config_member(sub, &canonical);
231 }
232 Ok(canonical)
233}
234
235fn legacy_adoption_source(legacy: &Path, canonical: &Path) -> Option<PathBuf> {
239 if canonical.exists() || legacy == canonical || !legacy.exists() {
240 return None;
241 }
242 Some(legacy.to_path_buf())
243}
244
245fn relocate(src: &Path, dst: &Path) -> std::io::Result<()> {
250 if std::fs::rename(src, dst).is_ok() {
251 return Ok(());
252 }
253 if src.is_dir() {
254 std::fs::create_dir_all(dst)?;
255 for entry in std::fs::read_dir(src)? {
256 let entry = entry?;
257 relocate(&entry.path(), &dst.join(entry.file_name()))?;
258 }
259 std::fs::remove_dir_all(src)?;
260 } else {
261 std::fs::copy(src, dst)?;
262 std::fs::remove_file(src)?;
263 }
264 Ok(())
265}
266
267fn adopt_legacy_config_member(sub: &str, canonical: &Path) {
271 let Some(legacy_base) = dirs::config_dir() else {
272 return;
273 };
274 let legacy = legacy_base.join("lean-ctx").join(sub);
275 let Some(src) = legacy_adoption_source(&legacy, canonical) else {
276 return;
277 };
278 if let Some(parent) = canonical.parent()
279 && std::fs::create_dir_all(parent).is_err()
280 {
281 return;
282 }
283 let _ = relocate(&src, canonical);
284}
285
286pub fn data_dir() -> Result<PathBuf, String> {
293 lean_ctx_data_dir()
294}
295
296pub fn state_dir() -> Result<PathBuf, String> {
299 category_dir("LEAN_CTX_STATE_DIR", "XDG_STATE_HOME", ".local/state")
300}
301
302pub fn state_dir_read_only() -> Result<PathBuf, String> {
304 resolve_category_dir("LEAN_CTX_STATE_DIR", "XDG_STATE_HOME", ".local/state")
305}
306
307pub fn cache_dir() -> Result<PathBuf, String> {
310 category_dir("LEAN_CTX_CACHE_DIR", "XDG_CACHE_HOME", ".cache")
311}
312
313pub(crate) fn cache_dir_read_only() -> Result<PathBuf, String> {
315 resolve_category_dir("LEAN_CTX_CACHE_DIR", "XDG_CACHE_HOME", ".cache")
316}
317
318pub fn runtime_dir() -> Result<PathBuf, String> {
324 if let Some(base) = env_path("XDG_RUNTIME_DIR") {
325 return Ok(base.join("lean-ctx"));
326 }
327 state_dir()
328}
329
330fn raw_category_dir(cat_env: &str, xdg_env: &str, home_fallback: &str) -> Result<PathBuf, String> {
338 if let Some(p) = env_path(cat_env) {
339 return Ok(p);
340 }
341 Ok(xdg_base(xdg_env, home_fallback)?.join("lean-ctx"))
342}
343
344pub(crate) fn config_split_target() -> Result<PathBuf, String> {
346 raw_category_dir("LEAN_CTX_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")
347}
348
349pub(crate) fn xdg_config_lean_ctx_dir() -> Option<PathBuf> {
354 xdg_base("XDG_CONFIG_HOME", ".config")
355 .ok()
356 .map(|b| b.join("lean-ctx"))
357}
358
359pub(crate) fn data_split_target() -> Result<PathBuf, String> {
361 raw_category_dir("LEAN_CTX_DATA_DIR", "XDG_DATA_HOME", ".local/share")
362}
363
364pub fn data_dir_read_only() -> Result<PathBuf, String> {
366 raw_category_dir("LEAN_CTX_DATA_DIR", "XDG_DATA_HOME", ".local/share")
367}
368
369pub(crate) fn state_split_target() -> Result<PathBuf, String> {
371 raw_category_dir("LEAN_CTX_STATE_DIR", "XDG_STATE_HOME", ".local/state")
372}
373
374pub(crate) fn cache_split_target() -> Result<PathBuf, String> {
376 raw_category_dir("LEAN_CTX_CACHE_DIR", "XDG_CACHE_HOME", ".cache")
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn resolve_prefers_override_then_single_then_xdg() {
385 let over = PathBuf::from("/over/ride");
386 let single = PathBuf::from("/single/dir");
387 let base = PathBuf::from("/xdg/base");
388
389 assert_eq!(
390 resolve(Some(over.clone()), Some(single.clone()), &base),
391 over
392 );
393 assert_eq!(resolve(None, Some(single.clone()), &base), single);
394 assert_eq!(
395 resolve(None, None, &base),
396 PathBuf::from("/xdg/base/lean-ctx")
397 );
398 }
399
400 #[test]
401 fn single_dir_fs_detects_legacy_with_data() {
402 let home = tempfile::tempdir().unwrap();
403 let xdg = tempfile::tempdir().unwrap();
404 let legacy = home.path().join(".lean-ctx");
405 std::fs::create_dir_all(&legacy).unwrap();
406 std::fs::write(legacy.join("stats.json"), "{}").unwrap();
407
408 assert_eq!(
409 single_dir_override_fs(home.path(), xdg.path(), None),
410 Some(legacy)
411 );
412 }
413
414 #[test]
415 fn single_dir_fs_detects_mixed_with_data() {
416 let home = tempfile::tempdir().unwrap();
417 let xdg = tempfile::tempdir().unwrap();
418 let mixed = xdg.path().join("lean-ctx");
419 std::fs::create_dir_all(&mixed).unwrap();
420 std::fs::write(mixed.join("stats.json"), "{}").unwrap();
423
424 assert_eq!(
425 single_dir_override_fs(home.path(), xdg.path(), None),
426 Some(mixed)
427 );
428 }
429
430 #[test]
431 fn single_dir_fs_ignores_config_only_dir() {
432 let home = tempfile::tempdir().unwrap();
435 let xdg = tempfile::tempdir().unwrap();
436 let mixed = xdg.path().join("lean-ctx");
437 std::fs::create_dir_all(&mixed).unwrap();
438 std::fs::write(mixed.join("config.toml"), "").unwrap();
439 std::fs::write(mixed.join("shell-hook.zsh"), "").unwrap();
440
441 assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
442 }
443
444 #[test]
445 fn single_dir_fs_prefers_legacy_over_mixed() {
446 let home = tempfile::tempdir().unwrap();
447 let xdg = tempfile::tempdir().unwrap();
448 let legacy = home.path().join(".lean-ctx");
449 std::fs::create_dir_all(&legacy).unwrap();
450 std::fs::write(legacy.join("sessions"), "x").unwrap();
451 let mixed = xdg.path().join("lean-ctx");
452 std::fs::create_dir_all(&mixed).unwrap();
453 std::fs::write(mixed.join("stats.json"), "{}").unwrap();
454
455 assert_eq!(
456 single_dir_override_fs(home.path(), xdg.path(), None),
457 Some(legacy)
458 );
459 }
460
461 #[test]
462 fn xdg_pinned_install_ignores_stray_legacy_marker() {
463 let home = tempfile::tempdir().unwrap();
467 let xdg = tempfile::tempdir().unwrap();
468 crate::core::layout_pin::write_xdg_pin_in(xdg.path()).unwrap();
469
470 let legacy = home.path().join(".lean-ctx");
471 std::fs::create_dir_all(&legacy).unwrap();
472 std::fs::write(legacy.join("stats.json"), "{}").unwrap();
473
474 assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
475 }
476
477 #[test]
478 fn xdg_pinned_install_ignores_stray_mixed_marker() {
479 let home = tempfile::tempdir().unwrap();
482 let xdg = tempfile::tempdir().unwrap();
483 crate::core::layout_pin::write_xdg_pin_in(xdg.path()).unwrap();
484
485 let mixed = xdg.path().join("lean-ctx");
486 std::fs::write(mixed.join("stats.json"), "{}").unwrap();
487
488 assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
489 }
490
491 #[test]
492 fn split_install_ignores_stray_mixed_marker_when_xdg_data_present() {
493 let home = tempfile::tempdir().unwrap();
500 let xdg_config = tempfile::tempdir().unwrap();
501 let xdg_data = tempfile::tempdir().unwrap();
502
503 let mixed = xdg_config.path().join("lean-ctx");
504 std::fs::create_dir_all(&mixed).unwrap();
505 std::fs::write(mixed.join("stats.json"), "{}").unwrap();
506
507 let data = xdg_data.path().join("lean-ctx");
508 std::fs::create_dir_all(&data).unwrap();
509 std::fs::write(data.join("stats.json"), "{}").unwrap();
510
511 assert_eq!(
512 single_dir_override_fs(home.path(), xdg_config.path(), Some(xdg_data.path())),
513 None,
514 "a populated XDG data dir must keep the config dir from collapsing"
515 );
516
517 let empty_data = tempfile::tempdir().unwrap();
520 assert_eq!(
521 single_dir_override_fs(home.path(), xdg_config.path(), Some(empty_data.path())),
522 Some(mixed)
523 );
524 }
525
526 #[test]
527 fn single_dir_fs_ignores_empty_dirs() {
528 let home = tempfile::tempdir().unwrap();
529 let xdg = tempfile::tempdir().unwrap();
530 std::fs::create_dir_all(home.path().join(".lean-ctx")).unwrap();
531 std::fs::create_dir_all(xdg.path().join("lean-ctx")).unwrap();
532
533 assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
534 }
535
536 #[test]
537 fn single_dir_fs_ignores_non_marker_files() {
538 let home = tempfile::tempdir().unwrap();
539 let xdg = tempfile::tempdir().unwrap();
540 let mixed = xdg.path().join("lean-ctx");
541 std::fs::create_dir_all(&mixed).unwrap();
542 std::fs::write(mixed.join("random.txt"), "x").unwrap();
543
544 assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
545 }
546
547 #[test]
548 fn xdg_base_honors_env_then_home_fallback() {
549 let _lock = crate::core::data_dir::test_env_lock();
550 let tmp = tempfile::tempdir().unwrap();
551 crate::test_env::set_var("XDG_CONFIG_HOME", tmp.path());
552 let from_env = xdg_base("XDG_CONFIG_HOME", ".config").unwrap();
553 crate::test_env::remove_var("XDG_CONFIG_HOME");
554 assert_eq!(from_env, tmp.path());
555
556 let fallback = xdg_base("LEAN_CTX_NONEXISTENT_XDG_VAR", ".cache").unwrap();
558 assert!(fallback.ends_with(".cache"), "got: {}", fallback.display());
559 }
560
561 #[test]
562 fn legacy_adoption_source_only_when_canonical_absent() {
563 let tmp = tempfile::tempdir().unwrap();
564 let legacy = tmp.path().join("legacy");
565 let canonical = tmp.path().join("canonical");
566
567 assert_eq!(legacy_adoption_source(&legacy, &canonical), None);
569
570 std::fs::create_dir_all(&legacy).unwrap();
572 assert_eq!(
573 legacy_adoption_source(&legacy, &canonical),
574 Some(legacy.clone())
575 );
576
577 std::fs::create_dir_all(&canonical).unwrap();
579 assert_eq!(legacy_adoption_source(&legacy, &canonical), None);
580 }
581
582 #[test]
583 fn relocate_moves_file_then_directory() {
584 let tmp = tempfile::tempdir().unwrap();
585
586 let src_file = tmp.path().join("providers.toml");
588 std::fs::write(&src_file, "id = \"x\"\n").unwrap();
589 let dst_file = tmp.path().join("config/lean-ctx/providers.toml");
590 std::fs::create_dir_all(dst_file.parent().unwrap()).unwrap();
591 relocate(&src_file, &dst_file).unwrap();
592 assert!(!src_file.exists(), "source file must be moved, not copied");
593 assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), "id = \"x\"\n");
594
595 let src_dir = tmp.path().join("personas");
597 std::fs::create_dir_all(src_dir.join("nested")).unwrap();
598 std::fs::write(src_dir.join("a.toml"), "a").unwrap();
599 std::fs::write(src_dir.join("nested/b.toml"), "b").unwrap();
600 let dst_dir = tmp.path().join("config/lean-ctx/personas");
601 std::fs::create_dir_all(dst_dir.parent().unwrap()).unwrap();
602 relocate(&src_dir, &dst_dir).unwrap();
603 assert!(!src_dir.exists(), "source dir must be moved");
604 assert_eq!(
605 std::fs::read_to_string(dst_dir.join("a.toml")).unwrap(),
606 "a"
607 );
608 assert_eq!(
609 std::fs::read_to_string(dst_dir.join("nested/b.toml")).unwrap(),
610 "b"
611 );
612 }
613
614 #[test]
615 fn single_dir_override_honors_data_dir_env() {
616 let _lock = crate::core::data_dir::test_env_lock();
617 let tmp = tempfile::tempdir().unwrap();
618 crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
619 let got = single_dir_override();
620 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
621 assert_eq!(got, Some(tmp.path().to_path_buf()));
623 }
624
625 #[test]
626 fn is_standard_xdg_data_dir_matches_xdg_data_home() {
627 let _lock = crate::core::data_dir::test_env_lock();
628 let data_home = tempfile::tempdir().unwrap();
629 crate::test_env::set_var("XDG_DATA_HOME", data_home.path());
630 let is_std = is_standard_xdg_data_dir(&data_home.path().join("lean-ctx"));
631 let is_custom = is_standard_xdg_data_dir(Path::new("/some/custom/lean-ctx"));
632 crate::test_env::remove_var("XDG_DATA_HOME");
633 assert!(is_std, "$XDG_DATA_HOME/lean-ctx is the standard data dir");
634 assert!(!is_custom, "a custom path is not the standard data dir");
635 }
636
637 #[test]
638 fn standard_data_pin_does_not_collapse_categories() {
639 let _lock = crate::core::data_dir::test_env_lock();
643 let home = tempfile::tempdir().unwrap();
644 let xdg_config = tempfile::tempdir().unwrap();
645 let xdg_data = tempfile::tempdir().unwrap();
646 let data_pin = xdg_data.path().join("lean-ctx");
647 crate::test_env::set_var("HOME", home.path());
648 crate::test_env::set_var("XDG_CONFIG_HOME", xdg_config.path());
649 crate::test_env::set_var("XDG_DATA_HOME", xdg_data.path());
650 crate::test_env::set_var("LEAN_CTX_DATA_DIR", &data_pin);
651
652 let got = single_dir_override();
653
654 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
655 crate::test_env::remove_var("XDG_DATA_HOME");
656 crate::test_env::remove_var("XDG_CONFIG_HOME");
657 crate::test_env::remove_var("HOME");
658
659 assert_eq!(got, None);
660 }
661
662 #[test]
663 fn config_dir_honors_explicit_override() {
664 let _lock = crate::core::data_dir::test_env_lock();
665 let tmp = tempfile::tempdir().unwrap();
666 crate::test_env::set_var("LEAN_CTX_CONFIG_DIR", tmp.path());
667 let got = config_dir().unwrap();
668 crate::test_env::remove_var("LEAN_CTX_CONFIG_DIR");
669 assert_eq!(got, tmp.path());
670 }
671
672 #[test]
673 fn state_and_cache_dirs_honor_explicit_overrides() {
674 let _lock = crate::core::data_dir::test_env_lock();
675 let state = tempfile::tempdir().unwrap();
676 let cache = tempfile::tempdir().unwrap();
677 crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
678 crate::test_env::set_var("LEAN_CTX_CACHE_DIR", cache.path());
679 let got_state = state_dir().unwrap();
680 let got_cache = cache_dir().unwrap();
681 crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
682 crate::test_env::remove_var("LEAN_CTX_CACHE_DIR");
683 assert_eq!(got_state, state.path());
684 assert_eq!(got_cache, cache.path());
685 }
686
687 #[test]
688 fn data_dir_matches_lean_ctx_data_dir() {
689 let _guard = crate::core::data_dir::isolated_data_dir();
690 assert_eq!(
691 data_dir().unwrap(),
692 crate::core::data_dir::lean_ctx_data_dir().unwrap()
693 );
694 }
695
696 #[test]
697 fn runtime_dir_honors_xdg_runtime_dir() {
698 let _lock = crate::core::data_dir::test_env_lock();
699 let tmp = tempfile::tempdir().unwrap();
700 crate::test_env::set_var("XDG_RUNTIME_DIR", tmp.path());
701 let got = runtime_dir().unwrap();
702 crate::test_env::remove_var("XDG_RUNTIME_DIR");
703 assert_eq!(got, tmp.path().join("lean-ctx"));
704 }
705}