Skip to main content

qubit_config/source/
yaml_config_source.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2025 - 2026 Haixing Hu.
4 *
5 *    SPDX-License-Identifier: Apache-2.0
6 *
7 *    Licensed under the Apache License, Version 2.0.
8 *
9 ******************************************************************************/
10//! # YAML File Configuration Source
11//!
12//! Loads configuration from YAML format files.
13//!
14//! # Flattening Strategy
15//!
16//! Nested YAML mappings are flattened using dot-separated keys.
17//! For example:
18//!
19//! ```yaml
20//! server:
21//!   host: localhost
22//!   port: 8080
23//! ```
24//!
25//! becomes `server.host = "localhost"` and `server.port = 8080`.
26//!
27//! Arrays are stored as multi-value properties.
28//!
29
30use std::collections::HashSet;
31use std::path::{
32    Path,
33    PathBuf,
34};
35
36use serde_norway as yaml_backend;
37use yaml_backend::Value as YamlValue;
38
39use crate::{
40    Config,
41    ConfigError,
42    ConfigResult,
43    utils,
44};
45
46use super::{
47    ConfigSource,
48    config_source::load_transactionally,
49};
50
51/// Configuration source that loads from YAML format files
52///
53/// # Examples
54///
55/// ```rust
56/// use qubit_config::source::{YamlConfigSource, ConfigSource};
57/// use qubit_config::Config;
58///
59/// let temp_dir = tempfile::tempdir().unwrap();
60/// let path = temp_dir.path().join("config.yaml");
61/// std::fs::write(&path, "server:\n  port: 8080\n").unwrap();
62/// let source = YamlConfigSource::from_file(path);
63/// let mut config = Config::new();
64/// source.load(&mut config).unwrap();
65/// assert_eq!(config.get::<i64>("server.port").unwrap(), 8080);
66/// ```
67///
68#[derive(Debug, Clone)]
69pub struct YamlConfigSource {
70    path: PathBuf,
71}
72
73impl YamlConfigSource {
74    /// Creates a new `YamlConfigSource` from a file path
75    ///
76    /// # Parameters
77    ///
78    /// * `path` - Path to the YAML file
79    #[inline]
80    pub fn from_file<P: AsRef<Path>>(path: P) -> Self {
81        Self {
82            path: path.as_ref().to_path_buf(),
83        }
84    }
85}
86
87impl ConfigSource for YamlConfigSource {
88    fn load(&self, config: &mut Config) -> ConfigResult<()> {
89        load_transactionally(self, config)
90    }
91
92    fn load_into(&self, config: &mut Config) -> ConfigResult<()> {
93        let content = std::fs::read_to_string(&self.path).map_err(|e| {
94            ConfigError::IoError(std::io::Error::new(
95                e.kind(),
96                format!("Failed to read YAML file '{}': {}", self.path.display(), e),
97            ))
98        })?;
99
100        let value: YamlValue = yaml_backend::from_str(&content).map_err(|e| {
101            ConfigError::ParseError(format!("Failed to parse YAML file '{}': {}", self.path.display(), e))
102        })?;
103
104        let mut seen = HashSet::new();
105        flatten_yaml_value("", &value, config, &mut seen)?;
106        Ok(())
107    }
108}
109
110/// Recursively flattens a YAML value into the config using dot-separated keys.
111///
112/// Scalar types are stored with their native types where possible:
113/// - Integer numbers → i64
114/// - Floating-point numbers → f64
115/// - Booleans → bool
116/// - Strings → String
117/// - Null → empty property (is_null returns true)
118pub(crate) fn flatten_yaml_value(
119    prefix: &str,
120    value: &YamlValue,
121    config: &mut Config,
122    seen: &mut HashSet<String>,
123) -> ConfigResult<()> {
124    match value {
125        YamlValue::Mapping(map) => {
126            for (k, v) in map {
127                let key_str = yaml_key_to_string(k)?;
128                let key = if prefix.is_empty() {
129                    key_str
130                } else {
131                    format!("{}.{}", prefix, key_str)
132                };
133                flatten_yaml_value(&key, v, config, seen)?;
134            }
135        }
136        YamlValue::Sequence(seq) => {
137            utils::ensure_unique_flattened_key(seen, prefix)?;
138            flatten_yaml_sequence(prefix, seq, config)?;
139        }
140        YamlValue::Null => {
141            // Null values are stored as empty properties to preserve null semantics.
142            use qubit_datatype::DataType;
143            utils::ensure_unique_flattened_key(seen, prefix)?;
144            config.set_null(prefix, DataType::String)?;
145        }
146        YamlValue::Bool(b) => {
147            utils::ensure_unique_flattened_key(seen, prefix)?;
148            config.set(prefix, *b)?;
149        }
150        YamlValue::Number(n) => {
151            utils::ensure_unique_flattened_key(seen, prefix)?;
152            if let Some(i) = n.as_i64() {
153                config.set(prefix, i)?;
154            } else {
155                let f = n.as_f64().expect("YAML number should be representable as i64 or f64");
156                config.set(prefix, f)?;
157            }
158        }
159        YamlValue::String(s) => {
160            utils::ensure_unique_flattened_key(seen, prefix)?;
161            config.set(prefix, s.clone())?;
162        }
163        YamlValue::Tagged(tagged) => {
164            flatten_yaml_value(prefix, &tagged.value, config, seen)?;
165        }
166    }
167    Ok(())
168}
169
170/// Flattens a YAML sequence into multi-value config entries.
171///
172/// Homogeneous scalar sequences are stored with their native types. Empty
173/// sequences are stored as explicit empty string lists because YAML carries no
174/// element type for them. Mixed scalar sequences fall back to string
175/// representation.
176///
177/// Nested structures inside sequences (mapping/sequence/tagged) are rejected
178/// with a parse error to avoid silently losing structure information.
179fn flatten_yaml_sequence(prefix: &str, seq: &[YamlValue], config: &mut Config) -> ConfigResult<()> {
180    if seq.is_empty() {
181        config.set(prefix, Vec::<String>::new())?;
182        return Ok(());
183    }
184
185    enum SeqKind {
186        Integer,
187        Float,
188        Bool,
189        String,
190    }
191
192    let kind = match &seq[0] {
193        YamlValue::Number(n) if n.is_i64() => SeqKind::Integer,
194        YamlValue::Number(_) => SeqKind::Float,
195        YamlValue::Bool(_) => SeqKind::Bool,
196        YamlValue::Mapping(_) | YamlValue::Sequence(_) | YamlValue::Tagged(_) => {
197            return Err(unsupported_yaml_sequence_element_error(prefix, &seq[0]));
198        }
199        _ => SeqKind::String,
200    };
201
202    let all_same = seq.iter().all(|item| match (&kind, item) {
203        (SeqKind::Integer, YamlValue::Number(n)) => n.is_i64(),
204        (SeqKind::Float, YamlValue::Number(_)) => true,
205        (SeqKind::Bool, YamlValue::Bool(_)) => true,
206        (SeqKind::String, YamlValue::String(_)) => true,
207        _ => false,
208    });
209
210    if !all_same {
211        let values = seq
212            .iter()
213            .map(|item| yaml_scalar_to_string(item, prefix))
214            .collect::<ConfigResult<Vec<_>>>()?;
215        config.set(prefix, values)?;
216        return Ok(());
217    }
218
219    match kind {
220        SeqKind::Integer => {
221            let values = seq
222                .iter()
223                .map(|item| {
224                    item.as_i64()
225                        .expect("YAML integer sequence was validated before insertion")
226                })
227                .collect::<Vec<_>>();
228            config.set(prefix, values)?;
229        }
230        SeqKind::Float => {
231            let values = seq
232                .iter()
233                .map(|item| {
234                    item.as_f64()
235                        .expect("YAML float sequence was validated before insertion")
236                })
237                .collect::<Vec<_>>();
238            config.set(prefix, values)?;
239        }
240        SeqKind::Bool => {
241            let values = seq
242                .iter()
243                .map(|item| {
244                    item.as_bool()
245                        .expect("YAML bool sequence was validated before insertion")
246                })
247                .collect::<Vec<_>>();
248            config.set(prefix, values)?;
249        }
250        SeqKind::String => {
251            let values = seq
252                .iter()
253                .map(|item| {
254                    yaml_scalar_to_string(item, prefix).expect("YAML string sequence was validated before insertion")
255                })
256                .collect::<Vec<_>>();
257            config.set(prefix, values)?;
258        }
259    }
260
261    Ok(())
262}
263
264/// Converts a YAML key to a string
265fn yaml_key_to_string(value: &YamlValue) -> ConfigResult<String> {
266    match value {
267        YamlValue::String(s) => Ok(s.clone()),
268        YamlValue::Number(n) => Ok(n.to_string()),
269        YamlValue::Bool(b) => Ok(b.to_string()),
270        YamlValue::Null => Ok("null".to_string()),
271        _ => Err(ConfigError::ParseError(format!(
272            "Unsupported YAML mapping key type: {value:?}"
273        ))),
274    }
275}
276
277/// Converts a YAML scalar value to a string (fallback for mixed-type
278/// sequences).
279///
280/// Nested structures are rejected to avoid silently converting them to empty
281/// strings.
282fn yaml_scalar_to_string(value: &YamlValue, key: &str) -> ConfigResult<String> {
283    match value {
284        YamlValue::String(s) => Ok(s.clone()),
285        YamlValue::Number(n) => Ok(n.to_string()),
286        YamlValue::Bool(b) => Ok(b.to_string()),
287        YamlValue::Null => Ok(String::new()),
288        YamlValue::Sequence(_) | YamlValue::Mapping(_) | YamlValue::Tagged(_) => {
289            Err(unsupported_yaml_sequence_element_error(key, value))
290        }
291    }
292}
293
294/// Builds a parse error for unsupported nested YAML sequence elements.
295fn unsupported_yaml_sequence_element_error(key: &str, value: &YamlValue) -> ConfigError {
296    let key = if key.is_empty() { "<root>" } else { key };
297    ConfigError::ParseError(format!("Unsupported nested YAML structure at key '{key}': {value:?}"))
298}