phosphor_app/paths.rs
1//! Where Phosphor keeps the files it owns.
2//!
3//! One module rather than a `HOME` lookup at each call site, because the three
4//! places that needed a home directory — the theme preference, the preset
5//! banks and the debug log — each hand-rolled it, and all three got the same
6//! answer wrong in the same way. `HOME` is a Unix variable. Windows does not
7//! set it, so every one of those lookups came back `None` there, and the call
8//! sites treat `None` as "do nothing": a player saved a preset, was told
9//! nothing, and the preset did not exist. Losing work quietly is the worst
10//! failure this application has, so the rule lives in one place and is tested.
11//!
12//! The resolution rule, in order:
13//!
14//! 1. `PHOSPHOR_HOME`, if it is set to something non-blank. Names the
15//! directory itself, not a parent — that is what makes a portable install
16//! or an isolated test run possible.
17//! 2. On Unix, `$HOME/.phosphor`. Exactly what every previous version wrote,
18//! and pinned by a test so it stays that way.
19//! 3. On Windows, `%APPDATA%\phosphor`, falling back to
20//! `%USERPROFILE%\AppData\Roaming\phosphor`. `%APPDATA%` is where Windows
21//! keeps per-user application data and is what the platform's own file
22//! dialogs will show; a dotted directory in the profile root is a Unix
23//! habit that does not belong there. `HOME` is deliberately *not* consulted
24//! on Windows even though MSYS and Git Bash set it, because then the same
25//! installation would keep two sets of presets depending on which shell
26//! launched it.
27//!
28//! An empty or all-whitespace variable counts as unset. `HOME=""` used to
29//! produce the relative path `.phosphor`, which scatters presets into whatever
30//! directory the process happened to start in — the exact outcome the `None`
31//! branch exists to prevent.
32//!
33//! [`Convention`] is a value rather than a `cfg` so the Windows rule can be
34//! tested on a Unix machine. A `#[cfg(windows)]` function is a function nobody
35//! here can run.
36
37use std::path::{Path, PathBuf};
38
39// ── Platform convention ──
40
41/// Which platform's directory layout to follow.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Convention {
44 /// A dotted directory in the home directory: `$HOME/.phosphor`.
45 Unix,
46 /// The roaming application-data directory: `%APPDATA%\phosphor`.
47 Windows,
48}
49
50/// The convention this build follows.
51#[cfg(windows)]
52pub const NATIVE: Convention = Convention::Windows;
53
54/// The convention this build follows.
55#[cfg(not(windows))]
56pub const NATIVE: Convention = Convention::Unix;
57
58/// Environment variable that names the application directory outright.
59pub const OVERRIDE_VAR: &str = "PHOSPHOR_HOME";
60
61/// Directory name under `%APPDATA%`. Not dotted: Windows does not hide files
62/// by name, and `AppData\Roaming\.phosphor` looks like a mistake.
63const WINDOWS_DIR: &str = "phosphor";
64
65/// Directory name under `$HOME`.
66const UNIX_DIR: &str = ".phosphor";
67
68/// What a save or open prompt has always started with, and still does when
69/// the working directory has one.
70const LOCAL_SESSIONS: &str = "sessions";
71
72// ── Resolution ──
73
74/// The directory holding everything Phosphor owns, or `None` when the
75/// environment names no home directory at all.
76///
77/// `None` is not a path to fall back on — there genuinely is nowhere to write
78/// — so callers say so rather than writing relative to the working directory.
79pub fn app_dir() -> Option<PathBuf> {
80 app_dir_in(NATIVE, |key| std::env::var(key).ok())
81}
82
83/// [`app_dir`] with the platform and the environment supplied.
84///
85/// The whole rule is in here, as a function of its inputs, so both platforms'
86/// answers can be asserted from any machine.
87pub fn app_dir_in(convention: Convention, env: impl Fn(&str) -> Option<String>) -> Option<PathBuf> {
88 let var = |key: &str| {
89 env(key)
90 .filter(|value| !value.trim().is_empty())
91 .map(PathBuf::from)
92 };
93
94 if let Some(dir) = var(OVERRIDE_VAR) {
95 return Some(dir);
96 }
97
98 match convention {
99 Convention::Unix => Some(var("HOME")?.join(UNIX_DIR)),
100 Convention::Windows => var("APPDATA")
101 .or_else(|| var("USERPROFILE").map(|p| p.join("AppData").join("Roaming")))
102 .map(|p| p.join(WINDOWS_DIR)),
103 }
104}
105
106/// Where user preset banks live — `<app dir>/presets`.
107pub fn preset_dir() -> Option<PathBuf> {
108 app_dir().map(|dir| dir.join("presets"))
109}
110
111/// Where sessions live when the player has not named somewhere else —
112/// `<app dir>/sessions`.
113pub fn session_dir() -> Option<PathBuf> {
114 app_dir().map(|dir| dir.join(LOCAL_SESSIONS))
115}
116
117// ── Session prompts ──
118
119/// The text a save or open prompt starts the field with.
120///
121/// `sessions/` when the working directory already has a `sessions` directory.
122/// That is a checkout being run from its own root, which is how this has
123/// always behaved and what the sessions already on disk are relative to.
124///
125/// Otherwise the absolute `<app dir>/sessions/`. A bare `sessions/` resolves
126/// against wherever the process was started, and a Start Menu shortcut, a
127/// Finder alias or a desktop launcher starts it somewhere the player has never
128/// looked — so the file is written successfully to a directory nobody will
129/// find again.
130pub fn session_prompt_dir() -> String {
131 session_prompt_dir_from(Path::new(LOCAL_SESSIONS).is_dir(), session_dir())
132}
133
134/// [`session_prompt_dir`] with the filesystem answers supplied: whether the
135/// working directory has a `sessions` directory, and what [`session_dir`] says.
136pub fn session_prompt_dir_from(local_exists: bool, sessions: Option<PathBuf>) -> String {
137 // A forward slash even on Windows, which accepts it everywhere a
138 // backslash goes, so the string a checkout sees is one string.
139 let local = format!("{LOCAL_SESSIONS}/");
140 if local_exists {
141 return local;
142 }
143 match sessions {
144 Some(dir) => format!("{}{}", dir.display(), std::path::MAIN_SEPARATOR),
145 None => local,
146 }
147}
148
149/// Where to look for a session the player named in the open prompt.
150///
151/// An absolute path is taken as given, and so is a relative one that exists
152/// against the working directory — that is where it has always resolved and a
153/// checkout must keep working. Only when neither finds a file does this try
154/// the application directory, so `sessions/take3.phos` still opens after the
155/// player stops launching from the checkout.
156///
157/// Saving does not go through this. A save resolves the path exactly as typed,
158/// as it always has; it is the *prompt* that starts somewhere deterministic.
159/// Making a write depend on which files happen to exist is how a save lands
160/// somewhere the player did not ask for.
161pub fn find_session(input: &Path) -> PathBuf {
162 find_session_in(input, app_dir().as_deref())
163}
164
165/// [`find_session`] with the application directory supplied.
166pub fn find_session_in(input: &Path, app: Option<&Path>) -> PathBuf {
167 if input.is_absolute() || input.exists() {
168 return input.to_path_buf();
169 }
170 let Some(app) = app else {
171 return input.to_path_buf();
172 };
173 // `sessions/take3.phos` first, then a bare `take3.phos`.
174 for base in [app.to_path_buf(), app.join(LOCAL_SESSIONS)] {
175 let candidate = base.join(input);
176 if candidate.exists() {
177 return candidate;
178 }
179 }
180 input.to_path_buf()
181}
182
183/// These run on every platform, including the one whose rule they are mostly
184/// about, and the assertions are written to mean the same thing on all of
185/// them. Two habits make that work:
186///
187/// * A `PathBuf` expectation is built with the same `join` calls the code
188/// uses, never spelled out with a separator in it. `join` inserts the host's
189/// separator, so a literal would only match on the host it was typed for.
190/// * Where a literal does appear, it is safe because `Path`'s `PartialEq`
191/// compares `components()`, and Windows counts `/` and `\` as separators
192/// alike — so `x.join("y")` and `"x/y"` are equal there as well as here.
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 /// An environment with exactly these variables in it and nothing else.
198 fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
199 move |key| {
200 vars.iter()
201 .find(|(name, _)| *name == key)
202 .map(|(_, value)| (*value).to_string())
203 }
204 }
205
206 /// The paths every macOS and Linux build has written since the beginning.
207 /// If this test changes, somebody's presets moved.
208 #[test]
209 fn unix_resolves_to_the_dot_directory_it_always_has() {
210 let vars = env(&[("HOME", "/home/player")]);
211 assert_eq!(
212 app_dir_in(Convention::Unix, &vars),
213 Some(PathBuf::from("/home/player/.phosphor"))
214 );
215 assert_eq!(
216 app_dir_in(Convention::Unix, &vars).map(|d| d.join("presets")),
217 Some(PathBuf::from("/home/player/.phosphor/presets"))
218 );
219 }
220
221 /// `APPDATA` is the first thing Windows offers and the first thing taken.
222 ///
223 /// Asserted as a `join` rather than against a literal, because `PathBuf`
224 /// is host-flavoured: run on Unix, `join` inserts `/`. What this can prove
225 /// from any machine is which variable was read and which segments were
226 /// appended to it, which is the whole of the rule. Which separator ends up
227 /// between them is `std::path`'s business and correct by construction.
228 #[test]
229 fn windows_resolves_under_appdata() {
230 let vars = env(&[
231 ("APPDATA", r"C:\Users\player\AppData\Roaming"),
232 ("USERPROFILE", r"C:\Users\player"),
233 ]);
234 assert_eq!(
235 app_dir_in(Convention::Windows, &vars),
236 Some(PathBuf::from(r"C:\Users\player\AppData\Roaming").join("phosphor"))
237 );
238 }
239
240 /// Service accounts and stripped environments can be missing `APPDATA`
241 /// while still having a profile. Rebuilding the roaming path from the
242 /// profile lands in the same place `APPDATA` would have named.
243 #[test]
244 fn windows_falls_back_to_the_user_profile() {
245 let vars = env(&[("USERPROFILE", r"C:\Users\player")]);
246 assert_eq!(
247 app_dir_in(Convention::Windows, &vars),
248 Some(
249 PathBuf::from(r"C:\Users\player")
250 .join("AppData")
251 .join("Roaming")
252 .join("phosphor")
253 )
254 );
255 }
256
257 /// The defect this module exists for, stated as a test: a Windows
258 /// environment has no `HOME`, and the old lookup answered `None` — which
259 /// every call site read as "quietly do nothing".
260 #[test]
261 fn a_windows_environment_without_home_still_resolves() {
262 let vars = env(&[
263 ("APPDATA", r"C:\Users\player\AppData\Roaming"),
264 ("USERPROFILE", r"C:\Users\player"),
265 ]);
266 assert_eq!(vars("HOME"), None, "this test is about HOME being absent");
267 assert!(
268 app_dir_in(Convention::Windows, &vars).is_some(),
269 "presets would be silently discarded"
270 );
271 }
272
273 /// `HOME` on Windows is a shell's habit, not the platform's, and honouring
274 /// it would give one installation two sets of presets depending on how it
275 /// was launched.
276 #[test]
277 fn windows_ignores_home() {
278 let vars = env(&[("HOME", "/c/Users/player")]);
279 assert_eq!(app_dir_in(Convention::Windows, &vars), None);
280 }
281
282 /// The override names the directory itself — no suffix appended — which is
283 /// what a portable install and an isolated test both need.
284 #[test]
285 fn the_override_wins_on_both_platforms() {
286 let vars = env(&[
287 (OVERRIDE_VAR, "/tmp/scratch-phosphor"),
288 ("HOME", "/home/player"),
289 ("APPDATA", r"C:\Users\player\AppData\Roaming"),
290 ]);
291 for convention in [Convention::Unix, Convention::Windows] {
292 assert_eq!(
293 app_dir_in(convention, &vars),
294 Some(PathBuf::from("/tmp/scratch-phosphor")),
295 "{convention:?} did not honour {OVERRIDE_VAR}"
296 );
297 }
298 }
299
300 /// A variable set to nothing is not a home directory. `HOME=""` used to
301 /// produce the relative path `.phosphor`, which puts presets in whatever
302 /// directory the process was launched from.
303 #[test]
304 fn a_blank_variable_is_not_a_home_directory() {
305 for blank in ["", " "] {
306 assert_eq!(app_dir_in(Convention::Unix, env(&[("HOME", blank)])), None);
307 assert_eq!(
308 app_dir_in(Convention::Windows, env(&[("APPDATA", blank)])),
309 None
310 );
311 assert_eq!(
312 app_dir_in(Convention::Unix, env(&[(OVERRIDE_VAR, blank), ("HOME", "/h")])),
313 Some(PathBuf::from("/h/.phosphor")),
314 "a blank override swallowed the real home directory"
315 );
316 }
317 }
318
319 /// Nothing to go on means nowhere to write, and the caller has to say so.
320 #[test]
321 fn an_empty_environment_resolves_to_nothing() {
322 for convention in [Convention::Unix, Convention::Windows] {
323 assert_eq!(app_dir_in(convention, env(&[])), None);
324 }
325 }
326
327 /// A checkout keeps the prompt it has always had. The absolute form only
328 /// appears where the relative one would have resolved somewhere arbitrary.
329 #[test]
330 fn the_prompt_prefers_a_checkouts_own_sessions_directory() {
331 let sessions = PathBuf::from("/home/player/.phosphor").join("sessions");
332 assert_eq!(session_prompt_dir_from(true, Some(sessions.clone())), "sessions/");
333 assert_eq!(
334 session_prompt_dir_from(false, Some(sessions.clone())),
335 format!("{}{}", sessions.display(), std::path::MAIN_SEPARATOR)
336 );
337 // No home directory at all: the relative path is still better than an
338 // empty prompt, and it is what this did before.
339 assert_eq!(session_prompt_dir_from(false, None), "sessions/");
340 }
341
342 /// Opening finds the file in the working directory first, then in the
343 /// application directory, and hands back what was typed when neither has
344 /// it so the failure message names the path the player entered.
345 #[test]
346 fn opening_falls_back_to_the_application_directory() {
347 let root = std::env::temp_dir().join(format!("phosphor-paths-{}", std::process::id()));
348 let _ = std::fs::remove_dir_all(&root);
349 let app = root.join("app");
350 std::fs::create_dir_all(app.join("sessions")).unwrap();
351 std::fs::write(app.join("sessions").join("take3.phos"), "{}").unwrap();
352
353 // `sessions/take3.phos`, typed from a directory that has no `sessions`.
354 assert_eq!(
355 find_session_in(Path::new("sessions/take3.phos"), Some(&app)),
356 app.join("sessions").join("take3.phos")
357 );
358 // A bare name finds it too.
359 assert_eq!(
360 find_session_in(Path::new("take3.phos"), Some(&app)),
361 app.join("sessions").join("take3.phos")
362 );
363 // Nothing anywhere: unchanged, so the error names what was typed.
364 assert_eq!(
365 find_session_in(Path::new("nowhere.phos"), Some(&app)),
366 PathBuf::from("nowhere.phos")
367 );
368 // An absolute path is never rewritten, even when it does not exist.
369 let absolute = root.join("elsewhere.phos");
370 assert_eq!(find_session_in(&absolute, Some(&app)), absolute);
371
372 let _ = std::fs::remove_dir_all(&root);
373 }
374
375 /// A file in the working directory wins over one of the same name in the
376 /// application directory — the relative path a checkout types has to keep
377 /// meaning the checkout's own file.
378 #[test]
379 fn the_working_directory_wins_over_the_application_directory() {
380 let root = std::env::temp_dir().join(format!("phosphor-paths-cwd-{}", std::process::id()));
381 let _ = std::fs::remove_dir_all(&root);
382 let app = root.join("app");
383 std::fs::create_dir_all(app.join("sessions")).unwrap();
384 std::fs::write(app.join("sessions").join("Cargo.toml"), "{}").unwrap();
385
386 // Cargo runs a test with the package root as the working directory.
387 assert!(Path::new("Cargo.toml").exists(), "this test needs a file in the working directory");
388 // `Cargo.toml` exists relative to this crate's working directory, and
389 // the same name exists in the application directory. The local one is
390 // the answer.
391 assert_eq!(
392 find_session_in(Path::new("Cargo.toml"), Some(&app)),
393 PathBuf::from("Cargo.toml")
394 );
395
396 let _ = std::fs::remove_dir_all(&root);
397 }
398
399 // ── The wiring, against the real process environment ──
400 //
401 // The tests above prove the rule. These prove `app_dir` is actually
402 // wired to it: a correct rule reached through the wrong variable is the
403 // defect this module was written to fix.
404 //
405 // Every reader and writer of the process environment in this crate goes
406 // through `std::env`, which serialises them against each other, so the
407 // only thing to guard is these tests overwriting each other's setup.
408
409 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
410
411 /// Run `body` with `vars` applied to the real environment and everything
412 /// else this module reads removed, then put the environment back.
413 fn with_env(vars: &[(&str, &str)], body: impl FnOnce()) {
414 let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
415 const KEYS: [&str; 4] = [OVERRIDE_VAR, "HOME", "APPDATA", "USERPROFILE"];
416 let saved: Vec<(&str, Option<String>)> =
417 KEYS.iter().map(|k| (*k, std::env::var(k).ok())).collect();
418
419 for key in KEYS {
420 std::env::remove_var(key);
421 }
422 for (key, value) in vars {
423 std::env::set_var(key, value);
424 }
425
426 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(body));
427
428 for (key, value) in saved {
429 match value {
430 Some(v) => std::env::set_var(key, v),
431 None => std::env::remove_var(key),
432 }
433 }
434 drop(guard);
435 if let Err(payload) = result {
436 std::panic::resume_unwind(payload);
437 }
438 }
439
440 /// The real `app_dir` reads the real variables, and the derived
441 /// directories hang off it where they always have.
442 #[test]
443 #[cfg(unix)]
444 fn the_process_environment_reaches_app_dir() {
445 with_env(&[("HOME", "/home/pinned")], || {
446 assert_eq!(app_dir(), Some(PathBuf::from("/home/pinned/.phosphor")));
447 assert_eq!(
448 preset_dir(),
449 Some(PathBuf::from("/home/pinned/.phosphor/presets"))
450 );
451 assert_eq!(
452 session_dir(),
453 Some(PathBuf::from("/home/pinned/.phosphor/sessions"))
454 );
455 assert_eq!(
456 crate::preset::default_dir(),
457 Some(PathBuf::from("/home/pinned/.phosphor/presets")),
458 "the preset bank moved out of ~/.phosphor/presets"
459 );
460 });
461 }
462
463 /// Unset means unset, however this build was compiled: with nothing in the
464 /// environment there is nowhere to write, and callers are told so.
465 #[test]
466 fn an_unset_process_environment_gives_no_directory() {
467 with_env(&[], || {
468 assert_eq!(app_dir(), None);
469 assert_eq!(preset_dir(), None);
470 assert_eq!(crate::preset::default_dir(), None);
471 });
472 }
473
474 /// The override reaches the real lookup too, which is what lets a test or
475 /// a portable install point the whole application somewhere else.
476 #[test]
477 fn the_process_environment_honours_the_override() {
478 with_env(&[(OVERRIDE_VAR, "/tmp/pinned-phosphor"), ("HOME", "/home/pinned")], || {
479 assert_eq!(app_dir(), Some(PathBuf::from("/tmp/pinned-phosphor")));
480 });
481 }
482
483 /// This build follows the host's convention.
484 #[test]
485 fn the_native_convention_matches_the_host() {
486 #[cfg(windows)]
487 assert_eq!(NATIVE, Convention::Windows);
488 #[cfg(not(windows))]
489 assert_eq!(NATIVE, Convention::Unix);
490 }
491}