lumen_core/app_paths.rs
1//! Process-global app location: the directory an app was loaded from, and
2//! the per-user directory it keeps saved data in.
3//!
4//! A script function that names a file runs outside the world, so it cannot
5//! look the app up through a `&World`. It reads this cache instead, the way
6//! [`crate::i18n`] and [`crate::window_state`] carry their own process-global
7//! seams. The runtime publishes both values with [`set_app`] while it builds
8//! the app, before any script host loads, so `on_start` already sees them.
9//!
10//! Until then the app directory reads as the process working directory and
11//! the id as `lumen-app`, which keeps a bare host in a test or in
12//! `lumenc check` resolving paths instead of failing.
13
14use std::path::{Path, PathBuf};
15use std::sync::{Mutex, OnceLock};
16
17/// The id an app falls back to before the runtime publishes a real one.
18const DEFAULT_APP_ID: &str = "lumen-app";
19
20#[derive(Debug, Clone)]
21struct AppPaths {
22 dir: PathBuf,
23 id: String,
24}
25
26impl Default for AppPaths {
27 fn default() -> Self {
28 Self {
29 dir: PathBuf::from("."),
30 id: DEFAULT_APP_ID.to_string(),
31 }
32 }
33}
34
35fn cell() -> &'static Mutex<AppPaths> {
36 static PATHS: OnceLock<Mutex<AppPaths>> = OnceLock::new();
37 PATHS.get_or_init(|| Mutex::new(AppPaths::default()))
38}
39
40/// Publish the app's directory and its stable id (`[app] id` in
41/// `lumen.toml`, else the app directory name).
42pub fn set_app(dir: impl Into<PathBuf>, id: impl Into<String>) {
43 let (dir, id) = (dir.into(), id.into());
44 if let Ok(mut p) = cell().lock() {
45 p.dir = dir;
46 p.id = id;
47 }
48}
49
50/// The directory the app was loaded from.
51pub fn app_dir() -> PathBuf {
52 cell()
53 .lock()
54 .map(|p| p.dir.clone())
55 .unwrap_or_else(|_| PathBuf::from("."))
56}
57
58/// The app's stable id.
59pub fn app_id() -> String {
60 cell()
61 .lock()
62 .map(|p| p.id.clone())
63 .unwrap_or_else(|_| DEFAULT_APP_ID.to_string())
64}
65
66/// Resolve a path an app author wrote: a relative path against the app
67/// directory, an absolute path unchanged. It is the rule the runtime already
68/// applies to the paths its script commands carry, so
69/// `"data/tasks.json"` names the same file wherever the app was
70/// started from.
71pub fn resolve(path: impl AsRef<Path>) -> PathBuf {
72 let path = path.as_ref();
73 if path.is_relative() {
74 app_dir().join(path)
75 } else {
76 path.to_path_buf()
77 }
78}
79
80/// The directory this app keeps saved data in, created when missing.
81///
82/// The location follows the platform convention for user data:
83/// `$XDG_DATA_HOME` (else `~/.local/share`) on Linux,
84/// `~/Library/Application Support` on macOS, `%APPDATA%` on Windows. Each
85/// app gets `lumen/<app-id>` under that root, so two apps sharing a machine
86/// keep their saves apart. A machine that offers no per-user data root at
87/// all falls back to the app directory.
88pub fn data_dir() -> PathBuf {
89 let dir = data_dir_for(&app_id());
90 if let Err(e) = std::fs::create_dir_all(&dir) {
91 crate::warn_line!("data_dir({}): {e}", dir.display());
92 }
93 dir
94}
95
96/// The directory a given app id keeps saved data in, without touching (or
97/// creating) it and without going through the process-global published app.
98///
99/// Same platform root and shape as [`data_dir`] (`<root>/lumen/<id>`), but
100/// takes the id explicitly: an OS-capability crate that scopes storage to
101/// an app id of its own - rather than the current process's published one -
102/// resolves its storage through this, so there is one definition of the
103/// shape and not a second copy of it.
104pub fn data_dir_for(id: &str) -> PathBuf {
105 data_dir_at(data_root(), id)
106}
107
108/// Join a resolved root and an app id into the one path shape every
109/// per-app data directory uses. Exposed so a caller that already holds an
110/// override root (a test, an embedder) can place a directory the same way
111/// [`data_dir_for`] does, instead of reimplementing the join.
112pub fn data_dir_under(root: &Path, id: &str) -> PathBuf {
113 root.join("lumen").join(id)
114}
115
116/// Place one app's data under a resolved root. Split out so the fallback
117/// arm is reachable without a machine that lacks a data root.
118fn data_dir_at(root: Option<PathBuf>, id: &str) -> PathBuf {
119 match root {
120 Some(root) => data_dir_under(&root, id),
121 None => app_dir(),
122 }
123}
124
125fn data_root() -> Option<PathBuf> {
126 data_root_from(|key| std::env::var(key).ok().filter(|v| !v.is_empty()))
127}
128
129/// Read the per-user data root out of `env`. Takes the lookup as an
130/// argument so tests drive the set and unset arms without writing to the
131/// process environment.
132fn data_root_from(env: impl Fn(&str) -> Option<String>) -> Option<PathBuf> {
133 #[cfg(target_os = "linux")]
134 {
135 if let Some(d) = env("XDG_DATA_HOME") {
136 return Some(PathBuf::from(d));
137 }
138 env("HOME").map(|h| Path::new(&h).join(".local").join("share"))
139 }
140 #[cfg(target_os = "macos")]
141 {
142 env("HOME").map(|h| Path::new(&h).join("Library").join("Application Support"))
143 }
144 #[cfg(target_os = "windows")]
145 {
146 env("APPDATA").map(PathBuf::from)
147 }
148 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
149 {
150 let _ = env;
151 None
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 /// One test for the whole module: the cache is process-global, so
160 /// separate `#[test]` functions would race over the published app.
161 #[test]
162 fn paths_follow_the_published_app() {
163 let dir = std::env::temp_dir().join(format!("lumen-app-paths-{}", std::process::id()));
164 set_app(&dir, "lumen-core-app-paths-test");
165 assert_eq!(app_dir(), dir);
166 assert_eq!(app_id(), "lumen-core-app-paths-test");
167
168 // Relative goes to the app, absolute is left alone.
169 assert_eq!(resolve("tasks.json"), dir.join("tasks.json"));
170 let absolute = dir.join("elsewhere.json");
171 assert_eq!(resolve(&absolute), absolute);
172
173 // A data root scopes the app under it; no root falls back to the app.
174 let root = dir.join("share");
175 assert_eq!(
176 data_dir_at(Some(root.clone()), "lumen-core-app-paths-test"),
177 root.join("lumen").join("lumen-core-app-paths-test")
178 );
179 assert_eq!(data_dir_at(None, "lumen-core-app-paths-test"), dir);
180
181 // `data_dir_under` is the one join every per-app data directory goes
182 // through; an id-scoped crate (an OS capability that is not the
183 // published app) reaches the same shape through it directly.
184 assert_eq!(
185 data_dir_under(&root, "other-app"),
186 root.join("lumen").join("other-app")
187 );
188
189 // The live directory exists once asked for, and sits under the app id.
190 let live = data_dir();
191 assert!(live.is_dir(), "{} was not created", live.display());
192 assert!(live.ends_with("lumen-core-app-paths-test") || live == dir);
193 if live != dir {
194 let _ = std::fs::remove_dir(&live);
195 }
196
197 // A directory that cannot be made is reported, not raised: the caller
198 // still gets a path, and the write it was for fails on its own terms.
199 // Both the id and the fallback name something that cannot be a
200 // directory, so the failure holds with or without a data root.
201 let blocked = dir.join("not-a-directory");
202 std::fs::create_dir_all(&dir).expect("app dir");
203 std::fs::write(&blocked, "").expect("blocking file");
204 set_app(&blocked, "cannot\0be-a-directory");
205 assert!(!data_dir().is_dir());
206 let _ = std::fs::remove_dir_all(&dir);
207 }
208
209 #[test]
210 fn the_data_root_reads_the_platform_variables() {
211 let set = data_root_from(|key| match key {
212 "XDG_DATA_HOME" | "HOME" | "APPDATA" => Some("/home/tester/share".to_string()),
213 _ => None,
214 });
215 assert!(set.is_some(), "a populated environment resolves a root");
216 assert!(set.unwrap().starts_with("/home/tester"));
217 // Without the XDG override the home directory carries the root.
218 let home_only =
219 data_root_from(|key| (key != "XDG_DATA_HOME").then(|| "/home/tester".to_string()));
220 assert!(home_only.is_some_and(|d| d.starts_with("/home/tester")));
221 assert_eq!(data_root_from(|_| None), None);
222 // The process environment path, whatever this machine reports.
223 let _ = data_root();
224 }
225}