Skip to main content

lspkit_config/
layered.rs

1//! Layered TOML config loader.
2//!
3//! Walks up from a starting directory looking for a config file by name, then
4//! deserializes it into a consumer-supplied `serde::Deserialize` type. The
5//! search stops at the filesystem root or when a file is found.
6
7use std::path::{Path, PathBuf};
8
9use serde::de::DeserializeOwned;
10
11/// Errors from loading config.
12#[non_exhaustive]
13#[derive(Debug, thiserror::Error)]
14pub enum LoadError {
15    /// No config file was found while walking up from the start directory.
16    #[error("no config file named {0:?} found")]
17    NotFound(String),
18    /// The file existed but could not be read.
19    #[error("could not read {path}: {source}")]
20    Io {
21        /// Path that failed to open.
22        path: PathBuf,
23        /// I/O error.
24        #[source]
25        source: std::io::Error,
26    },
27    /// The file's TOML could not be parsed.
28    #[error("could not parse {path}: {message}")]
29    Parse {
30        /// Path that failed to parse.
31        path: PathBuf,
32        /// Detail.
33        message: String,
34    },
35}
36
37/// Outcome of a successful load.
38#[non_exhaustive]
39#[derive(Debug)]
40pub struct LoadedConfig<T> {
41    /// Path of the file that was loaded.
42    pub source: PathBuf,
43    /// Deserialized config.
44    pub value: T,
45}
46
47/// Walk up from `start` looking for `file_name`. Returns the first hit's path,
48/// or `None` if the search reaches the filesystem root.
49#[must_use]
50pub fn find_ancestor(start: &Path, file_name: &str) -> Option<PathBuf> {
51    let mut current: Option<&Path> = Some(start);
52    while let Some(dir) = current {
53        let candidate = dir.join(file_name);
54        if candidate.is_file() {
55            return Some(candidate);
56        }
57        current = dir.parent();
58    }
59    None
60}
61
62/// Load and deserialize a config file by walking up from `start`.
63///
64/// # Errors
65/// Returns [`LoadError::NotFound`] if no file matches, [`LoadError::Io`] on
66/// read failure, or [`LoadError::Parse`] on invalid TOML.
67pub fn load_from_ancestor<T>(start: &Path, file_name: &str) -> Result<LoadedConfig<T>, LoadError>
68where
69    T: DeserializeOwned,
70{
71    let path =
72        find_ancestor(start, file_name).ok_or_else(|| LoadError::NotFound(file_name.to_owned()))?;
73    load_from_path(&path)
74}
75
76/// Load and deserialize a config file at a specific path.
77///
78/// # Errors
79/// Returns [`LoadError::Io`] on read failure or [`LoadError::Parse`] on
80/// invalid TOML.
81pub fn load_from_path<T>(path: &Path) -> Result<LoadedConfig<T>, LoadError>
82where
83    T: DeserializeOwned,
84{
85    let text = std::fs::read_to_string(path).map_err(|source| LoadError::Io {
86        path: path.to_path_buf(),
87        source,
88    })?;
89    let value: T = toml::from_str(&text).map_err(|err| LoadError::Parse {
90        path: path.to_path_buf(),
91        message: err.to_string(),
92    })?;
93    Ok(LoadedConfig {
94        source: path.to_path_buf(),
95        value,
96    })
97}