hjkl-config 0.2.1

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::Serialize;
use serde::de::DeserializeOwned;

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

pub use hjkl_xdg::{cache_dir, config_dir, data_dir};

/// 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,
}

/// 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(crate) 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);
            }
        }
    }
}

/// Serialize `cfg` and write it to `path`, creating parent directories.
///
/// Opt-in helper for apps that want to scaffold a starter config on user
/// request. **Not** called automatically by [`load`] — callers must invoke
/// it explicitly (e.g. behind a `--init` flag).
pub fn write_default<C: AppConfig + Serialize>(path: &Path, cfg: &C) -> Result<(), ConfigError> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| ConfigError::Write {
            path: parent.to_path_buf(),
            source: e,
        })?;
    }
    let body = toml::to_string_pretty(cfg).map_err(|e| ConfigError::Write {
        path: path.to_path_buf(),
        source: std::io::Error::other(e.to_string()),
    })?;
    std::fs::write(path, body).map_err(|e| ConfigError::Write {
        path: path.to_path_buf(),
        source: e,
    })
}

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

    /// 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"));
    }
}