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            if !config.contains(prefix) {
132                config.set_null(prefix, DataType::String)?;
133            }
134        }
135        YamlValue::Bool(b) => {
136            config.set(prefix, *b)?;
137        }
138        YamlValue::Number(n) => {
139            if let Some(i) = n.as_i64() {
140                config.set(prefix, i)?;
141            } else if let Some(f) = n.as_f64() {
142                config.set(prefix, f)?;
143            } else {
144                config.set(prefix, n.to_string())?;
145            }
146        }
147        YamlValue::String(s) => {
148            config.set(prefix, s.clone())?;
149        }
150        YamlValue::Tagged(tagged) => {
151            flatten_yaml_value(prefix, &tagged.value, config)?;
152        }
153    }
154    Ok(())
155}
156
157/// Flattens a YAML sequence into multi-value config entries.
158///
159/// Homogeneous scalar sequences are stored with their native types.
160/// Mixed scalar sequences fall back to string representation.
161///
162/// Nested structures inside sequences (mapping/sequence/tagged) are rejected
163/// with a parse error to avoid silently losing structure information.
164fn flatten_yaml_sequence(prefix: &str, seq: &[YamlValue], config: &mut Config) -> ConfigResult<()> {
165    if seq.is_empty() {
166        return Ok(());
167    }
168
169    enum SeqKind {
170        Integer,
171        Float,
172        Bool,
173        String,
174    }
175
176    let kind = match &seq[0] {
177        YamlValue::Number(n) if n.is_i64() => SeqKind::Integer,
178        YamlValue::Number(_) => SeqKind::Float,
179        YamlValue::Bool(_) => SeqKind::Bool,
180        YamlValue::Mapping(_) | YamlValue::Sequence(_) | YamlValue::Tagged(_) => {
181            return Err(unsupported_yaml_sequence_element_error(prefix, &seq[0]));
182        }
183        _ => SeqKind::String,
184    };
185
186    let all_same = seq.iter().all(|item| match (&kind, item) {
187        (SeqKind::Integer, YamlValue::Number(n)) => n.is_i64(),
188        (SeqKind::Float, YamlValue::Number(_)) => true,
189        (SeqKind::Bool, YamlValue::Bool(_)) => true,
190        (SeqKind::String, YamlValue::String(_)) => true,
191        _ => false,
192    });
193
194    if !all_same {
195        for item in seq {
196            config.add(prefix, yaml_scalar_to_string(item, prefix)?)?;
197        }
198        return Ok(());
199    }
200
201    match kind {
202        SeqKind::Integer => {
203            for item in seq {
204                if let YamlValue::Number(n) = item
205                    && let Some(i) = n.as_i64()
206                {
207                    config.add(prefix, i)?;
208                }
209            }
210        }
211        SeqKind::Float => {
212            for item in seq {
213                if let YamlValue::Number(n) = item
214                    && let Some(f) = n.as_f64()
215                {
216                    config.add(prefix, f)?;
217                }
218            }
219        }
220        SeqKind::Bool => {
221            for item in seq {
222                if let YamlValue::Bool(b) = item {
223                    config.add(prefix, *b)?;
224                }
225            }
226        }
227        SeqKind::String => {
228            for item in seq {
229                config.add(prefix, yaml_scalar_to_string(item, prefix)?)?;
230            }
231        }
232    }
233
234    Ok(())
235}
236
237/// Converts a YAML key to a string
238fn yaml_key_to_string(value: &YamlValue) -> ConfigResult<String> {
239    match value {
240        YamlValue::String(s) => Ok(s.clone()),
241        YamlValue::Number(n) => Ok(n.to_string()),
242        YamlValue::Bool(b) => Ok(b.to_string()),
243        YamlValue::Null => Ok("null".to_string()),
244        _ => Err(ConfigError::ParseError(format!(
245            "Unsupported YAML mapping key type: {value:?}"
246        ))),
247    }
248}
249
250/// Converts a YAML scalar value to a string (fallback for mixed-type
251/// sequences).
252///
253/// Nested structures are rejected to avoid silently converting them to empty
254/// strings.
255fn yaml_scalar_to_string(value: &YamlValue, key: &str) -> ConfigResult<String> {
256    match value {
257        YamlValue::String(s) => Ok(s.clone()),
258        YamlValue::Number(n) => Ok(n.to_string()),
259        YamlValue::Bool(b) => Ok(b.to_string()),
260        YamlValue::Null => Ok(String::new()),
261        YamlValue::Sequence(_) | YamlValue::Mapping(_) | YamlValue::Tagged(_) => {
262            Err(unsupported_yaml_sequence_element_error(key, value))
263        }
264    }
265}
266
267/// Builds a parse error for unsupported nested YAML sequence elements.
268fn unsupported_yaml_sequence_element_error(key: &str, value: &YamlValue) -> ConfigError {
269    let key = if key.is_empty() { "<root>" } else { key };
270    ConfigError::ParseError(format!(
271        "Unsupported nested YAML structure at key '{key}': {value:?}"
272    ))
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn test_yaml_key_to_string_number() {
281        let key = YamlValue::Number(serde_yaml::Number::from(42));
282        assert_eq!(yaml_key_to_string(&key).unwrap(), "42");
283    }
284
285    #[test]
286    fn test_yaml_key_to_string_bool() {
287        let key = YamlValue::Bool(true);
288        assert_eq!(yaml_key_to_string(&key).unwrap(), "true");
289    }
290
291    #[test]
292    fn test_yaml_key_to_string_null() {
293        let key = YamlValue::Null;
294        assert_eq!(yaml_key_to_string(&key).unwrap(), "null");
295    }
296
297    #[test]
298    fn test_yaml_scalar_to_string_bool() {
299        assert_eq!(
300            yaml_scalar_to_string(&YamlValue::Bool(false), "k").unwrap(),
301            "false"
302        );
303    }
304
305    #[test]
306    fn test_yaml_scalar_to_string_null() {
307        assert_eq!(yaml_scalar_to_string(&YamlValue::Null, "k").unwrap(), "");
308    }
309
310    #[test]
311    fn test_yaml_scalar_to_string_sequence_returns_error() {
312        let result = yaml_scalar_to_string(&YamlValue::Sequence(vec![]), "arr");
313        assert!(matches!(result, Err(ConfigError::ParseError(_))));
314    }
315
316    #[test]
317    fn test_yaml_scalar_to_string_mapping_returns_error() {
318        let result = yaml_scalar_to_string(&YamlValue::Mapping(serde_yaml::Mapping::new()), "obj");
319        assert!(matches!(result, Err(ConfigError::ParseError(_))));
320    }
321
322    #[test]
323    fn test_flatten_yaml_sequence_mixed_int_null_fallback() {
324        // Mixed: int + null → falls back to string
325        let seq = vec![
326            YamlValue::Number(serde_yaml::Number::from(1i64)),
327            YamlValue::Null,
328        ];
329        let mut config = Config::new();
330        flatten_yaml_sequence("mixed", &seq, &mut config).unwrap();
331        // Should fall back to string representation
332        assert!(config.contains("mixed"));
333    }
334
335    #[test]
336    fn test_flatten_yaml_sequence_mixed_float_string_fallback() {
337        // Mixed: float + string → falls back to string
338        let seq = vec![
339            YamlValue::Number(serde_yaml::Number::from(1.5f64)),
340            YamlValue::String("two".to_string()),
341        ];
342        let mut config = Config::new();
343        flatten_yaml_sequence("mixed", &seq, &mut config).unwrap();
344        assert!(config.contains("mixed"));
345    }
346
347    #[test]
348    fn test_flatten_yaml_sequence_mixed_bool_string_fallback() {
349        // Mixed: bool + string → falls back to string
350        let seq = vec![YamlValue::Bool(true), YamlValue::String("two".to_string())];
351        let mut config = Config::new();
352        flatten_yaml_sequence("mixed", &seq, &mut config).unwrap();
353        assert!(config.contains("mixed"));
354    }
355
356    #[test]
357    fn test_flatten_yaml_sequence_mixed_string_int_fallback() {
358        // Mixed: string + int → falls back to string
359        let seq = vec![
360            YamlValue::String("one".to_string()),
361            YamlValue::Number(serde_yaml::Number::from(2i64)),
362        ];
363        let mut config = Config::new();
364        flatten_yaml_sequence("mixed", &seq, &mut config).unwrap();
365        assert!(config.contains("mixed"));
366    }
367
368    #[test]
369    fn test_flatten_yaml_sequence_nested_mapping_returns_error() {
370        let seq = vec![YamlValue::Mapping(serde_yaml::Mapping::new())];
371        let mut config = Config::new();
372        let result = flatten_yaml_sequence("nested", &seq, &mut config);
373        assert!(matches!(result, Err(ConfigError::ParseError(_))));
374    }
375
376    #[test]
377    fn test_flatten_yaml_sequence_nested_sequence_returns_error() {
378        let seq = vec![YamlValue::Sequence(vec![YamlValue::Bool(true)])];
379        let mut config = Config::new();
380        let result = flatten_yaml_sequence("nested", &seq, &mut config);
381        assert!(matches!(result, Err(ConfigError::ParseError(_))));
382    }
383
384    #[test]
385    fn test_flatten_yaml_value_tagged() {
386        use serde_yaml::value::Tag;
387        use serde_yaml::value::TaggedValue;
388        let tagged = YamlValue::Tagged(Box::new(TaggedValue {
389            tag: Tag::new("!!str"),
390            value: YamlValue::String("hello".to_string()),
391        }));
392        let mut config = Config::new();
393        flatten_yaml_value("key", &tagged, &mut config).unwrap();
394        assert_eq!(config.get_string("key").unwrap(), "hello");
395    }
396
397    #[test]
398    fn test_flatten_yaml_value_number_no_i64() {
399        // A very large float that can't be represented as i64
400        let num = serde_yaml::Number::from(f64::MAX);
401        let val = YamlValue::Number(num);
402        let mut config = Config::new();
403        flatten_yaml_value("key", &val, &mut config).unwrap();
404        assert!(config.contains("key"));
405    }
406}