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