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//! | state  | `$XDG_STATE_HOME`   | `~/.local/state/openlogi`     |
11//!
12//! On Windows `$HOME` falls back to `%USERPROFILE%`, so paths resolve to
13//! `%USERPROFILE%\.config\openlogi` etc.
14//!
15//! **Decision (#347):** the Windows location is final, not best-effort.
16//! XDG-on-every-platform is this module's deliberate design — macOS also
17//! skips its native `~/Library/Application Support` — and Windows follows
18//! the same rule rather than `%APPDATA%`. Recorded before the agent first
19//! shipped in Windows artifacts, because moving it afterwards would strand
20//! every existing user's `config.toml` and the agent's first-run state.
21
22//! Local packaged macOS builds stamped with dev-channel identifiers use the
23//! same layout under an `openlogi-dev` app directory.
24
25use std::path::PathBuf;
26use std::sync::OnceLock;
27
28use etcetera::{BaseStrategy, base_strategy::Xdg};
29use thiserror::Error;
30
31/// Production subdirectory created under each XDG base directory.
32///
33/// Public because the dev tooling has to name the same directories from
34/// outside a running app — `xtask macos dev-bundle` remembers the developer's
35/// codesigning certificate under [`DEV_APP_DIR`], where `cargo clean` cannot
36/// reach it.
37pub const APP_DIR: &str = "openlogi";
38/// Local macOS dev builds use a separate profile so development agents
39/// cannot take over the installed app's socket, lock, config, or asset cache.
40pub const DEV_APP_DIR: &str = "openlogi-dev";
41
42/// Failure resolving the per-user base directories.
43#[derive(Debug, Error)]
44pub enum PathsError {
45    /// No home directory could be determined for the current user, so none
46    /// of the XDG bases resolve.
47    #[error("could not resolve a home directory for the current user")]
48    HomeNotFound,
49}
50
51fn xdg() -> Result<Xdg, PathsError> {
52    Xdg::new().map_err(|_| PathsError::HomeNotFound)
53}
54
55fn app_dir() -> &'static str {
56    static IS_DEV_PROFILE: OnceLock<bool> = OnceLock::new();
57    if *IS_DEV_PROFILE.get_or_init(is_dev_profile) {
58        DEV_APP_DIR
59    } else {
60        APP_DIR
61    }
62}
63
64fn is_dev_profile() -> bool {
65    match std::env::var("OPENLOGI_PROFILE") {
66        Ok(value) if value == "dev" => return true,
67        Ok(value) if matches!(value.as_str(), "prod" | "production") => return false,
68        _ => {}
69    }
70
71    #[cfg(target_os = "macos")]
72    {
73        if let Some(identifier) = current_bundle_identifier() {
74            return crate::brand::is_dev_id(&identifier);
75        }
76    }
77
78    false
79}
80
81#[cfg(target_os = "macos")]
82fn current_bundle_identifier() -> Option<String> {
83    let exe = std::env::current_exe().ok()?;
84    for ancestor in exe.ancestors() {
85        if !ancestor
86            .extension()
87            .is_some_and(|ext| ext.eq_ignore_ascii_case("app"))
88        {
89            continue;
90        }
91
92        let info = ancestor.join("Contents/Info.plist");
93        let Ok(plist) = plist::Value::from_file(info) else {
94            continue;
95        };
96        let Some(identifier) = plist
97            .as_dictionary()
98            .and_then(|dictionary| dictionary.get("CFBundleIdentifier"))
99            .and_then(plist::Value::as_string)
100        else {
101            continue;
102        };
103        return Some(identifier.to_owned());
104    }
105
106    None
107}
108
109/// The current user's home directory.
110///
111/// The plain home, not an XDG base — for callers placing files under
112/// OS-native locations (e.g. macOS `~/Library/LaunchAgents`).
113pub fn home_dir() -> Result<PathBuf, PathsError> {
114    Ok(xdg()?.home_dir().to_path_buf())
115}
116
117/// The raw XDG config home directory (without the `openlogi` subdirectory).
118///
119/// Honours an absolute `$XDG_CONFIG_HOME`; falls back to `~/.config`.
120/// Useful when placing files that belong to other apps under the same base
121/// (e.g. systemd user units at `$XDG_CONFIG_HOME/systemd/user/`).
122pub fn xdg_config_home() -> Result<PathBuf, PathsError> {
123    Ok(xdg()?.config_dir())
124}
125
126/// Directory holding the user's `config.toml`.
127///
128/// `$XDG_CONFIG_HOME/openlogi`, default `~/.config/openlogi`.
129/// Local macOS dev builds use `openlogi-dev` instead.
130pub fn config_dir() -> Result<PathBuf, PathsError> {
131    Ok(xdg_config_home()?.join(app_dir()))
132}
133
134/// Full path to the user config file.
135pub fn config_path() -> Result<PathBuf, PathsError> {
136    Ok(config_dir()?.join("config.toml"))
137}
138
139/// Directory for downloaded application data; the device-render asset cache
140/// lives under `data_dir()/assets`.
141///
142/// `$XDG_DATA_HOME/openlogi`, default `~/.local/share/openlogi`.
143/// Local macOS dev builds use `openlogi-dev` instead.
144pub fn data_dir() -> Result<PathBuf, PathsError> {
145    Ok(xdg()?.data_dir().join(app_dir()))
146}
147
148/// Directory for logs and other rebuildable process state — the agent's
149/// rotated log files live here.
150///
151/// `$XDG_STATE_HOME/openlogi`, default `~/.local/state/openlogi`.
152/// Local macOS dev builds use `openlogi-dev` instead.
153pub fn state_dir() -> Result<PathBuf, PathsError> {
154    let xdg = xdg()?;
155    Ok(xdg
156        .state_dir()
157        .map_or_else(|| xdg.data_dir().join(app_dir()), |dir| dir.join(app_dir())))
158}
159
160/// Directory for runtime sockets — the background agent's IPC endpoint.
161pub fn runtime_dir() -> Result<PathBuf, PathsError> {
162    let xdg = xdg()?;
163    Ok(xdg.runtime_dir().map_or_else(
164        || xdg.config_dir().join(app_dir()),
165        |dir| dir.join(app_dir()),
166    ))
167}
168
169/// Path to the background agent's Unix-domain IPC socket: the GUI connects here
170/// to reach the agent that owns device I/O.
171pub fn agent_socket_path() -> Result<PathBuf, PathsError> {
172    Ok(runtime_dir()?.join("agent.sock"))
173}
174
175#[cfg(test)]
176#[cfg(unix)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn config_dir_keeps_openlogi_under_xdg_config_home() {
182        assert!(config_dir().expect("config dir").ends_with("openlogi"));
183    }
184
185    #[test]
186    fn data_dir_keeps_openlogi_under_xdg_data_home() {
187        assert!(data_dir().expect("data dir").ends_with("openlogi"));
188    }
189
190    #[test]
191    fn runtime_dir_keeps_openlogi_suffix() {
192        assert!(runtime_dir().expect("runtime dir").ends_with("openlogi"));
193    }
194}