Skip to main content

linesmith_core/data_context/
xdg.rs

1//! Single source of truth for the linesmith XDG cascade.
2//!
3//! All `linesmith` paths derived from `$XDG_*_HOME` / `$HOME` flow
4//! through [`resolve_subdir`]. Three runtime callers used to
5//! re-implement the same cascade independently (cache root, segment
6//! plugin dir, user theme dir), with the doctor mirroring all three —
7//! every consumer was a drift opportunity. Collapsing them here
8//! gives one cascade definition + one piece of test surface.
9//!
10//! The function is pure: it takes [`XdgEnv`] (a snapshot of the
11//! relevant env vars) and a [`XdgScope`] tag, returns a `PathBuf` or
12//! `None`. Callers build [`XdgEnv`] at the boundary — `driver.rs`
13//! from `CliEnv`, `doctor` from its [`crate::doctor::EnvVarState`]
14//! snapshot — so the cascade itself never touches `std::env`.
15
16use std::ffi::OsString;
17use std::path::PathBuf;
18
19/// Snapshot of the env vars the XDG cascade reads. Only `xdg` and
20/// `home` matter; the cascade has no other inputs.
21///
22/// Fields are `OsString` (not `String`) because Unix paths are
23/// byte-strings — a user with `XDG_CACHE_HOME=/srv/café-bin` in a
24/// non-UTF-8 locale must not silently lose their setting. `None` and
25/// empty-string both count as "not set" — the cascade treats them
26/// the same. The [`XdgEnv::from_process_env`] factory normalizes at
27/// the boundary so each call site doesn't have to.
28#[derive(Debug, Clone, Default)]
29#[non_exhaustive]
30pub struct XdgEnv {
31    pub(crate) xdg_cache_home: Option<OsString>,
32    pub(crate) xdg_config_home: Option<OsString>,
33    pub(crate) home: Option<OsString>,
34}
35
36impl XdgEnv {
37    /// Snapshot the three env vars from the running process, treating
38    /// empty strings as unset per the XDG spec. Non-UTF-8 values are
39    /// preserved; `Option<OsString>` carries them through to the
40    /// cascade unchanged.
41    #[must_use]
42    pub fn from_process_env() -> Self {
43        Self::from_os_options(
44            std::env::var_os("XDG_CACHE_HOME"),
45            std::env::var_os("XDG_CONFIG_HOME"),
46            std::env::var_os("HOME"),
47        )
48    }
49
50    /// Build an [`XdgEnv`] from raw `OsString` reads (the shape
51    /// `std::env::var_os` returns). Filters empty values to `None`
52    /// at the single normalization point.
53    #[must_use]
54    pub fn from_os_options(
55        xdg_cache_home: Option<OsString>,
56        xdg_config_home: Option<OsString>,
57        home: Option<OsString>,
58    ) -> Self {
59        fn nonempty(v: Option<OsString>) -> Option<OsString> {
60            v.filter(|s| !s.is_empty())
61        }
62        Self {
63            xdg_cache_home: nonempty(xdg_cache_home),
64            xdg_config_home: nonempty(xdg_config_home),
65            home: nonempty(home),
66        }
67    }
68}
69
70/// Which XDG base spec directory the caller wants.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72#[non_exhaustive]
73pub enum XdgScope {
74    /// `$XDG_CACHE_HOME` → `$HOME/.cache`
75    Cache,
76    /// `$XDG_CONFIG_HOME` → `$HOME/.config`
77    Config,
78}
79
80/// Resolve `$XDG_<scope>_HOME/linesmith/<sub>` falling back to
81/// `$HOME/.<cache|config>/linesmith/<sub>`. `None` when neither
82/// source is populated.
83///
84/// Pass `sub = ""` for the linesmith root (used by
85/// [`crate::data_context::cache::default_root`]); pass a sub-name
86/// like `"segments"` or `"themes"` for the runtime user-content
87/// dirs. Empty `sub` short-circuits the inner helper, which omits
88/// the join rather than relying on `PathBuf` to suppress an empty
89/// component.
90#[must_use]
91pub fn resolve_subdir(env: &XdgEnv, scope: XdgScope, sub: &str) -> Option<PathBuf> {
92    let (xdg, home_sub) = match scope {
93        XdgScope::Cache => (env.xdg_cache_home.as_deref(), ".cache"),
94        XdgScope::Config => (env.xdg_config_home.as_deref(), ".config"),
95    };
96    if let Some(x) = xdg {
97        return Some(linesmith_subdir(PathBuf::from(x), sub));
98    }
99    env.home
100        .as_deref()
101        .map(|h| linesmith_subdir(PathBuf::from(h).join(home_sub), sub))
102}
103
104fn linesmith_subdir(base: PathBuf, sub: &str) -> PathBuf {
105    let with_app = base.join("linesmith");
106    if sub.is_empty() {
107        with_app
108    } else {
109        with_app.join(sub)
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    fn os(s: &str) -> Option<OsString> {
118        Some(OsString::from(s))
119    }
120
121    #[test]
122    fn from_os_options_filters_empty_strings_to_none() {
123        let env = XdgEnv::from_os_options(Some(OsString::new()), os("/x"), Some(OsString::new()));
124        assert_eq!(env.xdg_cache_home, None);
125        assert_eq!(env.xdg_config_home, os("/x"));
126        assert_eq!(env.home, None);
127    }
128
129    #[test]
130    fn cache_scope_prefers_xdg_cache_home_over_home() {
131        let env = XdgEnv::from_os_options(os("/xdg"), None, os("/home"));
132        assert_eq!(
133            resolve_subdir(&env, XdgScope::Cache, ""),
134            Some(PathBuf::from("/xdg/linesmith"))
135        );
136    }
137
138    #[test]
139    fn cache_scope_falls_back_to_home_dot_cache() {
140        let env = XdgEnv::from_os_options(None, None, os("/home/user"));
141        assert_eq!(
142            resolve_subdir(&env, XdgScope::Cache, ""),
143            Some(PathBuf::from("/home/user/.cache/linesmith"))
144        );
145    }
146
147    #[test]
148    fn config_scope_uses_xdg_config_home() {
149        let env = XdgEnv::from_os_options(None, os("/conf"), None);
150        assert_eq!(
151            resolve_subdir(&env, XdgScope::Config, "segments"),
152            Some(PathBuf::from("/conf/linesmith/segments"))
153        );
154    }
155
156    #[test]
157    fn config_scope_falls_back_to_home_dot_config() {
158        let env = XdgEnv::from_os_options(None, None, os("/home/user"));
159        assert_eq!(
160            resolve_subdir(&env, XdgScope::Config, "themes"),
161            Some(PathBuf::from("/home/user/.config/linesmith/themes"))
162        );
163    }
164
165    #[test]
166    fn config_scope_does_not_borrow_xdg_cache_home() {
167        // A user with $XDG_CACHE_HOME set but $XDG_CONFIG_HOME unset
168        // (and $HOME unset) gets `None` for Config — the two scopes
169        // do not share env vars.
170        let env = XdgEnv::from_os_options(os("/xdg-cache"), None, None);
171        assert_eq!(resolve_subdir(&env, XdgScope::Config, ""), None);
172    }
173
174    #[test]
175    fn returns_none_when_neither_xdg_nor_home_is_set() {
176        let env = XdgEnv::default();
177        assert_eq!(resolve_subdir(&env, XdgScope::Cache, "x"), None);
178        assert_eq!(resolve_subdir(&env, XdgScope::Config, "y"), None);
179    }
180
181    #[test]
182    fn empty_sub_does_not_append_trailing_slash() {
183        let env = XdgEnv::from_os_options(os("/xdg"), None, None);
184        let path = resolve_subdir(&env, XdgScope::Cache, "").unwrap();
185        assert_eq!(path, PathBuf::from("/xdg/linesmith"));
186        assert_eq!(path.components().count(), 3); // "/", "xdg", "linesmith"
187    }
188
189    #[cfg(unix)]
190    #[test]
191    fn preserves_non_utf8_xdg_cache_home() {
192        // Critical regression guard: Unix paths are byte-strings.
193        // A user with `XDG_CACHE_HOME=/srv/<latin1-bytes>` must NOT
194        // silently fall through to $HOME. The OsString-typed env
195        // fields preserve these bytes through the cascade; an
196        // earlier `String`-typed implementation would have dropped
197        // them via the `var().ok()` collapse to `None`.
198        use std::os::unix::ffi::OsStringExt;
199        // Latin-1 "café" (\xe9 is non-UTF-8 as a standalone byte).
200        let bytes = b"/srv/caf\xe9-bin".to_vec();
201        let xdg = OsString::from_vec(bytes.clone());
202        let env = XdgEnv::from_os_options(Some(xdg), None, os("/home/user"));
203        let resolved = resolve_subdir(&env, XdgScope::Cache, "").unwrap();
204        // Result preserves the non-UTF-8 ancestor; uses XDG (not the
205        // $HOME fallback) because the XDG var was set + non-empty.
206        assert!(
207            resolved
208                .as_os_str()
209                .as_encoded_bytes()
210                .starts_with(b"/srv/caf\xe9-bin/linesmith"),
211            "expected non-UTF-8 XDG to be preserved: got {:?}",
212            resolved
213        );
214    }
215}