Skip to main content

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}