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.
32const APP_DIR: &str = "openlogi";
33/// Local macOS dev builds use a separate profile so development agents
34/// cannot take over the installed app's socket, lock, config, or asset cache.
35const DEV_APP_DIR: &str = "openlogi-dev";
36/// The user's configuration file, under [`config_dir`].
37pub const CONFIG_FILE: &str = "config.toml";
38
39/// Which of the two side-by-side installs a process belongs to.
40///
41/// Decides the per-profile directory under every XDG base, so a local dev
42/// build never touches the shipped app's socket, lock, config, or asset cache.
43/// Tooling that has to name a profile's files from outside a process of that
44/// profile — `xtask macos dev-bundle` waiting for the dev agent's socket, or
45/// remembering the developer's codesigning certificate — asks for that
46/// profile's paths by name instead of rebuilding them.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum Profile {
49    /// The shipped app.
50    Production,
51    /// A local packaged macOS build stamped with dev-channel identifiers.
52    Dev,
53}
54
55impl Profile {
56    /// The profile this process runs under: forced by [`crate::env::PROFILE`],
57    /// or detected from the bundle the executable lives in. Memoized — the
58    /// answer cannot change within a process lifetime.
59    #[must_use]
60    pub fn current() -> Self {
61        static CURRENT: OnceLock<Profile> = OnceLock::new();
62        *CURRENT.get_or_init(Self::detect)
63    }
64
65    /// The launchd service label this profile's bundle declares — what its
66    /// embedded LaunchAgent plist carries, `SMAppService` registers, and
67    /// `launchctl` addresses. The dev variant is suffixed, so a dev
68    /// registration can never collide with the shipped one. Frozen once
69    /// shipped; see [`crate::brand::AGENT_SERVICE_LABEL`].
70    #[must_use]
71    pub fn agent_service_label(self) -> String {
72        match self {
73            Self::Production => crate::brand::AGENT_SERVICE_LABEL.to_owned(),
74            Self::Dev => crate::brand::dev_id(crate::brand::AGENT_SERVICE_LABEL),
75        }
76    }
77
78    fn detect() -> Self {
79        match std::env::var(crate::env::PROFILE) {
80            Ok(value) if value == Self::Dev.env_value() => return Self::Dev,
81            // `production` is the long-hand spelling older docs used.
82            Ok(value) if value == Self::Production.env_value() || value == "production" => {
83                return Self::Production;
84            }
85            _ => {}
86        }
87
88        #[cfg(target_os = "macos")]
89        {
90            if let Some(identifier) = current_bundle_identifier()
91                && crate::brand::is_dev_id(&identifier)
92            {
93                return Self::Dev;
94            }
95        }
96
97        Self::Production
98    }
99
100    /// The value of [`crate::env::PROFILE`] that forces this profile.
101    #[must_use]
102    pub const fn env_value(self) -> &'static str {
103        match self {
104            Self::Production => "prod",
105            Self::Dev => "dev",
106        }
107    }
108
109    /// The per-profile directory name under each XDG base.
110    const fn app_dir(self) -> &'static str {
111        match self {
112            Self::Production => APP_DIR,
113            Self::Dev => DEV_APP_DIR,
114        }
115    }
116}
117
118/// Failure resolving the per-user base directories.
119#[derive(Debug, Error)]
120pub enum PathsError {
121    /// No home directory could be determined for the current user, so none
122    /// of the XDG bases resolve.
123    #[error("could not resolve a home directory for the current user")]
124    HomeNotFound,
125}
126
127fn xdg() -> Result<Xdg, PathsError> {
128    Xdg::new().map_err(|_| PathsError::HomeNotFound)
129}
130
131fn app_dir() -> &'static str {
132    Profile::current().app_dir()
133}
134
135/// Whether this process runs under the dev profile; see [`Profile::current`].
136#[must_use]
137pub fn is_dev_profile() -> bool {
138    Profile::current() == Profile::Dev
139}
140
141#[cfg(target_os = "macos")]
142fn current_bundle_identifier() -> Option<String> {
143    let exe = std::env::current_exe().ok()?;
144    for ancestor in exe.ancestors() {
145        if !ancestor
146            .extension()
147            .is_some_and(|ext| ext.eq_ignore_ascii_case("app"))
148        {
149            continue;
150        }
151
152        let info = ancestor.join("Contents/Info.plist");
153        let Ok(plist) = plist::Value::from_file(info) else {
154            continue;
155        };
156        let Some(identifier) = plist
157            .as_dictionary()
158            .and_then(|dictionary| dictionary.get("CFBundleIdentifier"))
159            .and_then(plist::Value::as_string)
160        else {
161            continue;
162        };
163        return Some(identifier.to_owned());
164    }
165
166    None
167}
168
169/// The current user's home directory.
170///
171/// The plain home, not an XDG base — for callers placing files under
172/// OS-native locations (e.g. macOS `~/Library/LaunchAgents`).
173pub fn home_dir() -> Result<PathBuf, PathsError> {
174    Ok(xdg()?.home_dir().to_path_buf())
175}
176
177/// The raw XDG config home directory (without the `openlogi` subdirectory).
178///
179/// Honours an absolute `$XDG_CONFIG_HOME`; falls back to `~/.config`.
180/// Useful when reading files that belong to another app's namespace under the
181/// same base. This tier is the user's own: generated files belong under
182/// [`xdg_data_home`] instead, which systemd and friends rank below it.
183pub fn xdg_config_home() -> Result<PathBuf, PathsError> {
184    Ok(xdg()?.config_dir())
185}
186
187/// The raw XDG data home directory (without the `openlogi` subdirectory).
188///
189/// Honours an absolute `$XDG_DATA_HOME`; falls back to `~/.local/share`.
190/// The counterpart to [`xdg_config_home`] for files that belong to another
191/// app's namespace under the same base — generated systemd user units at
192/// `$XDG_DATA_HOME/systemd/user/`, which is the tier systemd reserves for
193/// units installed on the user's behalf rather than authored by them.
194pub fn xdg_data_home() -> Result<PathBuf, PathsError> {
195    Ok(xdg()?.data_dir())
196}
197
198/// Directory holding the user's `config.toml`.
199///
200/// `$XDG_CONFIG_HOME/openlogi`, default `~/.config/openlogi`.
201/// Local macOS dev builds use `openlogi-dev` instead.
202pub fn config_dir() -> Result<PathBuf, PathsError> {
203    config_dir_for(Profile::current())
204}
205
206/// [`config_dir`] for a named profile, for tooling outside that profile.
207pub fn config_dir_for(profile: Profile) -> Result<PathBuf, PathsError> {
208    Ok(xdg_config_home()?.join(profile.app_dir()))
209}
210
211/// Full path to the user config file.
212pub fn config_path() -> Result<PathBuf, PathsError> {
213    Ok(config_dir()?.join(CONFIG_FILE))
214}
215
216/// Directory for downloaded application data; the device-render asset cache
217/// lives under `data_dir()/assets`.
218///
219/// `$XDG_DATA_HOME/openlogi`, default `~/.local/share/openlogi`.
220/// Local macOS dev builds use `openlogi-dev` instead.
221pub fn data_dir() -> Result<PathBuf, PathsError> {
222    Ok(xdg()?.data_dir().join(app_dir()))
223}
224
225/// Directory for logs and other rebuildable process state — the agent's
226/// rotated log files live here.
227///
228/// `$XDG_STATE_HOME/openlogi`, default `~/.local/state/openlogi`.
229/// Local macOS dev builds use `openlogi-dev` instead.
230pub fn state_dir() -> Result<PathBuf, PathsError> {
231    let xdg = xdg()?;
232    Ok(xdg
233        .state_dir()
234        .map_or_else(|| xdg.data_dir().join(app_dir()), |dir| dir.join(app_dir())))
235}
236
237/// Directory for runtime sockets — the background agent's IPC endpoint.
238pub fn runtime_dir() -> Result<PathBuf, PathsError> {
239    runtime_dir_for(Profile::current())
240}
241
242/// [`runtime_dir`] for a named profile, for tooling outside that profile.
243pub fn runtime_dir_for(profile: Profile) -> Result<PathBuf, PathsError> {
244    let xdg = xdg()?;
245    Ok(xdg.runtime_dir().map_or_else(
246        || xdg.config_dir().join(profile.app_dir()),
247        |dir| dir.join(profile.app_dir()),
248    ))
249}
250
251/// Path to the background agent's Unix-domain IPC socket: the GUI connects here
252/// to reach the agent that owns device I/O.
253pub fn agent_socket_path() -> Result<PathBuf, PathsError> {
254    agent_socket_path_for(Profile::current())
255}
256
257/// [`agent_socket_path`] for a named profile: where tooling waits for a dev
258/// agent it just started, without being a dev-profile process itself.
259pub fn agent_socket_path_for(profile: Profile) -> Result<PathBuf, PathsError> {
260    Ok(runtime_dir_for(profile)?.join("agent.sock"))
261}
262
263#[cfg(test)]
264#[cfg(unix)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn config_dir_keeps_openlogi_under_xdg_config_home() {
270        assert!(config_dir().expect("config dir").ends_with("openlogi"));
271    }
272
273    #[test]
274    fn data_dir_keeps_openlogi_under_xdg_data_home() {
275        assert!(data_dir().expect("data dir").ends_with("openlogi"));
276    }
277
278    #[test]
279    fn runtime_dir_keeps_openlogi_suffix() {
280        assert!(runtime_dir().expect("runtime dir").ends_with("openlogi"));
281    }
282
283    #[test]
284    fn a_named_profile_resolves_its_own_directories() {
285        assert!(
286            config_dir_for(Profile::Dev)
287                .expect("dev config dir")
288                .ends_with("openlogi-dev")
289        );
290        assert!(
291            agent_socket_path_for(Profile::Dev)
292                .expect("dev socket")
293                .ends_with("openlogi-dev/agent.sock")
294        );
295        assert!(
296            agent_socket_path_for(Profile::Production)
297                .expect("production socket")
298                .ends_with("openlogi/agent.sock")
299        );
300    }
301}