Skip to main content

teksilo_settings/
path.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! OS-correct path resolution for application config and data directories.
5//!
6//! [`AppPaths`] wraps `etcetera`'s native [`AppStrategy`] so the rest of the
7//! crate has a single point of truth for where settings files live. In
8//! production, [`AppPaths::new`] queries the OS (XDG on Linux,
9//! `%APPDATA%` on Windows, `~/Library/Application Support` on macOS); in
10//! tests, [`AppPaths::for_testing`] roots everything inside a `tempdir` so no
11//! test ever touches the user's real config tree.
12//!
13//! ## Usage
14//!
15//! Pass an `AppPaths` instance to [`SettingsBundle`](crate::SettingsBundle),
16//! [`SettingsStore`](crate::SettingsStore), or [`MruList`](crate::MruList); the
17//! individual files within it are addressed by name via
18//! [`config_file`](AppPaths::config_file) and
19//! [`data_file`](AppPaths::data_file).
20//!
21//! ```ignore
22//! use teksilo_settings::AppPaths;
23//!
24//! // Production: returns None when no home directory is detectable.
25//! if let Some(paths) = AppPaths::new("eu", "FernTech", "MyApp") {
26//!     let general_toml = paths.config_file("general");
27//!     let cache_toml   = paths.data_file("cache");
28//! }
29//!
30//! // Tests: deterministic, tempdir-rooted, never touches user files.
31//! let tmp = tempfile::tempdir().unwrap();
32//! let paths = AppPaths::for_testing(tmp.path());
33//! assert_eq!(paths.config_file("settings"), tmp.path().join("settings.toml"));
34//! ```
35
36use std::path::{Path, PathBuf};
37
38use etcetera::{AppStrategy, AppStrategyArgs, choose_app_strategy};
39
40/// Resolved OS-correct application directories (config and data).
41///
42/// Construct with [`AppPaths::new`] for production code, or
43/// [`AppPaths::for_testing`] in tests and headless CI environments.
44/// Use [`AppPaths::from_dirs`] when the application manages its own
45/// directory layout (e.g. portable mode).
46#[derive(Debug, Clone)]
47pub struct AppPaths {
48    config_dir: PathBuf,
49    data_dir: PathBuf,
50}
51
52impl AppPaths {
53    /// Resolve directories from the OS. The `(qualifier, organization,
54    /// application)` triple feeds [`etcetera::AppStrategyArgs`] as
55    /// `(top_level_domain, author, app_name)` — same fields, different
56    /// names — and selects the platform-native strategy (XDG on Linux,
57    /// `%APPDATA%`-based on Windows, `~/Library/Application Support`
58    /// on macOS).
59    ///
60    /// Returns `None` when no usable home directory could be detected
61    /// (a sandboxed or unconfigured environment). Callers who want to
62    /// degrade gracefully should fall back to [`AppPaths::for_testing`]
63    /// with an in-process directory.
64    pub fn new(qualifier: &str, organization: &str, application: &str) -> Option<Self> {
65        let strategy = choose_app_strategy(AppStrategyArgs {
66            top_level_domain: qualifier.to_string(),
67            author: organization.to_string(),
68            app_name: application.to_string(),
69        })
70        .ok()?;
71        Some(Self {
72            config_dir: strategy.config_dir(),
73            data_dir: strategy.data_dir(),
74        })
75    }
76
77    /// Construct an `AppPaths` rooted at an arbitrary directory. Used by
78    /// tests so that no test ever touches the user's real config tree.
79    /// Both `config_dir` and `data_dir` resolve to `root`.
80    pub fn for_testing(root: &Path) -> Self {
81        Self {
82            config_dir: root.to_path_buf(),
83            data_dir: root.to_path_buf(),
84        }
85    }
86
87    /// Construct from explicit config and data directories. Useful when
88    /// an application wants to override one or both (e.g. portable mode).
89    pub fn from_dirs(config_dir: PathBuf, data_dir: PathBuf) -> Self {
90        Self {
91            config_dir,
92            data_dir,
93        }
94    }
95
96    /// The platform-correct config directory (XDG_CONFIG_HOME, %APPDATA%,
97    /// `~/Library/Preferences`, etc.).
98    pub fn config_dir(&self) -> &Path {
99        &self.config_dir
100    }
101
102    /// The platform-correct data directory. Used for caches and
103    /// per-window state — anything larger than a configuration file.
104    pub fn data_dir(&self) -> &Path {
105        &self.data_dir
106    }
107
108    /// Resolve a per-concern config file by name (without extension).
109    /// `name = "general"` yields `<config_dir>/general.toml`.
110    pub fn config_file(&self, name: &str) -> PathBuf {
111        self.config_dir.join(format!("{name}.toml"))
112    }
113
114    /// Resolve a per-concern data file by name (without extension).
115    pub fn data_file(&self, name: &str) -> PathBuf {
116        self.data_dir.join(format!("{name}.toml"))
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use tempfile::tempdir;
124
125    #[test]
126    fn for_testing_routes_everything_to_root() {
127        let dir = tempdir().unwrap();
128        let paths = AppPaths::for_testing(dir.path());
129
130        assert_eq!(paths.config_dir(), dir.path());
131        assert_eq!(paths.data_dir(), dir.path());
132        assert_eq!(
133            paths.config_file("recents"),
134            dir.path().join("recents.toml")
135        );
136        assert_eq!(paths.data_file("cache"), dir.path().join("cache.toml"));
137    }
138
139    #[test]
140    fn from_dirs_keeps_separate_paths() {
141        let cfg = tempdir().unwrap();
142        let data = tempdir().unwrap();
143        let paths = AppPaths::from_dirs(cfg.path().to_path_buf(), data.path().to_path_buf());
144
145        assert_eq!(paths.config_dir(), cfg.path());
146        assert_eq!(paths.data_dir(), data.path());
147    }
148}