Skip to main content

openlogi_core/
paths.rs

1//! Per-OS application directories, following the XDG Base Directory spec on
2//! **every** platform — including macOS, so configuration lives at the
3//! familiar `~/.config/openlogi/` rather than macOS's
4//! `~/Library/Application Support/`.
5//!
6//! | kind   | env override        | default                       |
7//! |--------|---------------------|-------------------------------|
8//! | config | `$XDG_CONFIG_HOME`  | `~/.config/openlogi`          |
9//! | data   | `$XDG_DATA_HOME`    | `~/.local/share/openlogi`     |
10//!
11//! On Windows `$HOME` falls back to `%USERPROFILE%`, so paths resolve to
12//! `%USERPROFILE%\.config\openlogi` etc. — best-effort until a real Windows
13//! port lands.
14
15use std::ffi::OsString;
16use std::path::{Path, PathBuf};
17
18use thiserror::Error;
19
20/// Subdirectory created under each XDG base directory.
21const APP_DIR: &str = "openlogi";
22
23#[derive(Debug, Error)]
24pub enum PathsError {
25    #[error("could not resolve a home directory for the current user")]
26    HomeNotFound,
27}
28
29/// The user's home directory: `$HOME`, falling back to `%USERPROFILE%`.
30fn home() -> Result<PathBuf, PathsError> {
31    std::env::var_os("HOME")
32        .or_else(|| std::env::var_os("USERPROFILE"))
33        .filter(|h| !h.is_empty())
34        .map(PathBuf::from)
35        .ok_or(PathsError::HomeNotFound)
36}
37
38/// Resolve an XDG base directory plus the [`APP_DIR`] subdir.
39///
40/// Honours `env_value` only when it is an absolute path — per the spec a
41/// relative `$XDG_*_HOME` is invalid and must be ignored — otherwise falls
42/// back to `$HOME/<fallback>`. Split from the `std::env` read so the
43/// branching can be unit-tested without mutating process-global env vars.
44fn xdg_base(env_value: Option<OsString>, fallback: &[&str]) -> Result<PathBuf, PathsError> {
45    match env_value {
46        Some(v) if Path::new(&v).is_absolute() => Ok(PathBuf::from(v).join(APP_DIR)),
47        _ => {
48            let mut dir = home()?;
49            dir.extend(fallback);
50            dir.push(APP_DIR);
51            Ok(dir)
52        }
53    }
54}
55
56/// The raw XDG config home directory (without the `openlogi` subdirectory).
57///
58/// Honours an absolute `$XDG_CONFIG_HOME`; falls back to `~/.config`.
59/// Useful when placing files that belong to other apps under the same base
60/// (e.g. systemd user units at `$XDG_CONFIG_HOME/systemd/user/`).
61pub fn xdg_config_home() -> Result<PathBuf, PathsError> {
62    match std::env::var_os("XDG_CONFIG_HOME") {
63        Some(v) if Path::new(&v).is_absolute() => Ok(PathBuf::from(v)),
64        _ => Ok(home()?.join(".config")),
65    }
66}
67
68/// Directory holding the user's `config.toml`.
69///
70/// `$XDG_CONFIG_HOME/openlogi`, default `~/.config/openlogi`.
71pub fn config_dir() -> Result<PathBuf, PathsError> {
72    Ok(xdg_config_home()?.join(APP_DIR))
73}
74
75/// Full path to the user config file.
76pub fn config_path() -> Result<PathBuf, PathsError> {
77    Ok(config_dir()?.join("config.toml"))
78}
79
80/// Directory for downloaded application data; the device-render asset cache
81/// lives under `data_dir()/assets`.
82///
83/// `$XDG_DATA_HOME/openlogi`, default `~/.local/share/openlogi`.
84pub fn data_dir() -> Result<PathBuf, PathsError> {
85    xdg_base(std::env::var_os("XDG_DATA_HOME"), &[".local", "share"])
86}
87
88/// Resolve the runtime directory holding the agent's IPC socket. Honours an
89/// absolute `$XDG_RUNTIME_DIR` (Linux); otherwise falls back to [`config_dir`]
90/// — macOS has no `$XDG_RUNTIME_DIR`, and the config dir is already
91/// user-private. Split from the env read so the branch is unit-testable.
92fn runtime_base(env_value: Option<OsString>) -> Result<PathBuf, PathsError> {
93    match env_value {
94        Some(v) if Path::new(&v).is_absolute() => Ok(PathBuf::from(v).join(APP_DIR)),
95        _ => config_dir(),
96    }
97}
98
99/// Directory for runtime sockets — the background agent's IPC endpoint.
100pub fn runtime_dir() -> Result<PathBuf, PathsError> {
101    runtime_base(std::env::var_os("XDG_RUNTIME_DIR"))
102}
103
104/// Path to the background agent's Unix-domain IPC socket: the GUI connects here
105/// to reach the agent that owns device I/O.
106pub fn agent_socket_path() -> Result<PathBuf, PathsError> {
107    Ok(runtime_dir()?.join("agent.sock"))
108}
109
110#[cfg(all(test, unix))]
111#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn absolute_xdg_override_is_used_verbatim() {
117        let dir = xdg_base(Some("/tmp/xdg-config".into()), &[".config"])
118            .expect("absolute override needs no home dir");
119        assert_eq!(dir, PathBuf::from("/tmp/xdg-config/openlogi"));
120    }
121
122    #[test]
123    fn relative_xdg_value_is_ignored_per_spec() {
124        // A relative $XDG_*_HOME is invalid, so this must fall back to
125        // $HOME/.config/openlogi rather than honour the relative value.
126        let dir = xdg_base(Some("relative/dir".into()), &[".config"]).expect("home dir resolves");
127        assert!(dir.ends_with("openlogi"));
128        assert!(!dir.to_string_lossy().contains("relative"));
129    }
130
131    #[test]
132    fn absolute_runtime_dir_is_used_verbatim() {
133        let dir = runtime_base(Some("/run/user/501".into())).expect("absolute override");
134        assert_eq!(dir, PathBuf::from("/run/user/501/openlogi"));
135    }
136
137    #[test]
138    fn relative_runtime_dir_falls_back_to_config() {
139        // A relative $XDG_RUNTIME_DIR is invalid, so this falls back to the
140        // config dir (also ending in openlogi) rather than honouring it.
141        let dir = runtime_base(Some("relative/run".into())).expect("config dir resolves");
142        assert!(dir.ends_with("openlogi"));
143        assert!(!dir.to_string_lossy().contains("relative"));
144    }
145}