hjkl-config 0.40.0

Shared TOML config loader for hjkl-based apps — XDG path resolution, span-aware parse errors, optional validation hook.
Documentation
use std::path::{Path, PathBuf};

use serde::de::DeserializeOwned;

use crate::error::{ConfigError, locate};

/// Application config descriptor.
///
/// Implementing types are loaded from a TOML file under the application's
/// XDG config directory. The trait carries identity ([`APPLICATION`]) and
/// the per-impl filename ([`FILE`]) used by the load functions in this
/// crate.
///
/// Multiple structs can impl `AppConfig` with the same `APPLICATION` and
/// different `FILE` constants — that's how an app splits its user config
/// across e.g. `config.toml`, `keymap.toml`, `theme.toml`. For raw
/// directory lookups not tied to a specific struct, use the free
/// functions [`config_dir`], [`data_dir`], [`cache_dir`].
///
/// [`APPLICATION`]: AppConfig::APPLICATION
/// [`FILE`]: AppConfig::FILE
pub trait AppConfig: DeserializeOwned + Default {
    /// Application name. Becomes the leaf component of the XDG dirs:
    /// `$XDG_CONFIG_HOME/<APPLICATION>/`, `$XDG_DATA_HOME/<APPLICATION>/`, etc.
    const APPLICATION: &'static str;
    /// File basename for this struct's TOML, joined onto the config dir.
    /// Defaults to `config.toml`. Override per-impl when an app splits its
    /// user config across multiple files (e.g. `keymap.toml`).
    const FILE: &'static str = "config.toml";
}

/// Where the loaded config came from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigSource {
    /// Parsed from this user file.
    File(PathBuf),
    /// No user file present; built-in `Default` was used.
    Defaults,
}

// ── XDG-everywhere resolution (delegated to `hjkl-xdg`) ────────────────────
//
// Per the XDG Base Directory spec, each base var (XDG_CONFIG_HOME,
// XDG_DATA_HOME, XDG_CACHE_HOME) is honored if set to a non-empty
// absolute path; otherwise the documented default applies. This is
// applied uniformly on every platform — Linux, macOS, Windows — so a
// user's config / data / cache layout is identical everywhere.
//
// macOS users get `~/.config/<app>` instead of
// `~/Library/Application Support/<app>` because we ship CLI tools, not
// signed `.app` bundles. Windows users get `~/.config/<app>` instead of
// `%APPDATA%\<app>` for the same reason.
//
// That policy is implemented exactly once, in `hjkl-xdg`. This crate used to
// carry a byte-for-byte copy of it; two copies of a path resolver is how a
// fix lands in one of them and silently misses the other.

/// Map `hjkl-xdg`'s only failure mode onto this crate's existing variant, so
/// callers and their tests keep seeing [`ConfigError::NoHomeDir`].
fn map_xdg(e: hjkl_xdg::Error) -> ConfigError {
    match e {
        hjkl_xdg::Error::NoHomeDir => ConfigError::NoHomeDir,
    }
}

/// Resolve `<XDG_CONFIG_HOME>/<app>`, defaulting to `~/.config/<app>`.
pub fn config_dir(app: &str) -> Result<PathBuf, ConfigError> {
    hjkl_xdg::config_dir(app).map_err(map_xdg)
}

/// Resolve `<XDG_DATA_HOME>/<app>`, defaulting to `~/.local/share/<app>`.
pub fn data_dir(app: &str) -> Result<PathBuf, ConfigError> {
    hjkl_xdg::data_dir(app).map_err(map_xdg)
}

/// Resolve `<XDG_CACHE_HOME>/<app>`, defaulting to `~/.cache/<app>`.
pub fn cache_dir(app: &str) -> Result<PathBuf, ConfigError> {
    hjkl_xdg::cache_dir(app).map_err(map_xdg)
}

/// Resolve the full config file path for `C` — `<config_dir>/<FILE>`.
pub fn config_path<C: AppConfig>() -> Result<PathBuf, ConfigError> {
    Ok(config_dir(C::APPLICATION)?.join(C::FILE))
}

/// Load `C` from its default XDG path.
///
/// Returns `(C::default(), ConfigSource::Defaults)` if the file is absent
/// or the platform has no home dir. Never writes to disk.
pub fn load<C: AppConfig>() -> Result<(C, ConfigSource), ConfigError> {
    let path = match config_path::<C>() {
        Ok(p) => p,
        Err(ConfigError::NoHomeDir) => {
            return Ok((C::default(), ConfigSource::Defaults));
        }
        Err(e) => return Err(e),
    };
    if !path.exists() {
        return Ok((C::default(), ConfigSource::Defaults));
    }
    let cfg = load_from::<C>(&path)?;
    Ok((cfg, ConfigSource::File(path)))
}

/// Load `C` from an explicit path. Used by `--config <PATH>` flags and tests.
///
/// Errors with [`ConfigError::Io`] on read failure or [`ConfigError::Parse`]
/// (with line/col/snippet) on malformed TOML.
pub fn load_from<C: AppConfig>(path: &Path) -> Result<C, ConfigError> {
    let src = std::fs::read_to_string(path).map_err(|e| ConfigError::Io {
        path: path.to_path_buf(),
        source: e,
    })?;
    toml::from_str::<C>(&src).map_err(|e| {
        let span = e.span().unwrap_or(0..0);
        let (line, col, snippet) = locate(&src, span.start);
        ConfigError::Parse {
            path: path.to_path_buf(),
            line,
            col,
            message: e.message().to_string(),
            snippet,
        }
    })
}

/// Load `C` by layering a user file over a bundled defaults TOML.
///
/// `defaults_toml` is parsed as the seed value (typically embedded via
/// `include_str!()` so default *values* live in a TOML file in the source
/// tree, not in Rust code). If a user file exists at `C`'s default XDG
/// path, it is parsed and **deep-merged** on top of the seed: nested
/// tables are merged recursively; scalars and arrays in the user file
/// overwrite their seed counterparts.
///
/// When no user file exists, returns the seed-only value paired with
/// [`ConfigSource::Defaults`]. Never writes to disk.
///
/// Errors:
/// - [`ConfigError::Invalid`] if `defaults_toml` itself is malformed or
///   doesn't deserialize into `C` — this is a build-time bug in the
///   consumer, not a user error.
/// - [`ConfigError::Parse`] with line/col/snippet if the user file is
///   malformed TOML.
/// - [`ConfigError::Invalid`] if the merged result fails to deserialize
///   into `C` (unknown user key, wrong type, etc).
pub fn load_layered<C: AppConfig>(defaults_toml: &str) -> Result<(C, ConfigSource), ConfigError> {
    let user_path = match config_path::<C>() {
        Ok(p) if p.exists() => Some(p),
        Ok(_) => None,
        Err(ConfigError::NoHomeDir) => None,
        Err(e) => return Err(e),
    };
    match user_path {
        Some(p) => {
            let cfg = load_layered_from::<C>(defaults_toml, &p)?;
            Ok((cfg, ConfigSource::File(p)))
        }
        None => {
            let cfg = parse_defaults_only::<C>(defaults_toml)?;
            Ok((cfg, ConfigSource::Defaults))
        }
    }
}

/// Same as [`load_layered`] but reads the user file from an explicit path
/// (for `--config <PATH>` flags and tests). Always reads `path` — does not
/// fall back to defaults if missing.
pub fn load_layered_from<C: AppConfig>(defaults_toml: &str, path: &Path) -> Result<C, ConfigError> {
    let user_src = std::fs::read_to_string(path).map_err(|e| ConfigError::Io {
        path: path.to_path_buf(),
        source: e,
    })?;
    let user_table: toml::Table = toml::from_str(&user_src).map_err(|e| {
        let span = e.span().unwrap_or(0..0);
        let (line, col, snippet) = locate(&user_src, span.start);
        ConfigError::Parse {
            path: path.to_path_buf(),
            line,
            col,
            message: e.message().to_string(),
            snippet,
        }
    })?;

    let mut merged: toml::Table =
        toml::from_str(defaults_toml).map_err(|e| ConfigError::Invalid {
            path: PathBuf::from("<bundled defaults>"),
            message: format!("bundled defaults TOML is invalid: {e}"),
        })?;
    deep_merge(&mut merged, user_table);

    toml::Value::Table(merged)
        .try_into()
        .map_err(|e: toml::de::Error| ConfigError::Invalid {
            path: path.to_path_buf(),
            message: e.to_string(),
        })
}

fn parse_defaults_only<C: AppConfig>(defaults_toml: &str) -> Result<C, ConfigError> {
    toml::from_str::<C>(defaults_toml).map_err(|e| ConfigError::Invalid {
        path: PathBuf::from("<bundled defaults>"),
        message: format!("bundled defaults TOML is invalid: {e}"),
    })
}

/// Recursively merge `from` into `into`. Nested tables merge field-by-field;
/// scalars and arrays in `from` overwrite their counterparts in `into`.
pub fn deep_merge(into: &mut toml::Table, from: toml::Table) {
    for (k, v) in from {
        match (into.get_mut(&k), v) {
            (Some(toml::Value::Table(into_t)), toml::Value::Table(from_t)) => {
                deep_merge(into_t, from_t);
            }
            (_, v) => {
                into.insert(k, v);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // The per-branch resolver tests (env absolute wins / empty / unset /
    // relative-ignored / no-home-errs) used to live here against a private
    // `resolve_xdg` copy. That copy is gone; the branches are covered by
    // `hjkl-xdg`'s own tests of the same names. What this crate still owns —
    // and therefore still tests — is the delegation and the error mapping.

    /// Every public dir here must be exactly `hjkl-xdg`'s answer plus the app
    /// leaf. If the delegation ever drifted back into a private reimplementation
    /// these would diverge under any non-default `XDG_*` env.
    #[test]
    fn dirs_delegate_to_hjkl_xdg() {
        assert_eq!(
            config_dir("myapp").unwrap(),
            hjkl_xdg::config_home().unwrap().join("myapp")
        );
        assert_eq!(
            data_dir("myapp").unwrap(),
            hjkl_xdg::data_home().unwrap().join("myapp")
        );
        assert_eq!(
            cache_dir("myapp").unwrap(),
            hjkl_xdg::cache_home().unwrap().join("myapp")
        );
    }

    /// `hjkl-xdg`'s only failure mode keeps surfacing as this crate's
    /// `NoHomeDir`, which `load`/`load_layered` match on by name.
    #[test]
    fn xdg_no_home_maps_to_no_home_dir() {
        assert!(matches!(
            map_xdg(hjkl_xdg::Error::NoHomeDir),
            ConfigError::NoHomeDir
        ));
    }

    /// Smoke test the public `config_dir(app)` returns *something*
    /// ending in `<app>` — exact path depends on the test runner's
    /// $HOME, so we just check the leaf component.
    #[test]
    fn config_dir_smoke() {
        let p = config_dir("myapp").unwrap();
        assert!(
            p.ends_with("myapp"),
            "expected path ending in `myapp`, got {p:?}"
        );
    }

    #[test]
    fn data_dir_smoke() {
        let p = data_dir("myapp").unwrap();
        assert!(p.ends_with("myapp"));
    }

    #[test]
    fn cache_dir_smoke() {
        let p = cache_dir("myapp").unwrap();
        assert!(p.ends_with("myapp"));
    }
}