Skip to main content

scope/
paths.rs

1//! Where scope keeps the user's files: one folder, `$SCOPE_BCA_HOME`, else
2//! `~/.scope-bca`.
3//!
4//! ```text
5//! <home>/config.yaml   venues/   data/ (address book, tokens)
6//!        state/ (session, history, tui-events, migration log, daemon files)
7//! ```
8//!
9//! This module is the only place that knows the layout. Code calls these
10//! functions instead of `dirs::*` (clippy's `disallowed-methods` enforces
11//! this).
12//!
13//! In test builds (`cfg(test)` or the `test-support` feature, which every
14//! test crate turns on) every path is under a sandbox for each process,
15//! never the real home, and `SCOPE_BCA_HOME` is ignored. Tests that wrote
16//! the real folders replaced a user's API keys, address book and token
17//! aliases with test data (#98).
18
19pub mod migration;
20
21use std::ffi::OsString;
22use std::path::PathBuf;
23
24/// In test builds, the environment variable that moves the sandbox, for a
25/// test that needs a home it controls (for example one that cannot be
26/// written).
27pub const TEST_HOME_VAR: &str = "SCOPE_TEST_HOME";
28
29/// The environment variable that moves the whole scope folder.
30pub const HOME_VAR: &str = "SCOPE_BCA_HOME";
31
32/// The scope folder's name in the user's home.
33pub const HOME_DIR_NAME: &str = ".scope-bca";
34
35/// The scope folder: `$SCOPE_BCA_HOME`, else `~/.scope-bca`.
36pub fn home() -> PathBuf {
37    #[cfg(any(test, feature = "test-support"))]
38    return sandbox().join(HOME_DIR_NAME);
39    #[cfg(not(any(test, feature = "test-support")))]
40    home_from(std::env::var_os(HOME_VAR), user_home())
41}
42
43/// The root for an override and a user home. An empty override is no
44/// override; with no user home the folder is relative.
45pub fn home_from(override_root: Option<OsString>, user_home: Option<PathBuf>) -> PathBuf {
46    match override_root.filter(|r| !r.is_empty()) {
47        Some(root) => PathBuf::from(root),
48        None => user_home
49            .map(|h| h.join(HOME_DIR_NAME))
50            .unwrap_or_else(|| PathBuf::from(HOME_DIR_NAME)),
51    }
52}
53
54/// `<home>/config.yaml`.
55pub fn config_file() -> PathBuf {
56    home().join("config.yaml")
57}
58
59/// `<home>/venues`: the user's venue descriptors.
60pub fn venues_dir() -> PathBuf {
61    home().join("venues")
62}
63
64/// `<home>/watchlists`: the user's saved watchlists (#47).
65pub fn watchlists_dir() -> PathBuf {
66    home().join("watchlists")
67}
68
69/// `<home>/data`: what scope keeps for the user.
70pub fn data_dir() -> PathBuf {
71    home().join("data")
72}
73
74/// `<home>/state`: session, history and logs; safe to delete.
75pub fn state_dir() -> PathBuf {
76    home().join("state")
77}
78
79/// The user's home folder (for `~` in paths the user types).
80#[allow(clippy::disallowed_methods)]
81pub fn user_home() -> Option<PathBuf> {
82    #[cfg(any(test, feature = "test-support"))]
83    return Some(sandbox());
84    #[cfg(not(any(test, feature = "test-support")))]
85    dirs::home_dir()
86}
87
88/// The platform's config folder, where old versions could keep the config.
89/// Only the migration uses it.
90#[allow(clippy::disallowed_methods)]
91pub(crate) fn legacy_config_dir() -> Option<PathBuf> {
92    #[cfg(any(test, feature = "test-support"))]
93    return Some(sandbox().join("legacy-config"));
94    #[cfg(not(any(test, feature = "test-support")))]
95    dirs::config_dir()
96}
97
98/// The platform's data folder, where old versions kept tokens and TUI
99/// state. Only the migration uses it.
100#[allow(clippy::disallowed_methods)]
101pub(crate) fn legacy_data_dir() -> Option<PathBuf> {
102    #[cfg(any(test, feature = "test-support"))]
103    return Some(sandbox().join("legacy-data"));
104    #[cfg(not(any(test, feature = "test-support")))]
105    dirs::data_dir()
106}
107
108/// The test sandbox: `SCOPE_TEST_HOME` when set, else a directory for this
109/// process under the system temp directory, emptied on first use.
110#[cfg(any(test, feature = "test-support"))]
111fn sandbox() -> PathBuf {
112    static FRESH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
113    if let Some(dir) = std::env::var_os(TEST_HOME_VAR) {
114        return PathBuf::from(dir);
115    }
116    FRESH
117        .get_or_init(|| {
118            let dir = std::env::temp_dir().join(format!("scope-test-home-{}", std::process::id()));
119            // Process ids are reused: a finished test process with this id
120            // may have left its files here, such as a saved token choice
121            // that made a later run's ambiguous token resolve.
122            let _ = std::fs::remove_dir_all(&dir);
123            dir
124        })
125        .clone()
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::ffi::OsString;
132
133    #[test]
134    fn test_test_builds_never_resolve_the_real_home() {
135        // The guard against tests writing the user's files: every
136        // directory is in the sandbox under the temp directory.
137        let temp = std::env::temp_dir();
138        let mut all = vec![home(), config_file(), venues_dir(), data_dir(), state_dir()];
139        all.extend(user_home());
140        all.extend(legacy_config_dir());
141        all.extend(legacy_data_dir());
142        for dir in all {
143            assert!(
144                dir.starts_with(&temp),
145                "{} is outside the sandbox",
146                dir.display()
147            );
148        }
149    }
150
151    #[test]
152    fn test_the_layout_is_under_one_home() {
153        let root = home();
154        assert_eq!(config_file(), root.join("config.yaml"));
155        assert_eq!(venues_dir(), root.join("venues"));
156        assert_eq!(data_dir(), root.join("data"));
157        assert_eq!(state_dir(), root.join("state"));
158    }
159
160    #[test]
161    fn test_home_from_uses_the_override_else_the_user_home() {
162        let user = Some(PathBuf::from("/u/jon"));
163        assert_eq!(
164            home_from(None, user.clone()),
165            PathBuf::from("/u/jon/.scope-bca")
166        );
167        assert_eq!(
168            home_from(Some(OsString::from("/profiles/two")), user),
169            PathBuf::from("/profiles/two")
170        );
171    }
172
173    #[test]
174    fn test_home_from_ignores_an_empty_override() {
175        // Review focus 4: `SCOPE_BCA_HOME=` must not make the root ".".
176        let user = Some(PathBuf::from("/u/jon"));
177        assert_eq!(
178            home_from(Some(OsString::new()), user),
179            PathBuf::from("/u/jon/.scope-bca")
180        );
181    }
182
183    #[test]
184    fn test_home_from_without_a_user_home_is_relative() {
185        assert_eq!(home_from(None, None), PathBuf::from(HOME_DIR_NAME));
186    }
187}