hotl_platform/paths/mod.rs
1//! [`KnownPaths`] — where hotl's home, config, data and runtime directories are.
2
3use std::path::PathBuf;
4
5#[cfg(unix)]
6mod unix;
7#[cfg(unix)]
8pub use unix::UnixKnownPaths;
9#[cfg(unix)]
10pub type ActiveKnownPaths = UnixKnownPaths;
11
12#[cfg(windows)]
13mod windows;
14#[cfg(windows)]
15pub use windows::WindowsKnownPaths;
16#[cfg(windows)]
17pub type ActiveKnownPaths = WindowsKnownPaths;
18
19/// The directories hotl reads and writes its own state in.
20///
21/// CONTRACT: an explicitly-set `XDG_*` or `HOME` wins on **every** platform.
22/// That is not Unix bias — it is what keeps a Git Bash or MSYS2 user coherent
23/// between their shell and hotl, and those are exactly the users who have a
24/// POSIX shell on Windows.
25///
26/// Every method returns `Option` rather than a fallback path: "no home" must
27/// stay expressible, because a missing `$HOME` is how the config layer already
28/// decides there is no user rules tier. Narrower, never wider.
29pub trait KnownPaths: crate::sealed::Sealed {
30 fn home(&self) -> Option<PathBuf>;
31 fn config(&self) -> Option<PathBuf>;
32 fn data(&self) -> Option<PathBuf>;
33 /// A directory for sockets, pipes and pidfiles that need not survive a
34 /// reboot. `None` where the platform has no such concept.
35 fn runtime(&self) -> Option<PathBuf>;
36}
37
38/// Read one env var, rejecting the empty string — an exported-but-empty
39/// `XDG_CONFIG_HOME` means "unset", not "the current directory".
40pub(crate) fn env_path(name: &str) -> Option<PathBuf> {
41 std::env::var_os(name)
42 .filter(|v| !v.is_empty())
43 .map(PathBuf::from)
44}
45
46#[cfg(test)]
47pub(crate) fn assert_known_paths_contract<P: KnownPaths>(paths: &P) {
48 // The bug this trait exists to prevent, stated as an assertion: a
49 // hand-rolled `HOME` lookup returns nothing on Windows, and every caller's
50 // fallback chain then lands on a *relative* path — putting hotl's config
51 // and its session logs in whatever directory it was launched from. Inside
52 // the workspace, inside the sandbox write root, and readable by the agent
53 // whose transcripts they are.
54 for (what, dir) in [
55 ("home", paths.home()),
56 ("config", paths.config()),
57 ("data", paths.data()),
58 ] {
59 if let Some(dir) = dir {
60 assert!(
61 dir.is_absolute(),
62 "{what}() must be absolute or None, never a cwd-relative path: {dir:?}"
63 );
64 }
65 }
66 if let Some(data) = paths.data() {
67 assert!(data.is_absolute(), "data() must be absolute, got {data:?}");
68 }
69 if let (Some(c), Some(d)) = (paths.config(), paths.data()) {
70 assert_ne!(c, d, "config() and data() must not collide");
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 #[test]
79 fn active_adapter_upholds_the_contract() {
80 assert_known_paths_contract(&crate::KNOWN_PATHS);
81 }
82
83 /// The clause that makes Git Bash coherent with hotl: an explicit
84 /// `XDG_DATA_HOME` wins, on Windows too.
85 #[test]
86 fn an_explicit_xdg_var_wins_on_every_platform() {
87 // Serialized against the sibling env test by running in one body — the
88 // process env is shared, and a parallel test harness would race.
89 let key = "XDG_DATA_HOME";
90 let restore = std::env::var_os(key);
91 let want = if cfg!(windows) { r"C:\xdg" } else { "/xdg" };
92 // SAFETY: single-threaded within this test; restored before returning.
93 unsafe { std::env::set_var(key, want) };
94 let got = crate::KNOWN_PATHS.data();
95 // SAFETY: as above.
96 unsafe {
97 match restore {
98 Some(v) => std::env::set_var(key, v),
99 None => std::env::remove_var(key),
100 }
101 }
102 assert_eq!(got, Some(PathBuf::from(want).join("hotl")));
103 }
104}