hjkl_config/loader.rs
1use std::path::{Path, PathBuf};
2
3use serde::de::DeserializeOwned;
4
5use crate::error::{ConfigError, locate};
6
7/// Application config descriptor.
8///
9/// Implementing types are loaded from a TOML file under the application's
10/// XDG config directory. The trait carries identity ([`APPLICATION`]) and
11/// the per-impl filename ([`FILE`]) used by the load functions in this
12/// crate.
13///
14/// Multiple structs can impl `AppConfig` with the same `APPLICATION` and
15/// different `FILE` constants — that's how an app splits its user config
16/// across e.g. `config.toml`, `keymap.toml`, `theme.toml`. For raw
17/// directory lookups not tied to a specific struct, use the free
18/// functions [`config_dir`], [`data_dir`], [`cache_dir`].
19///
20/// [`APPLICATION`]: AppConfig::APPLICATION
21/// [`FILE`]: AppConfig::FILE
22pub trait AppConfig: DeserializeOwned + Default {
23 /// Application name. Becomes the leaf component of the XDG dirs:
24 /// `$XDG_CONFIG_HOME/<APPLICATION>/`, `$XDG_DATA_HOME/<APPLICATION>/`, etc.
25 const APPLICATION: &'static str;
26 /// File basename for this struct's TOML, joined onto the config dir.
27 /// Defaults to `config.toml`. Override per-impl when an app splits its
28 /// user config across multiple files (e.g. `keymap.toml`).
29 const FILE: &'static str = "config.toml";
30}
31
32/// Where the loaded config came from.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum ConfigSource {
35 /// Parsed from this user file.
36 File(PathBuf),
37 /// No user file present; built-in `Default` was used.
38 Defaults,
39}
40
41// ── XDG-everywhere resolution (delegated to `hjkl-xdg`) ────────────────────
42//
43// Per the XDG Base Directory spec, each base var (XDG_CONFIG_HOME,
44// XDG_DATA_HOME, XDG_CACHE_HOME) is honored if set to a non-empty
45// absolute path; otherwise the documented default applies. This is
46// applied uniformly on every platform — Linux, macOS, Windows — so a
47// user's config / data / cache layout is identical everywhere.
48//
49// macOS users get `~/.config/<app>` instead of
50// `~/Library/Application Support/<app>` because we ship CLI tools, not
51// signed `.app` bundles. Windows users get `~/.config/<app>` instead of
52// `%APPDATA%\<app>` for the same reason.
53//
54// That policy is implemented exactly once, in `hjkl-xdg`. This crate used to
55// carry a byte-for-byte copy of it; two copies of a path resolver is how a
56// fix lands in one of them and silently misses the other.
57
58/// Map `hjkl-xdg`'s only failure mode onto this crate's existing variant, so
59/// callers and their tests keep seeing [`ConfigError::NoHomeDir`].
60fn map_xdg(e: hjkl_xdg::Error) -> ConfigError {
61 match e {
62 hjkl_xdg::Error::NoHomeDir => ConfigError::NoHomeDir,
63 }
64}
65
66/// Resolve `<XDG_CONFIG_HOME>/<app>`, defaulting to `~/.config/<app>`.
67pub fn config_dir(app: &str) -> Result<PathBuf, ConfigError> {
68 hjkl_xdg::config_dir(app).map_err(map_xdg)
69}
70
71/// Resolve `<XDG_DATA_HOME>/<app>`, defaulting to `~/.local/share/<app>`.
72pub fn data_dir(app: &str) -> Result<PathBuf, ConfigError> {
73 hjkl_xdg::data_dir(app).map_err(map_xdg)
74}
75
76/// Resolve `<XDG_CACHE_HOME>/<app>`, defaulting to `~/.cache/<app>`.
77pub fn cache_dir(app: &str) -> Result<PathBuf, ConfigError> {
78 hjkl_xdg::cache_dir(app).map_err(map_xdg)
79}
80
81/// Resolve the full config file path for `C` — `<config_dir>/<FILE>`.
82pub fn config_path<C: AppConfig>() -> Result<PathBuf, ConfigError> {
83 Ok(config_dir(C::APPLICATION)?.join(C::FILE))
84}
85
86/// Load `C` from its default XDG path.
87///
88/// Returns `(C::default(), ConfigSource::Defaults)` if the file is absent
89/// or the platform has no home dir. Never writes to disk.
90pub fn load<C: AppConfig>() -> Result<(C, ConfigSource), ConfigError> {
91 let path = match config_path::<C>() {
92 Ok(p) => p,
93 Err(ConfigError::NoHomeDir) => {
94 return Ok((C::default(), ConfigSource::Defaults));
95 }
96 Err(e) => return Err(e),
97 };
98 if !path.exists() {
99 return Ok((C::default(), ConfigSource::Defaults));
100 }
101 let cfg = load_from::<C>(&path)?;
102 Ok((cfg, ConfigSource::File(path)))
103}
104
105/// Load `C` from an explicit path. Used by `--config <PATH>` flags and tests.
106///
107/// Errors with [`ConfigError::Io`] on read failure or [`ConfigError::Parse`]
108/// (with line/col/snippet) on malformed TOML.
109pub fn load_from<C: AppConfig>(path: &Path) -> Result<C, ConfigError> {
110 let src = std::fs::read_to_string(path).map_err(|e| ConfigError::Io {
111 path: path.to_path_buf(),
112 source: e,
113 })?;
114 toml::from_str::<C>(&src).map_err(|e| {
115 let span = e.span().unwrap_or(0..0);
116 let (line, col, snippet) = locate(&src, span.start);
117 ConfigError::Parse {
118 path: path.to_path_buf(),
119 line,
120 col,
121 message: e.message().to_string(),
122 snippet,
123 }
124 })
125}
126
127/// Load `C` by layering a user file over a bundled defaults TOML.
128///
129/// `defaults_toml` is parsed as the seed value (typically embedded via
130/// `include_str!()` so default *values* live in a TOML file in the source
131/// tree, not in Rust code). If a user file exists at `C`'s default XDG
132/// path, it is parsed and **deep-merged** on top of the seed: nested
133/// tables are merged recursively; scalars and arrays in the user file
134/// overwrite their seed counterparts.
135///
136/// When no user file exists, returns the seed-only value paired with
137/// [`ConfigSource::Defaults`]. Never writes to disk.
138///
139/// Errors:
140/// - [`ConfigError::Invalid`] if `defaults_toml` itself is malformed or
141/// doesn't deserialize into `C` — this is a build-time bug in the
142/// consumer, not a user error.
143/// - [`ConfigError::Parse`] with line/col/snippet if the user file is
144/// malformed TOML.
145/// - [`ConfigError::Invalid`] if the merged result fails to deserialize
146/// into `C` (unknown user key, wrong type, etc).
147pub fn load_layered<C: AppConfig>(defaults_toml: &str) -> Result<(C, ConfigSource), ConfigError> {
148 let user_path = match config_path::<C>() {
149 Ok(p) if p.exists() => Some(p),
150 Ok(_) => None,
151 Err(ConfigError::NoHomeDir) => None,
152 Err(e) => return Err(e),
153 };
154 match user_path {
155 Some(p) => {
156 let cfg = load_layered_from::<C>(defaults_toml, &p)?;
157 Ok((cfg, ConfigSource::File(p)))
158 }
159 None => {
160 let cfg = parse_defaults_only::<C>(defaults_toml)?;
161 Ok((cfg, ConfigSource::Defaults))
162 }
163 }
164}
165
166/// Same as [`load_layered`] but reads the user file from an explicit path
167/// (for `--config <PATH>` flags and tests). Always reads `path` — does not
168/// fall back to defaults if missing.
169pub fn load_layered_from<C: AppConfig>(defaults_toml: &str, path: &Path) -> Result<C, ConfigError> {
170 let user_src = std::fs::read_to_string(path).map_err(|e| ConfigError::Io {
171 path: path.to_path_buf(),
172 source: e,
173 })?;
174 let user_table: toml::Table = toml::from_str(&user_src).map_err(|e| {
175 let span = e.span().unwrap_or(0..0);
176 let (line, col, snippet) = locate(&user_src, span.start);
177 ConfigError::Parse {
178 path: path.to_path_buf(),
179 line,
180 col,
181 message: e.message().to_string(),
182 snippet,
183 }
184 })?;
185
186 let mut merged: toml::Table =
187 toml::from_str(defaults_toml).map_err(|e| ConfigError::Invalid {
188 path: PathBuf::from("<bundled defaults>"),
189 message: format!("bundled defaults TOML is invalid: {e}"),
190 })?;
191 deep_merge(&mut merged, user_table);
192
193 toml::Value::Table(merged)
194 .try_into()
195 .map_err(|e: toml::de::Error| ConfigError::Invalid {
196 path: path.to_path_buf(),
197 message: e.to_string(),
198 })
199}
200
201fn parse_defaults_only<C: AppConfig>(defaults_toml: &str) -> Result<C, ConfigError> {
202 toml::from_str::<C>(defaults_toml).map_err(|e| ConfigError::Invalid {
203 path: PathBuf::from("<bundled defaults>"),
204 message: format!("bundled defaults TOML is invalid: {e}"),
205 })
206}
207
208/// Recursively merge `from` into `into`. Nested tables merge field-by-field;
209/// scalars and arrays in `from` overwrite their counterparts in `into`.
210pub fn deep_merge(into: &mut toml::Table, from: toml::Table) {
211 for (k, v) in from {
212 match (into.get_mut(&k), v) {
213 (Some(toml::Value::Table(into_t)), toml::Value::Table(from_t)) => {
214 deep_merge(into_t, from_t);
215 }
216 (_, v) => {
217 into.insert(k, v);
218 }
219 }
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 // The per-branch resolver tests (env absolute wins / empty / unset /
228 // relative-ignored / no-home-errs) used to live here against a private
229 // `resolve_xdg` copy. That copy is gone; the branches are covered by
230 // `hjkl-xdg`'s own tests of the same names. What this crate still owns —
231 // and therefore still tests — is the delegation and the error mapping.
232
233 /// Every public dir here must be exactly `hjkl-xdg`'s answer plus the app
234 /// leaf. If the delegation ever drifted back into a private reimplementation
235 /// these would diverge under any non-default `XDG_*` env.
236 #[test]
237 fn dirs_delegate_to_hjkl_xdg() {
238 assert_eq!(
239 config_dir("myapp").unwrap(),
240 hjkl_xdg::config_home().unwrap().join("myapp")
241 );
242 assert_eq!(
243 data_dir("myapp").unwrap(),
244 hjkl_xdg::data_home().unwrap().join("myapp")
245 );
246 assert_eq!(
247 cache_dir("myapp").unwrap(),
248 hjkl_xdg::cache_home().unwrap().join("myapp")
249 );
250 }
251
252 /// `hjkl-xdg`'s only failure mode keeps surfacing as this crate's
253 /// `NoHomeDir`, which `load`/`load_layered` match on by name.
254 #[test]
255 fn xdg_no_home_maps_to_no_home_dir() {
256 assert!(matches!(
257 map_xdg(hjkl_xdg::Error::NoHomeDir),
258 ConfigError::NoHomeDir
259 ));
260 }
261
262 /// Smoke test the public `config_dir(app)` returns *something*
263 /// ending in `<app>` — exact path depends on the test runner's
264 /// $HOME, so we just check the leaf component.
265 #[test]
266 fn config_dir_smoke() {
267 let p = config_dir("myapp").unwrap();
268 assert!(
269 p.ends_with("myapp"),
270 "expected path ending in `myapp`, got {p:?}"
271 );
272 }
273
274 #[test]
275 fn data_dir_smoke() {
276 let p = data_dir("myapp").unwrap();
277 assert!(p.ends_with("myapp"));
278 }
279
280 #[test]
281 fn cache_dir_smoke() {
282 let p = cache_dir("myapp").unwrap();
283 assert!(p.ends_with("myapp"));
284 }
285}