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}