Skip to main content

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}