Skip to main content

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}