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