Skip to main content

gravityfile_core/
paths.rs

1//! Where gravityfile keeps its own files.
2//!
3//! XDG layout on every Unix, macOS included (`~/.config/gravityfile`,
4//! `~/.cache/gravityfile`, ...), honouring the `XDG_*_HOME` variables; the
5//! usual application directories on Windows. `GRAVITYFILE_HOME` puts
6//! everything under one directory, for portable installs and tests.
7
8use std::path::{Path, PathBuf};
9
10use etcetera::BaseStrategy;
11
12const APP: &str = "gravityfile";
13
14/// gravityfile's directories.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Dirs {
17    /// Settings and plugins.
18    pub config: PathBuf,
19    /// Data that can be rebuilt.
20    pub cache: PathBuf,
21    /// Data worth keeping, such as saved scans.
22    pub data: PathBuf,
23    /// Logs and other state.
24    pub state: PathBuf,
25}
26
27impl Dirs {
28    /// gravityfile's directories for this user, or `None` without a home
29    /// directory. Reads only; see [`migrate_legacy_config`].
30    pub fn discover() -> Option<Self> {
31        if let Some(home) = home_override() {
32            return Some(Self::under(&home));
33        }
34        let base = etcetera::choose_base_strategy().ok()?;
35        let config = base.config_dir().join(APP);
36        Some(Self {
37            config: match legacy_config() {
38                Some(legacy) if !config.exists() && legacy.is_dir() => legacy,
39                _ => config,
40            },
41            cache: base.cache_dir().join(APP),
42            data: base.data_dir().join(APP),
43            state: base
44                .state_dir()
45                .unwrap_or_else(|| base.data_dir())
46                .join(APP),
47        })
48    }
49
50    /// Everything under one directory.
51    pub fn under(home: &Path) -> Self {
52        Self {
53            config: home.join("config"),
54            cache: home.join("cache"),
55            data: home.join("data"),
56            state: home.join("state"),
57        }
58    }
59
60    /// The explorer's saved settings.
61    pub fn settings_file(&self) -> PathBuf {
62        self.config.join("settings.toml")
63    }
64
65    /// Key binding overrides for the explorer.
66    pub fn keymap_file(&self) -> PathBuf {
67        self.config.join("keymap.toml")
68    }
69
70    /// The history of scan totals.
71    pub fn history_file(&self) -> PathBuf {
72        self.data.join("history.jsonl")
73    }
74
75    /// Installed plugins.
76    pub fn plugin_dir(&self) -> PathBuf {
77        self.config.join("plugins")
78    }
79}
80
81fn home_override() -> Option<PathBuf> {
82    std::env::var_os("GRAVITYFILE_HOME")
83        .filter(|home| !home.is_empty())
84        .map(PathBuf::from)
85}
86
87/// Where versions before 0.4 kept settings and plugins on macOS.
88fn legacy_config() -> Option<PathBuf> {
89    if !cfg!(target_os = "macos") || home_override().is_some() {
90        return None;
91    }
92    Some(home()?.join("Library/Application Support").join(APP))
93}
94
95/// Move the configuration directory of versions before 0.4 (macOS's
96/// `~/Library/Application Support/gravityfile`) to the current location, if
97/// only the old one exists. Returns the move made, if any. Until this runs,
98/// [`Dirs::discover`] keeps using the old directory.
99pub fn migrate_legacy_config() -> std::io::Result<Option<(PathBuf, PathBuf)>> {
100    let (Some(legacy), Some(base)) = (legacy_config(), etcetera::choose_base_strategy().ok())
101    else {
102        return Ok(None);
103    };
104    let current = base.config_dir().join(APP);
105    Ok(move_legacy(&legacy, &current)?.then_some((legacy, current)))
106}
107
108/// Rename `legacy` to `current` if only `legacy` exists.
109fn move_legacy(legacy: &Path, current: &Path) -> std::io::Result<bool> {
110    if current.exists() || !legacy.is_dir() {
111        return Ok(false);
112    }
113    if let Some(parent) = current.parent() {
114        std::fs::create_dir_all(parent)?;
115    }
116    match std::fs::rename(legacy, current) {
117        Ok(()) => Ok(true),
118        // Another process moved it first.
119        Err(_) if current.exists() => Ok(false),
120        Err(error) => Err(error),
121    }
122}
123
124/// The user's home directory.
125pub fn home() -> Option<PathBuf> {
126    etcetera::home_dir().ok()
127}
128
129/// The user's configuration root (`~/.config`, `%APPDATA%`), where many tools
130/// keep their settings.
131pub fn config_root() -> Option<PathBuf> {
132    etcetera::choose_base_strategy()
133        .ok()
134        .map(|base| base.config_dir())
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn one_home_holds_every_directory() {
143        let dirs = Dirs::under(Path::new("/portable"));
144        assert_eq!(
145            dirs.settings_file(),
146            Path::new("/portable/config/settings.toml")
147        );
148        assert_eq!(dirs.plugin_dir(), Path::new("/portable/config/plugins"));
149        assert_eq!(dirs.cache, Path::new("/portable/cache"));
150    }
151
152    #[test]
153    fn a_legacy_directory_is_moved_once_and_never_over_a_current_one() {
154        let temp = tempfile::tempdir().unwrap();
155        let legacy = temp.path().join("Library/Application Support/gravityfile");
156        let current = temp.path().join(".config/gravityfile");
157        std::fs::create_dir_all(legacy.join("plugins")).unwrap();
158        std::fs::write(legacy.join("settings.toml"), "theme = 'light'").unwrap();
159
160        assert!(move_legacy(&legacy, &current).unwrap());
161        assert!(!legacy.exists());
162        assert_eq!(
163            std::fs::read_to_string(current.join("settings.toml")).unwrap(),
164            "theme = 'light'"
165        );
166        assert!(current.join("plugins").is_dir());
167
168        // With both present, the current directory wins and nothing moves.
169        std::fs::create_dir_all(&legacy).unwrap();
170        assert!(!move_legacy(&legacy, &current).unwrap());
171        assert!(legacy.exists());
172
173        // Without a legacy directory, nothing is created.
174        let fresh = temp.path().join("fresh/gravityfile");
175        assert!(!move_legacy(&temp.path().join("missing"), &fresh).unwrap());
176        assert!(!fresh.exists());
177    }
178}