concinnity_host/store/paths/root.rs
1// Anchoring: which directory the state tree hangs off.
2//
3// There is no default. A host installs a state directory before anything reads
4// the tree, and an uninstalled root resolves to `None` rather than a guess
5// against the working directory: a library that guessed would scatter a
6// project's settings and saves beside whatever directory its caller happened
7// to launch from.
8
9use std::path::{Path, PathBuf};
10use std::sync::{Mutex, OnceLock};
11
12fn installed_state_dir() -> &'static Mutex<Option<PathBuf>> {
13 static STATE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
14 STATE.get_or_init(|| Mutex::new(None))
15}
16
17fn writable_state_override() -> &'static Mutex<Option<PathBuf>> {
18 static WRITABLE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
19 WRITABLE.get_or_init(|| Mutex::new(None))
20}
21
22/// Anchor the state tree at `dir` for the rest of the process, so `data/`,
23/// `saves/`, and `settings` resolve under it. Every host installs one before
24/// reading project state: the dev CLI its project directory, a shipped
25/// application the directory beside its executable (or inside its app bundle),
26/// an embedder whatever its own layout implies.
27pub fn set_state_dir<P: Into<PathBuf>>(dir: P) {
28 *installed_state_dir().lock().unwrap() = Some(dir.into());
29}
30
31/// Remove an installed state dir, leaving the process with no state tree.
32pub fn clear_state_dir() {
33 *installed_state_dir().lock().unwrap() = None;
34}
35
36/// Anchor the runtime-writable state (`saves/` + `settings`) at `dir`, leaving
37/// the read-only content (`data/`) at the installed state dir. A shipped
38/// application installs this when its content dir is not writable (a read-only
39/// install such as Program Files), redirecting only what it writes at runtime
40/// to a per-user directory. When unset, writable state stays beside `data/`.
41pub fn set_writable_state_dir<P: Into<PathBuf>>(dir: P) {
42 *writable_state_override().lock().unwrap() = Some(dir.into());
43}
44
45/// Remove an installed writable-state dir, restoring writable state to the
46/// content root beside `data/`.
47pub fn clear_writable_state_dir() {
48 *writable_state_override().lock().unwrap() = None;
49}
50
51/// The state directory, or `None` when no host installed one.
52pub fn state_dir() -> Option<PathBuf> {
53 installed_state_dir().lock().unwrap().clone()
54}
55
56/// The directory holding runtime-writable state (`saves/` + `settings`): the
57/// writable override when one is installed, otherwise the state dir (writable
58/// state sits beside `data/`).
59pub fn writable_state_dir() -> Option<PathBuf> {
60 let over = writable_state_override().lock().unwrap().clone();
61 resolve_writable_dir(over.as_deref(), state_dir().as_deref())
62}
63
64// Pure resolution split out so the fallback rule is unit-testable without the
65// process-global override: the override verbatim, else the content state dir.
66fn resolve_writable_dir(over: Option<&Path>, state: Option<&Path>) -> Option<PathBuf> {
67 over.or(state).map(Path::to_path_buf)
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73
74 #[test]
75 fn resolve_writable_dir_prefers_override_then_falls_back() {
76 // An installed writable override wins verbatim, relocating only the
77 // writable state; without one, writable state stays with the content.
78 let state = Path::new("/game/MyGame");
79 let over = Path::new("/users/me/AppData/Local/MyGame");
80 assert_eq!(
81 resolve_writable_dir(Some(over), Some(state)).as_deref(),
82 Some(over)
83 );
84 assert_eq!(
85 resolve_writable_dir(None, Some(state)).as_deref(),
86 Some(state)
87 );
88 }
89
90 // An override with no content root behind it still resolves: a host may
91 // redirect its writable state without ever installing a state dir.
92 #[test]
93 fn resolve_writable_dir_without_a_state_dir() {
94 let over = Path::new("/users/me/MyGame");
95 assert_eq!(
96 resolve_writable_dir(Some(over), None).as_deref(),
97 Some(over)
98 );
99 assert_eq!(resolve_writable_dir(None, None), None);
100 }
101}