noyalib/figment.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! [`figment`] provider for noyalib YAML.
5//!
6//! [`figment`] is the popular layered-configuration crate: it
7//! merges multiple config sources (env vars, TOML / JSON / YAML
8//! files, CLI flags, in-memory overrides) into a single typed
9//! struct via `Figment::new().merge(...).join(...).extract()`. The
10//! [`Yaml`](crate::figment::Yaml) provider in this module plugs noyalib into that
11//! chain the same way `figment::providers::Toml` /
12//! `figment::providers::Json` do — without depending on the
13//! unmaintained `serde_yaml` 0.9 crate.
14//!
15//! Gated behind the `figment` Cargo feature.
16//!
17//! # Examples
18//!
19//! ```rust
20//! use figment::providers::Format;
21//! use figment::Figment;
22//! use noyalib::figment::Yaml;
23//!
24//! #[derive(serde::Deserialize)]
25//! struct Config {
26//! name: String,
27//! port: u16,
28//! }
29//!
30//! let yaml = "name: noyalib\nport: 8080\n";
31//! let cfg: Config = Figment::new().merge(Yaml::string(yaml)).extract().unwrap();
32//! assert_eq!(cfg.name, "noyalib");
33//! assert_eq!(cfg.port, 8080);
34//! ```
35//!
36//! Layered example — start with defaults, override with a YAML file,
37//! finalise with environment variables:
38//!
39//! ```rust,ignore
40//! use figment::Figment;
41//! use figment::providers::{Env, Serialized};
42//! use noyalib::figment::Yaml;
43//!
44//! let cfg = Figment::new()
45//! .merge(Serialized::defaults(MyDefaults::default()))
46//! .merge(Yaml::file("config.yaml"))
47//! .merge(Env::prefixed("MYAPP_"))
48//! .extract::<MyConfig>()
49//! .unwrap();
50//! ```
51
52use std::path::{Path, PathBuf};
53
54use figment::providers::Format;
55use figment::value::{Dict, Map};
56use figment::{Error as FigmentError, Metadata, Profile, Provider};
57
58use crate::ParserConfig;
59
60/// Figment [`Format`] for noyalib YAML.
61///
62/// Build a provider via `Yaml::string(yaml_text)`, `Yaml::file(path)`,
63/// or any of the other [`Format`] constructors inherited from the
64/// trait. The result implements [`figment::Provider`] and slots into
65/// `Figment::merge` / `Figment::join` chains.
66///
67/// These providers parse under the default [`ParserConfig`]: a source
68/// over `max_document_length` is refused, and a file is read no
69/// further than one byte past that limit. Use
70/// [`Yaml::string_with_config`] or [`Yaml::file_with_config`] to parse
71/// under other limits or policies.
72#[derive(Debug, Clone, Copy)]
73pub struct Yaml;
74
75impl Format for Yaml {
76 type Error = FigmentError;
77
78 const NAME: &'static str = "YAML";
79
80 fn from_str<T: serde_core::de::DeserializeOwned>(s: &str) -> Result<T, Self::Error> {
81 // figment's `Format::from_str` constrains `T:
82 // DeserializeOwned` only — no `'static`. Bypass noyalib's
83 // public `from_str` (which adds `'static` to enable the
84 // TypeId-driven tag-preserving path for `Value`) and call
85 // the internal non-tag-preserving entry directly.
86 // figment's typed targets are profile shapes, not `Value`,
87 // so the tag-preserving path would never have applied
88 // anyway.
89 parse(s, &ParserConfig::default()).map_err(to_figment)
90 }
91
92 fn from_path<T: serde_core::de::DeserializeOwned>(path: &Path) -> Result<T, Self::Error> {
93 // figment's default reads the whole file first; stop one byte
94 // past the length limit instead.
95 let config = ParserConfig::default();
96 read_bounded(path, &config)
97 .and_then(|text| parse(&text, &config))
98 .map_err(to_figment)
99 }
100}
101
102impl Yaml {
103 /// A provider that parses `yaml` under `config`, for limits or
104 /// policies other than the defaults. Emits to the default profile
105 /// unless [`YamlWithConfig::nested`] or [`YamlWithConfig::profile`]
106 /// says otherwise, like [`Format::string`].
107 ///
108 /// # Examples
109 ///
110 /// ```rust
111 /// use figment::Figment;
112 /// use noyalib::ParserConfig;
113 /// use noyalib::figment::Yaml;
114 ///
115 /// #[derive(serde::Deserialize)]
116 /// struct Config {
117 /// port: u16,
118 /// }
119 ///
120 /// let provider = Yaml::string_with_config("port: 8080\n", ParserConfig::strict());
121 /// let cfg: Config = Figment::new().merge(provider).extract().unwrap();
122 /// assert_eq!(cfg.port, 8080);
123 /// ```
124 #[must_use]
125 pub fn string_with_config(yaml: &str, config: ParserConfig) -> YamlWithConfig {
126 YamlWithConfig::new(Source::String(yaml.to_owned()), config)
127 }
128
129 /// A provider that reads and parses the file at `path` under
130 /// `config`. The path is used as given (no search of parent
131 /// directories, unlike [`Format::file`]); a missing file is an
132 /// error. Reading stops one byte past `max_document_length`.
133 #[must_use]
134 pub fn file_with_config<P: AsRef<Path>>(path: P, config: ParserConfig) -> YamlWithConfig {
135 YamlWithConfig::new(Source::File(path.as_ref().to_path_buf()), config)
136 }
137}
138
139/// A figment [`Provider`] for YAML parsed under a caller-supplied
140/// [`ParserConfig`]. Built by [`Yaml::string_with_config`] or
141/// [`Yaml::file_with_config`].
142#[derive(Debug, Clone)]
143pub struct YamlWithConfig {
144 source: Source,
145 config: ParserConfig,
146 profile: Option<Profile>,
147}
148
149#[derive(Debug, Clone)]
150enum Source {
151 String(String),
152 File(PathBuf),
153}
154
155impl YamlWithConfig {
156 fn new(source: Source, config: ParserConfig) -> Self {
157 Self {
158 source,
159 config,
160 profile: Some(Profile::Default),
161 }
162 }
163
164 /// Treat the document's top-level keys as profile names, like
165 /// figment's `Data::nested`.
166 #[must_use]
167 pub fn nested(mut self) -> Self {
168 self.profile = None;
169 self
170 }
171
172 /// Emit the document's values to `profile` (when not nested).
173 #[must_use]
174 pub fn profile<P: Into<Profile>>(mut self, profile: P) -> Self {
175 self.profile = Some(profile.into());
176 self
177 }
178}
179
180impl Provider for YamlWithConfig {
181 fn metadata(&self) -> Metadata {
182 match &self.source {
183 Source::String(_) => Metadata::named("YAML source string"),
184 Source::File(path) => Metadata::from("YAML file", path.as_path()),
185 }
186 }
187
188 fn data(&self) -> Result<Map<Profile, Dict>, FigmentError> {
189 let file_text;
190 let text = match &self.source {
191 Source::String(s) => s.as_str(),
192 Source::File(path) => {
193 file_text = read_bounded(path, &self.config).map_err(to_figment)?;
194 file_text.as_str()
195 }
196 };
197 match &self.profile {
198 Some(profile) => parse::<Dict>(text, &self.config).map(|dict| profile.collect(dict)),
199 None => parse(text, &self.config),
200 }
201 .map_err(to_figment)
202 }
203}
204
205/// Parse `s` under `config`.
206fn parse<T: serde_core::de::DeserializeOwned>(s: &str, config: &ParserConfig) -> crate::Result<T> {
207 crate::de::from_str_typed_no_tag_preserve::<T>(s, config)
208}
209
210/// Read `path` if it is a regular file, rejecting non-regular files and
211/// oversized files via metadata before reading, and stopping one byte
212/// past `config.max_document_length`.
213fn read_bounded(path: &Path, config: &ParserConfig) -> crate::Result<String> {
214 let pre_meta = std::fs::metadata(path).map_err(crate::Error::Io)?;
215 if !pre_meta.is_file() {
216 return Err(crate::Error::Io(std::io::Error::new(
217 std::io::ErrorKind::InvalidInput,
218 "not a regular file",
219 )));
220 }
221 let file = std::fs::File::open(path).map_err(crate::Error::Io)?;
222 let meta = file.metadata().map_err(crate::Error::Io)?;
223 if !meta.is_file() {
224 return Err(crate::Error::Io(std::io::Error::new(
225 std::io::ErrorKind::InvalidInput,
226 "not a regular file",
227 )));
228 }
229 let max_len = config.max_document_length;
230 if meta.len() > u64::try_from(max_len).unwrap_or(u64::MAX) {
231 return Err(crate::Error::Parse(format!(
232 "document exceeds maximum length of {max_len} bytes"
233 )));
234 }
235 crate::de::read_to_string_bounded(file, config)
236}
237
238/// figment's error type carries the message as text.
239fn to_figment(error: crate::Error) -> FigmentError {
240 FigmentError::from(error.to_string())
241}