zerodds_config/lib.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Crate `zerodds-config`. Safety classification: **SAFE**.
4//!
5//! Layered configuration: a base file, sorted drop-in directories, nested
6//! includes, and — for every value that survives the merge — the file and
7//! line it came from.
8//!
9//! ## Why this exists
10//!
11//! Three daemons carried the same YAML-subset parser in three copies
12//! (`coap-bridge::daemon::yaml`, `mqtt-bridge::daemon::yaml`,
13//! `websocket-bridge::daemon::config`), each saying in its own header that it
14//! was identical to the others, and the runtime read 28 `ZERODDS_*`
15//! environment variables straight out of `RuntimeConfig::default()`. A
16//! deployment could not answer the one question an operator asks first: where
17//! does this value come from. Nor could it ship a package default and let a
18//! site override one field without copying the whole file.
19//!
20//! ## The model
21//!
22//! A configuration is one tree of [`Node`]s, built by merging sources in a
23//! defined order. Later sources win. Every scalar remembers its [`Origin`],
24//! and [`Config::origin_of`] answers the operator's question by path.
25//!
26//! The order is fixed and dull on purpose:
27//!
28//! 1. the base file, if there is one,
29//! 2. each drop-in directory in the order it was added, and inside one
30//! directory its files sorted by name — `10-net.yaml` before `20-net.yaml`,
31//! the convention every systemd user already knows,
32//!
33//! Each file first loads its includes depth first, in the listed order, then
34//! merges its own keys. The textual position of `include:` does not change
35//! this precedence. An include may name a file or an explicit directory.
36//! Included directories use the same filename sort and extension filter as
37//! top-level drop-ins. Subdirectories are read only when explicitly included.
38//! Paths are relative to the file naming them. Explicit includes must exist,
39//! unlike optional top-level drop-in directories. Cycles and excessive depth
40//! are refused for both files and directories.
41//!
42//! ```yaml
43//! include:
44//! - defaults.yaml
45//! - project.d
46//! transport:
47//! mtu: 1400
48//! ```
49//!
50//! ## Merging
51//!
52//! Maps merge key by key, recursively. A scalar or sequence replaces what was
53//! there. A key written `+name` **appends** to the sequence already at `name`
54//! rather than replacing it, which is what a drop-in usually wants:
55//!
56//! ```yaml
57//! # 00-base.yaml
58//! peers:
59//! - 10.0.0.1
60//! # 10-site.yaml
61//! +peers:
62//! - 10.0.0.2 # both, in this order
63//! # 20-override.yaml
64//! peers:
65//! - 10.0.0.9 # only this one
66//! ```
67//!
68//! ## What this is not
69//!
70//! Not a YAML implementation. It reads the subset the daemons already used —
71//! two-space indentation, `key: value`, `- item` (a `- key: value` item is a
72//! map block that nests as written), flat flow lists of scalars
73//! (`[a, "b"]`), `#` comments, `${VAR}` and `${VAR:-default}` — and says so
74//! when it meets anything else, rather than guessing. Anchors, flow
75//! mappings, nested flow lists, multi-document streams and block scalars are
76//! refused by name.
77
78extern crate alloc;
79
80use alloc::collections::BTreeMap;
81use alloc::string::{String, ToString};
82use alloc::vec::Vec;
83
84pub mod cli;
85mod layers;
86mod load;
87mod parse;
88pub mod read;
89
90pub use layers::{
91 CLI_SOURCE, CommandLine, ENV_SOURCE, SystemLayout, env_config, env_layer, is_secret_path,
92 loader_for, path_node, unknown_keys,
93};
94pub use load::{Loader, MAX_INCLUDE_DEPTH, origins};
95
96/// How deep a configuration tree may nest.
97///
98/// A parser that recurses per indentation level and a walker that recurses
99/// per map level both turn a file into stack, and a configuration file is
100/// something an operator downloads, a package ships and a container mounts.
101/// Thirty-two is far past any hand-written layout — the deepest thing in this
102/// tree is four — and far short of what a stack minds.
103///
104/// Reaching it is a refusal by name ([`ConfigError::TooDeep`]), not a
105/// truncation: a file half-read is a configuration nobody wrote.
106pub const MAX_NESTING_DEPTH: usize = 32;
107pub use parse::parse_str;
108
109/// Where a value came from.
110///
111/// Carried per scalar rather than per file, because the answer an operator
112/// needs is about one value: after a base file and four drop-ins, *which* of
113/// them set `transport.mtu`.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum Origin {
116 /// A compiled-in default. No file said anything.
117 Default,
118 /// The process environment, under this variable name.
119 Env(String),
120 /// A file, at this line (1-based).
121 File {
122 /// Path as it was given to the loader, not canonicalised — an
123 /// operator recognises the path they wrote.
124 path: String,
125 /// 1-based line of the `key:` that carried the value.
126 line: usize,
127 },
128 /// Set programmatically by a caller.
129 Caller,
130 /// The command line, as the flag that carried it: `--set a.b=1`.
131 Cli(String),
132}
133
134impl core::fmt::Display for Origin {
135 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
136 match self {
137 Self::Default => f.write_str("built-in default"),
138 Self::Env(name) => write!(f, "environment ${name}"),
139 Self::File { path, line } => write!(f, "{path}:{line}"),
140 Self::Caller => f.write_str("set by the caller"),
141 Self::Cli(flag) => write!(f, "command line `{flag}`"),
142 }
143 }
144}
145
146/// One node of a configuration tree.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum Node {
149 /// A scalar, with the place it came from.
150 Scalar(String, Origin),
151 /// An ordered sequence.
152 Seq(Vec<Node>),
153 /// A mapping, ordered by key so that a rendered tree is stable.
154 Map(BTreeMap<String, Node>),
155}
156
157impl Node {
158 /// The scalar's text, or `None` for a sequence or a map.
159 #[must_use]
160 pub fn as_str(&self) -> Option<&str> {
161 match self {
162 Self::Scalar(s, _) => Some(s.as_str()),
163 _ => None,
164 }
165 }
166
167 /// The origin of a scalar; `None` for a sequence or a map, which have no
168 /// single one.
169 #[must_use]
170 pub fn origin(&self) -> Option<&Origin> {
171 match self {
172 Self::Scalar(_, o) => Some(o),
173 _ => None,
174 }
175 }
176
177 /// Follows a dotted path — `transport.mtu` — through maps.
178 #[must_use]
179 pub fn get(&self, path: &str) -> Option<&Self> {
180 let mut here = self;
181 for segment in path.split('.') {
182 match here {
183 Self::Map(m) => here = m.get(segment)?,
184 _ => return None,
185 }
186 }
187 Some(here)
188 }
189
190 /// Merges `other` into `self`, with `other` winning.
191 ///
192 /// Maps merge key by key. Anything else replaces. A key spelled `+name`
193 /// in `other` appends to the sequence at `name` instead of replacing it,
194 /// and appending to something that is not a sequence is an error rather
195 /// than a silent replacement — a drop-in that meant to extend a list and
196 /// found a scalar has a mistake in it, and saying so is the whole point.
197 ///
198 /// # Errors
199 /// [`ConfigError::AppendToNonSeq`] when `+name` meets a non-sequence,
200 /// [`ConfigError::TooDeep`] past [`MAX_NESTING_DEPTH`].
201 ///
202 /// zerodds-lint: recursion-depth 32
203 pub fn merge(&mut self, other: Self) -> Result<(), ConfigError> {
204 self.merge_at(other, 0)
205 }
206
207 /// [`Self::merge`] with the depth it is at.
208 ///
209 /// zerodds-lint: recursion-depth 32
210 fn merge_at(&mut self, other: Self, depth: usize) -> Result<(), ConfigError> {
211 if depth > MAX_NESTING_DEPTH {
212 return Err(ConfigError::TooDeep(MAX_NESTING_DEPTH));
213 }
214 // Even the first map is a layer: its +keys need interpretation, also
215 // in newly introduced nested maps. Installing it verbatim would leave
216 // literal +keys in the effective configuration.
217 if matches!(other, Self::Map(_)) && !matches!(self, Self::Map(_)) {
218 *self = Self::Map(BTreeMap::new());
219 }
220 let (mine, theirs) = match (&mut *self, other) {
221 (Self::Map(mine), Self::Map(theirs)) => (mine, theirs),
222 // Not two maps: the later source stands in place of the earlier
223 // one. A scalar does not merge with a sequence, and pretending it
224 // does would leave a value nobody wrote.
225 (slot, value) => {
226 *slot = value;
227 return Ok(());
228 }
229 };
230 for (key, value) in theirs {
231 if let Some(name) = key.strip_prefix('+') {
232 append(mine, name, value)?;
233 continue;
234 }
235 if matches!(value, Self::Map(_)) {
236 mine.entry(key)
237 .or_insert_with(|| Self::Map(BTreeMap::new()))
238 .merge_at(value, depth + 1)?;
239 } else {
240 mine.insert(key, value);
241 }
242 }
243 Ok(())
244 }
245}
246
247/// `+name` in a later source appends to the sequence at `name`.
248///
249/// Appending to something that is not a sequence is an error and not a silent
250/// replacement: a drop-in that meant to extend a list and found a scalar has a
251/// mistake in it, and saying so is the whole point of the spelling.
252fn append(into: &mut BTreeMap<String, Node>, name: &str, value: Node) -> Result<(), ConfigError> {
253 match into.get_mut(name) {
254 Some(Node::Seq(existing)) => {
255 match value {
256 Node::Seq(added) => existing.extend(added),
257 single => existing.push(single),
258 }
259 Ok(())
260 }
261 Some(_) => Err(ConfigError::AppendToNonSeq(name.to_string())),
262 None => {
263 // Nothing to append to yet: the drop-in creates the sequence, so
264 // the order of two drop-ins that both append does not depend on
265 // whether a base file happened to declare an empty list.
266 into.insert(
267 name.to_string(),
268 match value {
269 Node::Seq(v) => Node::Seq(v),
270 single => Node::Seq(alloc::vec![single]),
271 },
272 );
273 Ok(())
274 }
275 }
276}
277
278/// What can go wrong while reading a configuration.
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub enum ConfigError {
281 /// The file could not be read. Carries the path and the OS message.
282 Io(String, String),
283 /// The text is not in the subset this reader accepts.
284 Syntax {
285 /// Path of the file the line is in.
286 path: String,
287 /// 1-based line.
288 line: usize,
289 /// What was wrong, in the reader's own words.
290 message: String,
291 },
292 /// An `include:` chain came back to a file or directory already being read.
293 IncludeCycle(Vec<String>),
294 /// An `include:` chain went deeper than [`MAX_INCLUDE_DEPTH`].
295 IncludeTooDeep(usize),
296 /// `+name` was written where `name` is not a sequence.
297 AppendToNonSeq(String),
298 /// The tree nests deeper than [`MAX_NESTING_DEPTH`].
299 TooDeep(usize),
300 /// A command-line flag or a setting path that cannot be read.
301 Usage(String),
302 /// A value that parsed but is not one its setting accepts. Carries the
303 /// setting path, where the value came from, and what was expected — the
304 /// origin may be a file line, an environment variable or a flag.
305 Invalid {
306 /// Dotted setting path.
307 setting: String,
308 /// Where the refused value came from.
309 origin: Origin,
310 /// What the setting accepts.
311 message: String,
312 },
313}
314
315impl core::fmt::Display for ConfigError {
316 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
317 match self {
318 Self::Io(path, why) => write!(f, "{path}: {why}"),
319 Self::Syntax {
320 path,
321 line,
322 message,
323 } => write!(f, "{path}:{line}: {message}"),
324 Self::IncludeCycle(chain) => {
325 write!(f, "include cycle: {}", chain.join(" -> "))
326 }
327 Self::IncludeTooDeep(depth) => {
328 write!(f, "includes nested deeper than {depth}")
329 }
330 Self::AppendToNonSeq(key) => {
331 write!(f, "`+{key}` appends, but `{key}` is not a sequence")
332 }
333 Self::TooDeep(depth) => {
334 write!(f, "nested deeper than {depth} levels")
335 }
336 Self::Usage(message) => f.write_str(message),
337 Self::Invalid {
338 setting,
339 origin,
340 message,
341 } => write!(f, "{origin}: invalid {setting}: {message}"),
342 }
343 }
344}
345
346impl std::error::Error for ConfigError {}
347
348/// A merged configuration, and where each of its values came from.
349#[derive(Debug, Clone, Default)]
350pub struct Config {
351 root: Option<Node>,
352 /// Files read, in the order they were read. The audit trail an operator
353 /// gets when the value is not where they expected it.
354 sources: Vec<String>,
355}
356
357impl Config {
358 /// The merged tree, or `None` when nothing was loaded.
359 #[must_use]
360 pub fn root(&self) -> Option<&Node> {
361 self.root.as_ref()
362 }
363
364 /// Every file that contributed, in the order it was read.
365 #[must_use]
366 pub fn sources(&self) -> &[String] {
367 &self.sources
368 }
369
370 /// The node at a dotted path.
371 #[must_use]
372 pub fn get(&self, path: &str) -> Option<&Node> {
373 self.root.as_ref()?.get(path)
374 }
375
376 /// The scalar at a dotted path.
377 #[must_use]
378 pub fn get_str(&self, path: &str) -> Option<&str> {
379 self.get(path)?.as_str()
380 }
381
382 /// Where the value at a dotted path came from.
383 ///
384 /// This is the question a layered configuration exists to answer.
385 #[must_use]
386 pub fn origin_of(&self, path: &str) -> Option<&Origin> {
387 self.get(path)?.origin()
388 }
389
390 /// Merges one more source in, after everything already there.
391 /// A refused layer leaves both the effective tree and source list unchanged.
392 ///
393 /// # Errors
394 /// Whatever [`Node::merge`] reports.
395 pub fn push(&mut self, node: Node, source: String) -> Result<(), ConfigError> {
396 self.push_each(alloc::vec![node], source)
397 }
398
399 /// Merges several trees in, one after another, as one source. Each
400 /// tree's `+name` appends to what the ones before it left, which one
401 /// pre-merged tree could not express. All or nothing, like [`Self::push`].
402 ///
403 /// # Errors
404 /// Whatever [`Node::merge`] reports.
405 pub fn push_each(&mut self, nodes: Vec<Node>, source: String) -> Result<(), ConfigError> {
406 let mut root = self
407 .root
408 .clone()
409 .unwrap_or_else(|| Node::Map(BTreeMap::new()));
410 for node in nodes {
411 root.merge(node)?;
412 }
413 self.root = Some(root);
414 self.sources.push(source);
415 Ok(())
416 }
417}