Skip to main content

nidus_config/
lib.rs

1#![deny(missing_docs)]
2
3//! Typed configuration support.
4
5mod error;
6mod value;
7
8use std::{fs, path::Path};
9
10use serde::de::DeserializeOwned;
11use serde_json::{Map, Value};
12
13use error::deserialize_value;
14pub use error::{ConfigError, Result};
15use value::{insert_path, merge_maps, parse_scalar, prefixed_key_start};
16
17/// Typed configuration document assembled from explicit sources.
18///
19/// Sources are explicit and merge in the order you choose. Later sources
20/// override earlier values, while nested objects merge recursively.
21///
22/// ```
23/// use nidus_config::Config;
24/// use serde::Deserialize;
25///
26/// #[derive(Deserialize, Debug, PartialEq, Eq)]
27/// struct Settings {
28///     port: u16,
29///     database: DatabaseSettings,
30/// }
31///
32/// #[derive(Deserialize, Debug, PartialEq, Eq)]
33/// struct DatabaseSettings {
34///     url: String,
35///     pool_size: u32,
36/// }
37///
38/// let file = Config::from_json_str(r#"{
39///     "port": 3000,
40///     "database": { "url": "postgres://localhost/app", "pool_size": 5 }
41/// }"#)?;
42///
43/// let env = Config::from_prefixed_vars("APP", [
44///     ("APP_PORT", "8080"),
45///     ("APP_DATABASE__POOL_SIZE", "10"),
46/// ]);
47///
48/// let settings: Settings = file.merge(env).deserialize()?;
49/// assert_eq!(settings.port, 8080);
50/// assert_eq!(settings.database.url, "postgres://localhost/app");
51/// assert_eq!(settings.database.pool_size, 10);
52/// # Ok::<(), nidus_config::ConfigError>(())
53/// ```
54#[derive(Clone, Debug, Default)]
55pub struct Config {
56    values: Map<String, Value>,
57}
58
59impl Config {
60    /// Creates an empty configuration document.
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Creates configuration from key/value pairs.
66    ///
67    /// Values are parsed as JSON-like scalars, so strings such as `"true"`,
68    /// `"42"`, and `"null"` become boolean, number, and null values.
69    pub fn from_pairs<K, V>(pairs: impl IntoIterator<Item = (K, V)>) -> Self
70    where
71        K: Into<String>,
72        V: AsRef<str>,
73    {
74        let values = pairs
75            .into_iter()
76            .map(|(key, value)| (key.into(), parse_scalar(value.as_ref())))
77            .collect();
78        Self { values }
79    }
80
81    /// Creates configuration from a JSON object value.
82    pub fn from_value(value: Value) -> Result<Self> {
83        match value {
84            Value::Object(values) => Ok(Self { values }),
85            _ => Err(ConfigError::RootNotObject),
86        }
87    }
88
89    /// Creates configuration from a JSON object string.
90    pub fn from_json_str(source: &str) -> Result<Self> {
91        let value = serde_json::from_str(source).map_err(ConfigError::Parse)?;
92        Self::from_value(value)
93    }
94
95    /// Creates configuration from a JSON object file.
96    pub fn from_json_file(path: impl AsRef<Path>) -> Result<Self> {
97        let path = path.as_ref();
98        let label = path.display().to_string();
99        let source = fs::read_to_string(path).map_err(|source| ConfigError::ReadFile {
100            path: label.clone(),
101            source,
102        })?;
103        let value = serde_json::from_str(&source).map_err(|source| ConfigError::ParseFile {
104            path: label.clone(),
105            source,
106        })?;
107        match value {
108            Value::Object(values) => Ok(Self { values }),
109            _ => Err(ConfigError::FileRootNotObject { path: label }),
110        }
111    }
112
113    /// Creates configuration from process environment variables with a prefix.
114    ///
115    /// For prefix `APP`, `APP_PORT=3000` maps to `port`, and
116    /// `APP_DATABASE__URL=...` maps to `database.url`.
117    pub fn from_env_prefix(prefix: &str) -> Self {
118        Self::from_prefixed_vars(prefix, std::env::vars())
119    }
120
121    /// Creates configuration from prefixed key/value variables.
122    ///
123    /// This is useful for deterministic tests and for loading from custom
124    /// environment sources. Prefix matching is case-sensitive; keys are
125    /// normalized to lowercase after the prefix is removed. Double underscores
126    /// create nested paths; empty path segments are ignored.
127    pub fn from_prefixed_vars<K, V>(prefix: &str, vars: impl IntoIterator<Item = (K, V)>) -> Self
128    where
129        K: AsRef<str>,
130        V: AsRef<str>,
131    {
132        let prefix = prefixed_key_start(prefix);
133        let mut config = Self::new();
134
135        for (key, value) in vars {
136            let Some(raw_key) = key.as_ref().strip_prefix(&prefix) else {
137                continue;
138            };
139            if raw_key.is_empty() {
140                continue;
141            }
142
143            let path = raw_key
144                .split("__")
145                .filter(|segment| !segment.is_empty())
146                .map(|segment| segment.to_ascii_lowercase())
147                .collect::<Vec<_>>();
148            if !path.is_empty() {
149                insert_path(&mut config.values, &path, parse_scalar(value.as_ref()));
150            }
151        }
152
153        config
154    }
155
156    /// Inserts a raw JSON configuration value.
157    pub fn insert_value(&mut self, key: impl Into<String>, value: Value) {
158        self.values.insert(key.into(), value);
159    }
160
161    /// Returns a top-level raw configuration value.
162    pub fn get(&self, key: &str) -> Option<&Value> {
163        self.values.get(key)
164    }
165
166    /// Deserializes a top-level configuration value into a typed value.
167    pub fn get_typed<T>(&self, key: &str) -> Result<Option<T>>
168    where
169        T: DeserializeOwned,
170    {
171        self.get(key)
172            .map(|value| deserialize_value(key.to_owned(), value))
173            .transpose()
174    }
175
176    /// Deserializes a required top-level configuration value into a typed value.
177    pub fn get_required_typed<T>(&self, key: &str) -> Result<T>
178    where
179        T: DeserializeOwned,
180    {
181        self.get_typed(key)?
182            .ok_or_else(|| ConfigError::MissingValue {
183                path: key.to_owned(),
184            })
185    }
186
187    /// Returns a nested raw configuration value by path.
188    ///
189    /// Object segments match keys. Array segments are zero-based numeric
190    /// indexes such as `"0"`.
191    pub fn get_path<I, S>(&self, path: I) -> Option<&Value>
192    where
193        I: IntoIterator<Item = S>,
194        S: AsRef<str>,
195    {
196        let mut path = path.into_iter();
197        let first = path.next()?;
198        let mut value = self.values.get(first.as_ref())?;
199
200        for segment in path {
201            let segment = segment.as_ref();
202            value = match value {
203                Value::Object(object) => object.get(segment)?,
204                Value::Array(array) => array.get(segment.parse::<usize>().ok()?)?,
205                _ => return None,
206            };
207        }
208
209        Some(value)
210    }
211
212    /// Deserializes a nested configuration value into a typed value.
213    pub fn get_path_typed<I, S, T>(&self, path: I) -> Result<Option<T>>
214    where
215        I: IntoIterator<Item = S>,
216        S: AsRef<str>,
217        T: DeserializeOwned,
218    {
219        let path = path
220            .into_iter()
221            .map(|segment| segment.as_ref().to_owned())
222            .collect::<Vec<_>>();
223        let label = path.join(".");
224        self.get_path(path.iter().map(String::as_str))
225            .map(|value| deserialize_value(label, value))
226            .transpose()
227    }
228
229    /// Deserializes a required nested configuration value into a typed value.
230    pub fn get_required_path_typed<I, S, T>(&self, path: I) -> Result<T>
231    where
232        I: IntoIterator<Item = S>,
233        S: AsRef<str>,
234        T: DeserializeOwned,
235    {
236        let path = path
237            .into_iter()
238            .map(|segment| segment.as_ref().to_owned())
239            .collect::<Vec<_>>();
240        let label = path.join(".");
241        match self.get_path(path.iter().map(String::as_str)) {
242            Some(value) => deserialize_value(label, value),
243            None => Err(ConfigError::MissingValue { path: label }),
244        }
245    }
246
247    /// Merges another configuration source into this one.
248    ///
249    /// Values from `other` take precedence. Nested objects are merged
250    /// recursively so later sources can override one field without replacing an
251    /// entire nested configuration section.
252    pub fn merge(mut self, other: Self) -> Self {
253        self.merge_from(other);
254        self
255    }
256
257    /// Merges another configuration source into this configuration in place.
258    pub fn merge_from(&mut self, other: Self) {
259        merge_maps(&mut self.values, other.values);
260    }
261
262    /// Deserializes the configuration into a strongly typed settings struct.
263    pub fn deserialize<T>(&self) -> Result<T>
264    where
265        T: DeserializeOwned,
266    {
267        T::deserialize(&self.values).map_err(ConfigError::Deserialize)
268    }
269}