Skip to main content

qubit_config/source/
yaml_config_source.rs

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