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 let xdg_data_base = xdg_base("XDG_DATA_HOME", ".local/share").ok();
94 single_dir_override_fs(&home, &xdg_config_base, xdg_data_base.as_deref())
95}
96
97fn is_standard_xdg_data_dir(p: &Path) -> bool {
101 xdg_base("XDG_DATA_HOME", ".local/share")
102 .map(|base| base.join("lean-ctx"))
103 .is_ok_and(|standard| standard.as_path() == p)
104}
105
106pub(crate) fn data_pin_diverges_config(pin: &Path) -> bool {
114 !is_standard_xdg_data_dir(pin)
115}
116
117fn single_dir_override_fs(
119 home: &Path,
120 xdg_config_base: &Path,
121 xdg_data_base: Option<&Path>,
122) -> Option<PathBuf> {
123 if crate::core::layout_pin::is_xdg_pinned_in(xdg_config_base) {
127 return None;
128 }
129 let legacy = home.join(".lean-ctx");
130 if legacy.exists() && has_data_files(&legacy) {
131 return Some(legacy);
132 }
133 let mixed = xdg_config_base.join("lean-ctx");
134 if mixed.exists() && has_data_files(&mixed) {
135 if let Some(data_base) = xdg_data_base
142 && has_data_files(&data_base.join("lean-ctx"))
143 {
144 return None;
145 }
146 return Some(mixed);
147 }
148 None
149}
150
151#[cfg_attr(test, allow(clippy::unnecessary_wraps))]
155fn category_dir(cat_env: &str, xdg_env: &str, home_fallback: &str) -> Result<PathBuf, String> {
156 let category_override = env_path(cat_env);
157
158 #[cfg(test)]
161 {
162 if let Some(p) = category_override {
163 ensure_dir_permissions(&p);
164 return Ok(p);
165 }
166 let _ = (xdg_env, home_fallback);
170 Ok(super::data_dir::test_sandbox_dir())
171 }
172 #[cfg(not(test))]
173 {
174 let base = xdg_base(xdg_env, home_fallback)?;
175 let dir = resolve(category_override, single_dir_override(), &base);
176 ensure_dir_permissions(&dir);
177 Ok(dir)
178 }
179}
180
181pub fn config_dir() -> Result<PathBuf, String> {
184 category_dir("LEAN_CTX_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")
185}
186
187pub fn config_dir_member(sub: &str) -> Result<PathBuf, String> {
200 let canonical = config_dir()?.join(sub);
201 #[cfg(not(test))]
202 adopt_legacy_config_member(sub, &canonical);
203 Ok(canonical)
204}
205
206fn legacy_adoption_source(legacy: &Path, canonical: &Path) -> Option<PathBuf> {
210 if canonical.exists() || legacy == canonical || !legacy.exists() {
211 return None;
212 }
213 Some(legacy.to_path_buf())
214}
215
216fn relocate(src: &Path, dst: &Path) -> std::io::Result<()> {
221 if std::fs::rename(src, dst).is_ok() {
222 return Ok(());
223 }
224 if src.is_dir() {
225 std::fs::create_dir_all(dst)?;
226 for entry in std::fs::read_dir(src)? {
227 let entry = entry?;
228 relocate(&entry.path(), &dst.join(entry.file_name()))?;
229 }
230 std::fs::remove_dir_all(src)?;
231 } else {
232 std::fs::copy(src, dst)?;
233 std::fs::remove_file(src)?;
234 }
235 Ok(())
236}
237
238#[cfg(not(test))]
242fn adopt_legacy_config_member(sub: &str, canonical: &Path) {
243 let Some(legacy_base) = dirs::config_dir() else {
244 return;
245 };
246 let legacy = legacy_base.join("lean-ctx").join(sub);
247 let Some(src) = legacy_adoption_source(&legacy, canonical) else {
248 return;
249 };
250 if let Some(parent) = canonical.parent()
251 && std::fs::create_dir_all(parent).is_err()
252 {
253 return;
254 }
255 let _ = relocate(&src, canonical);
256}
257
258pub fn data_dir() -> Result<PathBuf, String> {
265 lean_ctx_data_dir()
266}
267
268pub fn state_dir() -> Result<PathBuf, String> {
271 category_dir("LEAN_CTX_STATE_DIR", "XDG_STATE_HOME", ".local/state")
272}
273
274pub fn cache_dir() -> Result<PathBuf, String> {
277 category_dir("LEAN_CTX_CACHE_DIR", "XDG_CACHE_HOME", ".cache")
278}
279
280pub fn runtime_dir() -> Result<PathBuf, String> {
286 if let Some(base) = env_path("XDG_RUNTIME_DIR") {
287 return Ok(base.join("lean-ctx"));
288 }
289 state_dir()
290}
291
292fn raw_category_dir(cat_env: &str, xdg_env: &str, home_fallback: &str) -> Result<PathBuf, String> {
300 if let Some(p) = env_path(cat_env) {
301 return Ok(p);
302 }
303 Ok(xdg_base(xdg_env, home_fallback)?.join("lean-ctx"))
304}
305
306pub(crate) fn config_split_target() -> Result<PathBuf, String> {
308 raw_category_dir("LEAN_CTX_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")
309}
310
311pub(crate) fn xdg_config_lean_ctx_dir() -> Option<PathBuf> {
316 xdg_base("XDG_CONFIG_HOME", ".config")
317 .ok()
318 .map(|b| b.join("lean-ctx"))
319}
320
321pub(crate) fn data_split_target() -> Result<PathBuf, String> {
323 raw_category_dir("LEAN_CTX_DATA_DIR", "XDG_DATA_HOME", ".local/share")
324}
325
326pub(crate) fn state_split_target() -> Result<PathBuf, String> {
328 raw_category_dir("LEAN_CTX_STATE_DIR", "XDG_STATE_HOME", ".local/state")
329}
330
331pub(crate) fn cache_split_target() -> Result<PathBuf, String> {
333 raw_category_dir("LEAN_CTX_CACHE_DIR", "XDG_CACHE_HOME", ".cache")
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn resolve_prefers_override_then_single_then_xdg() {
342 let over = PathBuf::from("/over/ride");
343 let single = PathBuf::from("/single/dir");
344 let base = PathBuf::from("/xdg/base");
345
346 assert_eq!(
347 resolve(Some(over.clone()), Some(single.clone()), &base),
348 over
349 );
350 assert_eq!(resolve(None, Some(single.clone()), &base), single);
351 assert_eq!(
352 resolve(None, None, &base),
353 PathBuf::from("/xdg/base/lean-ctx")
354 );
355 }
356
357 #[test]
358 fn single_dir_fs_detects_legacy_with_data() {
359 let home = tempfile::tempdir().unwrap();
360 let xdg = tempfile::tempdir().unwrap();
361 let legacy = home.path().join(".lean-ctx");
362 std::fs::create_dir_all(&legacy).unwrap();
363 std::fs::write(legacy.join("stats.json"), "{}").unwrap();
364
365 assert_eq!(
366 single_dir_override_fs(home.path(), xdg.path(), None),
367 Some(legacy)
368 );
369 }
370
371 #[test]
372 fn single_dir_fs_detects_mixed_with_data() {
373 let home = tempfile::tempdir().unwrap();
374 let xdg = tempfile::tempdir().unwrap();
375 let mixed = xdg.path().join("lean-ctx");
376 std::fs::create_dir_all(&mixed).unwrap();
377 std::fs::write(mixed.join("stats.json"), "{}").unwrap();
380
381 assert_eq!(
382 single_dir_override_fs(home.path(), xdg.path(), None),
383 Some(mixed)
384 );
385 }
386
387 #[test]
388 fn single_dir_fs_ignores_config_only_dir() {
389 let home = tempfile::tempdir().unwrap();
392 let xdg = tempfile::tempdir().unwrap();
393 let mixed = xdg.path().join("lean-ctx");
394 std::fs::create_dir_all(&mixed).unwrap();
395 std::fs::write(mixed.join("config.toml"), "").unwrap();
396 std::fs::write(mixed.join("shell-hook.zsh"), "").unwrap();
397
398 assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
399 }
400
401 #[test]
402 fn single_dir_fs_prefers_legacy_over_mixed() {
403 let home = tempfile::tempdir().unwrap();
404 let xdg = tempfile::tempdir().unwrap();
405 let legacy = home.path().join(".lean-ctx");
406 std::fs::create_dir_all(&legacy).unwrap();
407 std::fs::write(legacy.join("sessions"), "x").unwrap();
408 let mixed = xdg.path().join("lean-ctx");
409 std::fs::create_dir_all(&mixed).unwrap();
410 std::fs::write(mixed.join("stats.json"), "{}").unwrap();
411
412 assert_eq!(
413 single_dir_override_fs(home.path(), xdg.path(), None),
414 Some(legacy)
415 );
416 }
417
418 #[test]
419 fn xdg_pinned_install_ignores_stray_legacy_marker() {
420 let home = tempfile::tempdir().unwrap();
424 let xdg = tempfile::tempdir().unwrap();
425 crate::core::layout_pin::write_xdg_pin_in(xdg.path()).unwrap();
426
427 let legacy = home.path().join(".lean-ctx");
428 std::fs::create_dir_all(&legacy).unwrap();
429 std::fs::write(legacy.join("stats.json"), "{}").unwrap();
430
431 assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
432 }
433
434 #[test]
435 fn xdg_pinned_install_ignores_stray_mixed_marker() {
436 let home = tempfile::tempdir().unwrap();
439 let xdg = tempfile::tempdir().unwrap();
440 crate::core::layout_pin::write_xdg_pin_in(xdg.path()).unwrap();
441
442 let mixed = xdg.path().join("lean-ctx");
443 std::fs::write(mixed.join("stats.json"), "{}").unwrap();
444
445 assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
446 }
447
448 #[test]
449 fn split_install_ignores_stray_mixed_marker_when_xdg_data_present() {
450 let home = tempfile::tempdir().unwrap();
457 let xdg_config = tempfile::tempdir().unwrap();
458 let xdg_data = tempfile::tempdir().unwrap();
459
460 let mixed = xdg_config.path().join("lean-ctx");
461 std::fs::create_dir_all(&mixed).unwrap();
462 std::fs::write(mixed.join("stats.json"), "{}").unwrap();
463
464 let data = xdg_data.path().join("lean-ctx");
465 std::fs::create_dir_all(&data).unwrap();
466 std::fs::write(data.join("stats.json"), "{}").unwrap();
467
468 assert_eq!(
469 single_dir_override_fs(home.path(), xdg_config.path(), Some(xdg_data.path())),
470 None,
471 "a populated XDG data dir must keep the config dir from collapsing"
472 );
473
474 let empty_data = tempfile::tempdir().unwrap();
477 assert_eq!(
478 single_dir_override_fs(home.path(), xdg_config.path(), Some(empty_data.path())),
479 Some(mixed)
480 );
481 }
482
483 #[test]
484 fn single_dir_fs_ignores_empty_dirs() {
485 let home = tempfile::tempdir().unwrap();
486 let xdg = tempfile::tempdir().unwrap();
487 std::fs::create_dir_all(home.path().join(".lean-ctx")).unwrap();
488 std::fs::create_dir_all(xdg.path().join("lean-ctx")).unwrap();
489
490 assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
491 }
492
493 #[test]
494 fn single_dir_fs_ignores_non_marker_files() {
495 let home = tempfile::tempdir().unwrap();
496 let xdg = tempfile::tempdir().unwrap();
497 let mixed = xdg.path().join("lean-ctx");
498 std::fs::create_dir_all(&mixed).unwrap();
499 std::fs::write(mixed.join("random.txt"), "x").unwrap();
500
501 assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
502 }
503
504 #[test]
505 fn xdg_base_honors_env_then_home_fallback() {
506 let _lock = crate::core::data_dir::test_env_lock();
507 let tmp = tempfile::tempdir().unwrap();
508 crate::test_env::set_var("XDG_CONFIG_HOME", tmp.path());
509 let from_env = xdg_base("XDG_CONFIG_HOME", ".config").unwrap();
510 crate::test_env::remove_var("XDG_CONFIG_HOME");
511 assert_eq!(from_env, tmp.path());
512
513 let fallback = xdg_base("LEAN_CTX_NONEXISTENT_XDG_VAR", ".cache").unwrap();
515 assert!(fallback.ends_with(".cache"), "got: {}", fallback.display());
516 }
517
518 #[test]
519 fn legacy_adoption_source_only_when_canonical_absent() {
520 let tmp = tempfile::tempdir().unwrap();
521 let legacy = tmp.path().join("legacy");
522 let canonical = tmp.path().join("canonical");
523
524 assert_eq!(legacy_adoption_source(&legacy, &canonical), None);
526
527 std::fs::create_dir_all(&legacy).unwrap();
529 assert_eq!(
530 legacy_adoption_source(&legacy, &canonical),
531 Some(legacy.clone())
532 );
533
534 std::fs::create_dir_all(&canonical).unwrap();
536 assert_eq!(legacy_adoption_source(&legacy, &canonical), None);
537 }
538
539 #[test]
540 fn relocate_moves_file_then_directory() {
541 let tmp = tempfile::tempdir().unwrap();
542
543 let src_file = tmp.path().join("providers.toml");
545 std::fs::write(&src_file, "id = \"x\"\n").unwrap();
546 let dst_file = tmp.path().join("config/lean-ctx/providers.toml");
547 std::fs::create_dir_all(dst_file.parent().unwrap()).unwrap();
548 relocate(&src_file, &dst_file).unwrap();
549 assert!(!src_file.exists(), "source file must be moved, not copied");
550 assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), "id = \"x\"\n");
551
552 let src_dir = tmp.path().join("personas");
554 std::fs::create_dir_all(src_dir.join("nested")).unwrap();
555 std::fs::write(src_dir.join("a.toml"), "a").unwrap();
556 std::fs::write(src_dir.join("nested/b.toml"), "b").unwrap();
557 let dst_dir = tmp.path().join("config/lean-ctx/personas");
558 std::fs::create_dir_all(dst_dir.parent().unwrap()).unwrap();
559 relocate(&src_dir, &dst_dir).unwrap();
560 assert!(!src_dir.exists(), "source dir must be moved");
561 assert_eq!(
562 std::fs::read_to_string(dst_dir.join("a.toml")).unwrap(),
563 "a"
564 );
565 assert_eq!(
566 std::fs::read_to_string(dst_dir.join("nested/b.toml")).unwrap(),
567 "b"
568 );
569 }
570
571 #[test]
572 fn single_dir_override_honors_data_dir_env() {
573 let _lock = crate::core::data_dir::test_env_lock();
574 let tmp = tempfile::tempdir().unwrap();
575 crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
576 let got = single_dir_override();
577 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
578 assert_eq!(got, Some(tmp.path().to_path_buf()));
580 }
581
582 #[test]
583 fn is_standard_xdg_data_dir_matches_xdg_data_home() {
584 let _lock = crate::core::data_dir::test_env_lock();
585 let data_home = tempfile::tempdir().unwrap();
586 crate::test_env::set_var("XDG_DATA_HOME", data_home.path());
587 let is_std = is_standard_xdg_data_dir(&data_home.path().join("lean-ctx"));
588 let is_custom = is_standard_xdg_data_dir(Path::new("/some/custom/lean-ctx"));
589 crate::test_env::remove_var("XDG_DATA_HOME");
590 assert!(is_std, "$XDG_DATA_HOME/lean-ctx is the standard data dir");
591 assert!(!is_custom, "a custom path is not the standard data dir");
592 }
593
594 #[test]
595 fn standard_data_pin_does_not_collapse_categories() {
596 let _lock = crate::core::data_dir::test_env_lock();
600 let home = tempfile::tempdir().unwrap();
601 let xdg_config = tempfile::tempdir().unwrap();
602 let xdg_data = tempfile::tempdir().unwrap();
603 let data_pin = xdg_data.path().join("lean-ctx");
604 crate::test_env::set_var("HOME", home.path());
605 crate::test_env::set_var("XDG_CONFIG_HOME", xdg_config.path());
606 crate::test_env::set_var("XDG_DATA_HOME", xdg_data.path());
607 crate::test_env::set_var("LEAN_CTX_DATA_DIR", &data_pin);
608
609 let got = single_dir_override();
610
611 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
612 crate::test_env::remove_var("XDG_DATA_HOME");
613 crate::test_env::remove_var("XDG_CONFIG_HOME");
614 crate::test_env::remove_var("HOME");
615
616 assert_eq!(got, None);
617 }
618
619 #[test]
620 fn config_dir_honors_explicit_override() {
621 let _lock = crate::core::data_dir::test_env_lock();
622 let tmp = tempfile::tempdir().unwrap();
623 crate::test_env::set_var("LEAN_CTX_CONFIG_DIR", tmp.path());
624 let got = config_dir().unwrap();
625 crate::test_env::remove_var("LEAN_CTX_CONFIG_DIR");
626 assert_eq!(got, tmp.path());
627 }
628
629 #[test]
630 fn state_and_cache_dirs_honor_explicit_overrides() {
631 let _lock = crate::core::data_dir::test_env_lock();
632 let state = tempfile::tempdir().unwrap();
633 let cache = tempfile::tempdir().unwrap();
634 crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
635 crate::test_env::set_var("LEAN_CTX_CACHE_DIR", cache.path());
636 let got_state = state_dir().unwrap();
637 let got_cache = cache_dir().unwrap();
638 crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
639 crate::test_env::remove_var("LEAN_CTX_CACHE_DIR");
640 assert_eq!(got_state, state.path());
641 assert_eq!(got_cache, cache.path());
642 }
643
644 #[test]
645 fn data_dir_matches_lean_ctx_data_dir() {
646 let _guard = crate::core::data_dir::isolated_data_dir();
647 assert_eq!(
648 data_dir().unwrap(),
649 crate::core::data_dir::lean_ctx_data_dir().unwrap()
650 );
651 }
652
653 #[test]
654 fn runtime_dir_honors_xdg_runtime_dir() {
655 let _lock = crate::core::data_dir::test_env_lock();
656 let tmp = tempfile::tempdir().unwrap();
657 crate::test_env::set_var("XDG_RUNTIME_DIR", tmp.path());
658 let got = runtime_dir().unwrap();
659 crate::test_env::remove_var("XDG_RUNTIME_DIR");
660 assert_eq!(got, tmp.path().join("lean-ctx"));
661 }
662}