Skip to main content

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}