1use std::ffi::OsString;
47use std::path::{Path, PathBuf};
48
49use anyhow::{Context, Result, anyhow};
50
51pub const LOG_DIR_ENV: &str = "AI_MEMORY_LOG_DIR";
55
56pub const AUDIT_DIR_ENV: &str = "AI_MEMORY_AUDIT_DIR";
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum PathSource {
64 CliFlag,
66 EnvVar,
68 ConfigToml,
70 PlatformDefault,
72 SystemdLogsDir,
74}
75
76impl PathSource {
77 #[must_use]
78 pub fn as_str(self) -> &'static str {
79 match self {
80 Self::CliFlag => "CLI flag (--log-dir / --audit-dir)",
81 Self::EnvVar => "environment variable (AI_MEMORY_LOG_DIR / AI_MEMORY_AUDIT_DIR)",
82 Self::ConfigToml => "[logging]/[audit] path in config.toml",
83 Self::PlatformDefault => "platform default",
84 Self::SystemdLogsDir => "systemd LogsDirectory (/var/log/ai-memory/)",
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
92pub struct ResolvedDir {
93 pub path: PathBuf,
94 pub source: PathSource,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum DirKind {
101 Log,
102 Audit,
103}
104
105impl DirKind {
106 fn suffix(self) -> &'static str {
107 match self {
108 Self::Log => "logs",
109 Self::Audit => "audit",
110 }
111 }
112}
113
114pub fn resolve_log_dir(
123 cli_override: Option<&Path>,
124 config_path: Option<&str>,
125) -> Result<ResolvedDir> {
126 resolve_dir(DirKind::Log, cli_override, LOG_DIR_ENV, config_path)
127}
128
129pub fn resolve_audit_dir(
135 cli_override: Option<&Path>,
136 config_path: Option<&str>,
137) -> Result<ResolvedDir> {
138 resolve_dir(DirKind::Audit, cli_override, AUDIT_DIR_ENV, config_path)
139}
140
141fn resolve_dir(
142 kind: DirKind,
143 cli_override: Option<&Path>,
144 env_var: &str,
145 config_path: Option<&str>,
146) -> Result<ResolvedDir> {
147 let resolved = if let Some(p) = cli_override {
148 ResolvedDir {
149 path: PathBuf::from(p),
150 source: PathSource::CliFlag,
151 }
152 } else if let Some(env_val) = std::env::var_os(env_var) {
153 if env_val.is_empty() {
154 fall_through_to_config_or_default(kind, config_path)?
157 } else {
158 ResolvedDir {
159 path: PathBuf::from(env_val),
160 source: PathSource::EnvVar,
161 }
162 }
163 } else {
164 fall_through_to_config_or_default(kind, config_path)?
165 };
166
167 enforce_not_world_writable(&resolved)?;
168 Ok(resolved)
169}
170
171fn fall_through_to_config_or_default(
172 kind: DirKind,
173 config_path: Option<&str>,
174) -> Result<ResolvedDir> {
175 if let Some(raw) = config_path
176 && !raw.is_empty()
177 {
178 return Ok(ResolvedDir {
179 path: PathBuf::from(expand_tilde(raw)),
180 source: PathSource::ConfigToml,
181 });
182 }
183 Ok(platform_default(kind))
184}
185
186#[must_use]
190pub fn platform_default(kind: DirKind) -> ResolvedDir {
191 if std::env::var_os("INVOCATION_ID").is_some() {
198 let p = PathBuf::from("/var/log/ai-memory").join(kind.suffix());
199 if is_writable_dir(&p.parent().unwrap_or(&p)) {
200 return ResolvedDir {
201 path: p,
202 source: PathSource::SystemdLogsDir,
203 };
204 }
205 }
206
207 let p = if cfg!(target_os = "macos") {
213 macos_default(kind)
214 } else if cfg!(target_os = "windows") {
215 windows_default(kind)
216 } else {
217 linux_xdg_default(kind)
219 };
220 ResolvedDir {
221 path: p,
222 source: PathSource::PlatformDefault,
223 }
224}
225
226fn linux_xdg_default(kind: DirKind) -> PathBuf {
227 let base = std::env::var_os("XDG_STATE_HOME")
228 .filter(|s| !s.is_empty())
229 .map_or_else(
230 || {
231 let home = home_dir_or_dot();
232 home.join(".local").join("state")
233 },
234 PathBuf::from,
235 );
236 base.join("ai-memory").join(kind.suffix())
237}
238
239fn macos_default(kind: DirKind) -> PathBuf {
240 let home = home_dir_or_dot();
241 let base = home.join("Library").join("Logs").join("ai-memory");
242 match kind {
243 DirKind::Log => base,
244 DirKind::Audit => base.join("audit"),
245 }
246}
247
248fn windows_default(kind: DirKind) -> PathBuf {
249 let base = std::env::var_os("LOCALAPPDATA")
250 .filter(|s| !s.is_empty())
251 .map_or_else(
252 || {
253 home_dir_or_dot()
255 .join("AppData")
256 .join("Local")
257 .join("ai-memory")
258 },
259 |s| PathBuf::from(s).join("ai-memory"),
260 );
261 base.join(kind.suffix())
262}
263
264fn home_dir_or_dot() -> PathBuf {
265 if let Some(h) = std::env::var_os("HOME").filter(|s| !s.is_empty()) {
266 return PathBuf::from(h);
267 }
268 if let Some(h) = std::env::var_os("USERPROFILE").filter(|s| !s.is_empty()) {
269 return PathBuf::from(h);
270 }
271 PathBuf::from(".")
272}
273
274fn is_writable_dir(p: &Path) -> bool {
275 if !p.exists() || !p.is_dir() {
276 return false;
277 }
278 let probe = p.join(format!(".ai-memory-write-probe-{}", std::process::id()));
283 match std::fs::File::create(&probe) {
284 Ok(_) => {
285 let _ = std::fs::remove_file(&probe);
286 true
287 }
288 Err(_) => false,
289 }
290}
291
292pub fn enforce_not_world_writable(rd: &ResolvedDir) -> Result<()> {
299 #[cfg(unix)]
300 {
301 use std::os::unix::fs::PermissionsExt;
302 if !rd.path.exists() {
303 return Ok(());
304 }
305 let md = std::fs::metadata(&rd.path).with_context(|| {
312 format!(
313 "stat {} (resolved via {})",
314 rd.path.display(),
315 rd.source.as_str()
316 )
317 })?;
318 let mode = md.permissions().mode();
319 if mode & 0o002 != 0 {
320 return Err(anyhow!(
321 "log directory {} is world-writable (mode {:#o}); refusing for security. \
322 Resolved via: {}. Pick a non-world-writable directory and re-run.",
323 rd.path.display(),
324 mode & 0o7777,
325 rd.source.as_str()
326 ));
327 }
328 }
329 #[cfg(not(unix))]
330 {
331 let _ = rd;
335 }
336 Ok(())
337}
338
339pub fn ensure_dir_secure(dir: &Path) -> Result<()> {
346 std::fs::create_dir_all(dir)
347 .with_context(|| format!("creating log directory {}", dir.display()))?;
348 #[cfg(unix)]
349 {
350 use std::os::unix::fs::PermissionsExt;
351 let perms = std::fs::Permissions::from_mode(0o700);
352 std::fs::set_permissions(dir, perms)
358 .with_context(|| format!("setting mode 0700 on log directory {}", dir.display()))?;
359 }
360 Ok(())
361}
362
363#[must_use]
366pub fn expand_tilde(raw: &str) -> String {
367 if let Some(rest) = raw.strip_prefix("~/")
368 && let Some(home) = std::env::var_os("HOME")
369 {
370 let mut buf = OsString::from(home);
371 buf.push("/");
372 buf.push(rest);
373 return buf.to_string_lossy().into_owned();
374 }
375 raw.to_string()
376}
377
378#[cfg(test)]
383mod tests {
384 use super::*;
385
386 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
389 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
390 LOCK.get_or_init(|| std::sync::Mutex::new(()))
391 .lock()
392 .unwrap_or_else(|p| p.into_inner())
393 }
394
395 struct EnvGuard {
398 key: &'static str,
399 prev: Option<OsString>,
400 }
401 impl EnvGuard {
402 fn capture(key: &'static str) -> Self {
403 Self {
404 key,
405 prev: std::env::var_os(key),
406 }
407 }
408 fn set(&self, v: &str) {
409 unsafe {
412 std::env::set_var(self.key, v);
413 }
414 }
415 fn unset(&self) {
416 unsafe {
418 std::env::remove_var(self.key);
419 }
420 }
421 }
422 impl Drop for EnvGuard {
423 fn drop(&mut self) {
424 unsafe {
426 if let Some(v) = &self.prev {
427 std::env::set_var(self.key, v);
428 } else {
429 std::env::remove_var(self.key);
430 }
431 }
432 }
433 }
434
435 #[test]
436 fn log_dir_cli_flag_overrides_env_var() {
437 let _g = env_lock();
438 let env = EnvGuard::capture(LOG_DIR_ENV);
439 env.set("/should/not/win");
440 let cli = PathBuf::from("/cli/wins");
441 let resolved = resolve_log_dir(Some(&cli), Some("/config/loses")).unwrap();
442 assert_eq!(resolved.path, cli);
443 assert_eq!(resolved.source, PathSource::CliFlag);
444 }
445
446 #[test]
447 fn log_dir_env_var_overrides_config_toml() {
448 let _g = env_lock();
449 let env = EnvGuard::capture(LOG_DIR_ENV);
450 env.set("/env/wins");
451 let resolved = resolve_log_dir(None, Some("/config/loses")).unwrap();
452 assert_eq!(resolved.path, PathBuf::from("/env/wins"));
453 assert_eq!(resolved.source, PathSource::EnvVar);
454 }
455
456 #[test]
457 fn log_dir_config_toml_overrides_platform_default() {
458 let _g = env_lock();
459 let env = EnvGuard::capture(LOG_DIR_ENV);
460 env.unset();
461 let _inv = EnvGuard::capture("INVOCATION_ID");
462 _inv.unset();
463 let resolved = resolve_log_dir(None, Some("/config/wins")).unwrap();
464 assert_eq!(resolved.path, PathBuf::from("/config/wins"));
465 assert_eq!(resolved.source, PathSource::ConfigToml);
466 }
467
468 #[test]
469 fn log_dir_platform_default_resolves_per_os() {
470 let _g = env_lock();
471 let env = EnvGuard::capture(LOG_DIR_ENV);
472 env.unset();
473 let _inv = EnvGuard::capture("INVOCATION_ID");
474 _inv.unset();
475 let resolved = resolve_log_dir(None, None).unwrap();
476 assert_eq!(resolved.source, PathSource::PlatformDefault);
477 let s = resolved.path.to_string_lossy().to_string();
478 if cfg!(target_os = "macos") {
479 assert!(
480 s.contains("Library/Logs/ai-memory"),
481 "macOS default should be under Library/Logs/ai-memory, got {s}"
482 );
483 } else if cfg!(target_os = "windows") {
484 assert!(
485 s.to_lowercase().contains("ai-memory"),
486 "Windows default should contain ai-memory, got {s}"
487 );
488 } else {
489 assert!(
491 s.contains("ai-memory") && s.contains("logs"),
492 "Linux/Unix XDG default should contain ai-memory/logs, got {s}"
493 );
494 }
495 }
496
497 #[test]
498 fn audit_dir_cli_flag_overrides_env_var() {
499 let _g = env_lock();
500 let env = EnvGuard::capture(AUDIT_DIR_ENV);
501 env.set("/should/not/win");
502 let cli = PathBuf::from("/cli/audit/wins");
503 let resolved = resolve_audit_dir(Some(&cli), Some("/config/loses")).unwrap();
504 assert_eq!(resolved.path, cli);
505 assert_eq!(resolved.source, PathSource::CliFlag);
506 }
507
508 #[test]
509 fn audit_dir_env_var_overrides_config_toml() {
510 let _g = env_lock();
511 let env = EnvGuard::capture(AUDIT_DIR_ENV);
512 env.set("/env/audit/wins");
513 let resolved = resolve_audit_dir(None, Some("/config/loses")).unwrap();
514 assert_eq!(resolved.path, PathBuf::from("/env/audit/wins"));
515 assert_eq!(resolved.source, PathSource::EnvVar);
516 }
517
518 #[test]
519 fn audit_dir_config_toml_overrides_platform_default() {
520 let _g = env_lock();
521 let env = EnvGuard::capture(AUDIT_DIR_ENV);
522 env.unset();
523 let _inv = EnvGuard::capture("INVOCATION_ID");
524 _inv.unset();
525 let resolved = resolve_audit_dir(None, Some("/config/audit/wins")).unwrap();
526 assert_eq!(resolved.path, PathBuf::from("/config/audit/wins"));
527 assert_eq!(resolved.source, PathSource::ConfigToml);
528 }
529
530 #[test]
531 fn audit_dir_platform_default_resolves_per_os() {
532 let _g = env_lock();
533 let env = EnvGuard::capture(AUDIT_DIR_ENV);
534 env.unset();
535 let _inv = EnvGuard::capture("INVOCATION_ID");
536 _inv.unset();
537 let resolved = resolve_audit_dir(None, None).unwrap();
538 assert_eq!(resolved.source, PathSource::PlatformDefault);
539 let s = resolved.path.to_string_lossy().to_string();
540 assert!(
541 s.contains("ai-memory") && s.contains("audit"),
542 "audit platform default should mention ai-memory and audit, got {s}"
543 );
544 }
545
546 #[test]
547 #[cfg(unix)]
548 fn log_dir_creates_directory_with_secure_permissions() {
549 use std::os::unix::fs::PermissionsExt;
550 let tmp = tempfile::tempdir().unwrap();
551 let target = tmp.path().join("nested").join("logs");
552 ensure_dir_secure(&target).unwrap();
553 let md = std::fs::metadata(&target).unwrap();
554 let mode = md.permissions().mode() & 0o7777;
555 assert_eq!(
556 mode, 0o700,
557 "ensure_dir_secure must apply mode 0700 (got {mode:#o})"
558 );
559 }
560
561 #[test]
562 #[cfg(unix)]
563 fn log_dir_refuses_world_writable_destination() {
564 use std::os::unix::fs::PermissionsExt;
565 let _g = env_lock();
566 let tmp = tempfile::tempdir().unwrap();
567 let bad = tmp.path().join("worldwrite");
568 std::fs::create_dir(&bad).unwrap();
569 std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o777)).unwrap();
570 let env = EnvGuard::capture(LOG_DIR_ENV);
571 env.unset();
572 let err = resolve_log_dir(Some(&bad), None).unwrap_err();
573 let msg = format!("{err}");
574 assert!(
575 msg.contains("world-writable"),
576 "error should mention world-writable, got: {msg}"
577 );
578 assert!(
579 msg.contains("CLI flag"),
580 "error should name resolution layer (CLI flag), got: {msg}"
581 );
582 }
583
584 #[test]
585 #[cfg(unix)]
586 fn audit_dir_refuses_world_writable_destination() {
587 use std::os::unix::fs::PermissionsExt;
588 let _g = env_lock();
589 let tmp = tempfile::tempdir().unwrap();
590 let bad = tmp.path().join("audit-worldwrite");
591 std::fs::create_dir(&bad).unwrap();
592 std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o777)).unwrap();
593 let env = EnvGuard::capture(AUDIT_DIR_ENV);
594 env.unset();
595 let err = resolve_audit_dir(Some(&bad), None).unwrap_err();
596 assert!(format!("{err}").contains("world-writable"));
597 }
598
599 #[test]
600 fn log_dir_systemd_mode_uses_var_log_when_writable() {
601 let _g = env_lock();
610 let _inv = EnvGuard::capture("INVOCATION_ID");
611 _inv.set("test-invocation-id");
612
613 let tmp = tempfile::tempdir().unwrap();
614 assert!(is_writable_dir(tmp.path()));
616 assert!(!is_writable_dir(&tmp.path().join("does-not-exist")));
618
619 let resolved = platform_default(DirKind::Log);
625 assert!(matches!(
626 resolved.source,
627 PathSource::SystemdLogsDir | PathSource::PlatformDefault
628 ));
629
630 _inv.unset();
631 let resolved2 = platform_default(DirKind::Log);
632 assert_eq!(
633 resolved2.source,
634 PathSource::PlatformDefault,
635 "without INVOCATION_ID, must never pick SystemdLogsDir"
636 );
637 }
638
639 #[test]
640 fn log_dir_empty_env_var_falls_through_to_config() {
641 let _g = env_lock();
642 let env = EnvGuard::capture(LOG_DIR_ENV);
643 env.set("");
644 let resolved = resolve_log_dir(None, Some("/config/wins")).unwrap();
645 assert_eq!(resolved.source, PathSource::ConfigToml);
646 }
647
648 #[test]
649 fn expand_tilde_keeps_non_tilde_paths_unchanged() {
650 assert_eq!(expand_tilde("/abs/path"), "/abs/path");
651 assert_eq!(expand_tilde("relative/path"), "relative/path");
652 }
653
654 #[test]
655 fn path_source_strings_are_human_readable() {
656 for s in [
657 PathSource::CliFlag,
658 PathSource::EnvVar,
659 PathSource::ConfigToml,
660 PathSource::PlatformDefault,
661 PathSource::SystemdLogsDir,
662 ] {
663 assert!(!s.as_str().is_empty());
664 }
665 }
666
667 #[test]
673 fn expand_tilde_expands_home_dir() {
674 let _g = env_lock();
675 let env = EnvGuard::capture("HOME");
676 env.set("/test-home");
677 assert_eq!(expand_tilde("~/state/log"), "/test-home/state/log");
678 assert_eq!(expand_tilde("~root"), "~root");
681 }
682
683 #[test]
684 fn expand_tilde_no_home_keeps_input_unchanged() {
685 let _g = env_lock();
686 let env = EnvGuard::capture("HOME");
687 env.unset();
688 assert_eq!(expand_tilde("~/state"), "~/state");
690 }
691
692 #[cfg(unix)]
693 #[test]
694 fn enforce_not_world_writable_passes_through_on_nonexistent_path() {
695 let tmp = tempfile::tempdir().unwrap();
696 let r = ResolvedDir {
698 path: tmp.path().join("does-not-exist"),
699 source: PathSource::ConfigToml,
700 };
701 assert!(enforce_not_world_writable(&r).is_ok());
702 }
703
704 #[cfg(unix)]
705 #[test]
706 fn enforce_not_world_writable_passes_safe_dir() {
707 use std::os::unix::fs::PermissionsExt;
708 let tmp = tempfile::tempdir().unwrap();
709 let safe = tmp.path().join("safe");
710 std::fs::create_dir(&safe).unwrap();
711 std::fs::set_permissions(&safe, std::fs::Permissions::from_mode(0o755)).unwrap();
712 let r = ResolvedDir {
713 path: safe,
714 source: PathSource::ConfigToml,
715 };
716 assert!(enforce_not_world_writable(&r).is_ok());
717 }
718
719 #[test]
720 fn is_writable_dir_returns_false_for_a_file_path() {
721 let tmp = tempfile::tempdir().unwrap();
722 let f = tmp.path().join("regular.txt");
723 std::fs::write(&f, b"hello").unwrap();
724 assert!(!is_writable_dir(&f));
727 }
728
729 #[test]
730 fn is_writable_dir_returns_false_for_nonexistent_path() {
731 let tmp = tempfile::tempdir().unwrap();
732 assert!(!is_writable_dir(&tmp.path().join("nope")));
733 }
734
735 #[test]
736 fn dirkind_suffix_returns_logs_or_audit() {
737 assert_eq!(DirKind::Log.suffix(), "logs");
740 assert_eq!(DirKind::Audit.suffix(), "audit");
741 }
742
743 #[test]
744 fn ensure_dir_secure_creates_nested_path() {
745 let tmp = tempfile::tempdir().unwrap();
746 let target = tmp.path().join("a").join("b").join("c");
747 ensure_dir_secure(&target).unwrap();
748 assert!(target.is_dir());
749 }
750
751 #[test]
752 fn ensure_dir_secure_idempotent_on_existing_dir() {
753 let tmp = tempfile::tempdir().unwrap();
754 let target = tmp.path().join("present");
755 std::fs::create_dir(&target).unwrap();
756 ensure_dir_secure(&target).unwrap();
758 ensure_dir_secure(&target).unwrap();
759 }
760
761 #[test]
762 fn fall_through_uses_config_when_set() {
763 let _g = env_lock();
766 let env = EnvGuard::capture(LOG_DIR_ENV);
767 env.unset();
768 let r = resolve_log_dir(None, Some("/tmp/explicit-config")).unwrap();
769 assert_eq!(r.source, PathSource::ConfigToml);
770 assert_eq!(r.path, PathBuf::from("/tmp/explicit-config"));
771 }
772
773 #[test]
774 fn fall_through_expands_tilde_in_config_path() {
775 let _g = env_lock();
776 let env = EnvGuard::capture(LOG_DIR_ENV);
777 env.unset();
778 let home = EnvGuard::capture("HOME");
779 home.set("/test-tilde-home");
780 let r = resolve_log_dir(None, Some("~/state/logs")).unwrap();
781 assert_eq!(r.path, PathBuf::from("/test-tilde-home/state/logs"));
783 assert_eq!(r.source, PathSource::ConfigToml);
784 }
785
786 #[test]
787 fn fall_through_empty_config_path_uses_platform_default() {
788 let _g = env_lock();
789 let env = EnvGuard::capture(LOG_DIR_ENV);
790 env.unset();
791 let _inv = EnvGuard::capture("INVOCATION_ID");
792 _inv.unset();
793 let r = resolve_log_dir(None, Some("")).unwrap();
796 assert_eq!(r.source, PathSource::PlatformDefault);
797 }
798
799 #[test]
800 fn empty_audit_env_var_falls_through_to_config() {
801 let _g = env_lock();
802 let env = EnvGuard::capture(AUDIT_DIR_ENV);
803 env.set("");
804 let r = resolve_audit_dir(None, Some("/cfg/audit")).unwrap();
805 assert_eq!(r.source, PathSource::ConfigToml);
806 assert_eq!(r.path, PathBuf::from("/cfg/audit"));
807 }
808
809 #[cfg(unix)]
821 #[test]
822 fn is_writable_dir_returns_false_when_parent_is_readonly() {
823 use std::os::unix::fs::PermissionsExt;
827 let tmp = tempfile::tempdir().unwrap();
828 let ro = tmp.path().join("readonly");
829 std::fs::create_dir(&ro).unwrap();
830 std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o555)).unwrap();
831 assert!(!is_writable_dir(&ro));
832 std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o755)).unwrap();
834 }
835
836 #[test]
837 fn home_dir_or_dot_falls_back_to_dot_when_no_home() {
838 let _g = env_lock();
842 let home = EnvGuard::capture("HOME");
843 home.unset();
844 let user = EnvGuard::capture("USERPROFILE");
845 user.unset();
846 let p = super::home_dir_or_dot();
847 assert_eq!(p, PathBuf::from("."));
849 }
850
851 #[test]
852 fn home_dir_or_dot_prefers_home_over_userprofile() {
853 let _g = env_lock();
854 let home = EnvGuard::capture("HOME");
855 home.set("/test-home-precedence");
856 let user = EnvGuard::capture("USERPROFILE");
857 user.set("/test-userprofile");
858 let p = super::home_dir_or_dot();
859 assert_eq!(p, PathBuf::from("/test-home-precedence"));
860 }
861
862 #[test]
863 fn home_dir_or_dot_uses_userprofile_when_home_unset() {
864 let _g = env_lock();
865 let home = EnvGuard::capture("HOME");
866 home.unset();
867 let user = EnvGuard::capture("USERPROFILE");
868 user.set("/test-userprofile-only");
869 let p = super::home_dir_or_dot();
870 assert_eq!(p, PathBuf::from("/test-userprofile-only"));
871 }
872
873 #[cfg(target_os = "macos")]
874 #[test]
875 fn macos_default_returns_library_logs_path() {
876 let _g = env_lock();
878 let home = EnvGuard::capture("HOME");
879 home.set("/test-home");
880 let log = super::macos_default(DirKind::Log);
881 assert_eq!(log, PathBuf::from("/test-home/Library/Logs/ai-memory"));
882 let audit = super::macos_default(DirKind::Audit);
883 assert_eq!(
884 audit,
885 PathBuf::from("/test-home/Library/Logs/ai-memory/audit")
886 );
887 }
888
889 #[test]
890 fn linux_xdg_default_uses_xdg_state_home_when_set() {
891 let _g = env_lock();
895 let xdg = EnvGuard::capture("XDG_STATE_HOME");
896 xdg.set("/custom-xdg");
897 let p = super::linux_xdg_default(DirKind::Log);
898 assert_eq!(p, PathBuf::from("/custom-xdg/ai-memory/logs"));
899 let pa = super::linux_xdg_default(DirKind::Audit);
900 assert_eq!(pa, PathBuf::from("/custom-xdg/ai-memory/audit"));
901 }
902
903 #[test]
904 fn linux_xdg_default_falls_back_to_home_local_state() {
905 let _g = env_lock();
906 let xdg = EnvGuard::capture("XDG_STATE_HOME");
907 xdg.unset();
908 let home = EnvGuard::capture("HOME");
909 home.set("/test-home-xdg");
910 let p = super::linux_xdg_default(DirKind::Log);
911 assert_eq!(
912 p,
913 PathBuf::from("/test-home-xdg/.local/state/ai-memory/logs")
914 );
915 }
916
917 #[test]
918 fn linux_xdg_default_empty_xdg_falls_back_to_local_state() {
919 let _g = env_lock();
920 let xdg = EnvGuard::capture("XDG_STATE_HOME");
921 xdg.set("");
922 let home = EnvGuard::capture("HOME");
923 home.set("/test-home-empty-xdg");
924 let p = super::linux_xdg_default(DirKind::Log);
925 assert_eq!(
926 p,
927 PathBuf::from("/test-home-empty-xdg/.local/state/ai-memory/logs")
928 );
929 }
930
931 #[test]
932 fn windows_default_uses_localappdata_when_set() {
933 let _g = env_lock();
938 let app = EnvGuard::capture("LOCALAPPDATA");
939 app.set("/winapp");
940 let p = super::windows_default(DirKind::Log);
941 assert_eq!(p, PathBuf::from("/winapp/ai-memory/logs"));
942 let pa = super::windows_default(DirKind::Audit);
943 assert_eq!(pa, PathBuf::from("/winapp/ai-memory/audit"));
944 }
945
946 #[test]
947 fn windows_default_falls_back_to_home_appdata_when_localappdata_unset() {
948 let _g = env_lock();
949 let app = EnvGuard::capture("LOCALAPPDATA");
950 app.unset();
951 let home = EnvGuard::capture("HOME");
952 home.set("/test-win-home");
953 let user = EnvGuard::capture("USERPROFILE");
954 user.unset();
955 let p = super::windows_default(DirKind::Log);
956 assert_eq!(
957 p,
958 PathBuf::from("/test-win-home/AppData/Local/ai-memory/logs")
959 );
960 }
961
962 #[test]
963 fn windows_default_empty_localappdata_falls_back_to_home_appdata() {
964 let _g = env_lock();
965 let app = EnvGuard::capture("LOCALAPPDATA");
966 app.set("");
967 let home = EnvGuard::capture("HOME");
968 home.set("/test-win-home-empty");
969 let p = super::windows_default(DirKind::Log);
970 assert_eq!(
971 p,
972 PathBuf::from("/test-win-home-empty/AppData/Local/ai-memory/logs")
973 );
974 }
975}