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}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::Property;
293    use std::path::PathBuf;
294
295    use qubit_value::MultiValues;
296
297    fn config_with_final_property(name: &str) -> Config {
298        let mut config = Config::new();
299        let mut property = Property::with_value(name, MultiValues::String(vec!["old".to_string()]));
300        property.set_final(true);
301        config.insert_property(name, property).unwrap();
302        config
303    }
304
305    fn expect_final_error(result: ConfigResult<()>, name: &str) {
306        let err = result.expect_err("writing a final YAML property should fail");
307        assert_eq!(
308            err.to_string(),
309            format!("Property '{name}' is final and cannot be overridden"),
310        );
311    }
312
313    #[test]
314    fn test_from_file_stores_path() {
315        let path = PathBuf::from("config.yaml");
316        let source = YamlConfigSource::from_file(&path);
317        let cloned = source.clone();
318        assert_eq!(source.path, path);
319        assert_eq!(cloned.path, PathBuf::from("config.yaml"));
320    }
321
322    #[test]
323    fn test_load_yaml_file_success() {
324        let dir = tempfile::tempdir().unwrap();
325        let path = dir.path().join("config.yaml");
326        std::fs::write(&path, "server:\n  port: 8080\n").unwrap();
327
328        let source = YamlConfigSource::from_file(&path);
329        let mut config = Config::new();
330
331        source.load(&mut config).unwrap();
332
333        assert_eq!(config.get::<i64>("server.port").unwrap(), 8080);
334    }
335
336    #[test]
337    fn test_load_missing_yaml_file_returns_io_error() {
338        let source = YamlConfigSource::from_file("missing.yaml");
339        let mut config = Config::new();
340
341        source
342            .load(&mut config)
343            .expect_err("missing YAML file should fail");
344    }
345
346    #[test]
347    fn test_load_invalid_yaml_file_returns_parse_error() {
348        let dir = tempfile::tempdir().unwrap();
349        let path = dir.path().join("invalid.yaml");
350        std::fs::write(&path, "key: [unterminated\n").unwrap();
351
352        let source = YamlConfigSource::from_file(&path);
353        let mut config = Config::new();
354
355        source
356            .load(&mut config)
357            .expect_err("invalid YAML file should fail");
358    }
359
360    #[test]
361    fn test_yaml_key_to_string_number() {
362        let key = YamlValue::Number(serde_yaml::Number::from(42));
363        assert_eq!(yaml_key_to_string(&key).unwrap(), "42");
364    }
365
366    #[test]
367    fn test_yaml_key_to_string_bool() {
368        let key = YamlValue::Bool(true);
369        assert_eq!(yaml_key_to_string(&key).unwrap(), "true");
370    }
371
372    #[test]
373    fn test_yaml_key_to_string_null() {
374        let key = YamlValue::Null;
375        assert_eq!(yaml_key_to_string(&key).unwrap(), "null");
376    }
377
378    #[test]
379    fn test_yaml_scalar_to_string_bool() {
380        assert_eq!(
381            yaml_scalar_to_string(&YamlValue::Bool(false), "k").unwrap(),
382            "false"
383        );
384    }
385
386    #[test]
387    fn test_yaml_scalar_to_string_null() {
388        assert_eq!(yaml_scalar_to_string(&YamlValue::Null, "k").unwrap(), "");
389    }
390
391    #[test]
392    fn test_yaml_scalar_to_string_sequence_returns_error() {
393        yaml_scalar_to_string(&YamlValue::Sequence(vec![]), "arr")
394            .expect_err("nested YAML sequence should fail scalar conversion");
395    }
396
397    #[test]
398    fn test_yaml_scalar_to_string_mapping_returns_error() {
399        yaml_scalar_to_string(&YamlValue::Mapping(serde_yaml::Mapping::new()), "obj")
400            .expect_err("nested YAML mapping should fail scalar conversion");
401    }
402
403    #[test]
404    fn test_flatten_yaml_sequence_mixed_int_null_fallback() {
405        // Mixed: int + null → falls back to string
406        let seq = vec![
407            YamlValue::Number(serde_yaml::Number::from(1i64)),
408            YamlValue::Null,
409        ];
410        let mut config = Config::new();
411        flatten_yaml_sequence("mixed", &seq, &mut config).unwrap();
412        // Should fall back to string representation
413        assert!(config.contains("mixed"));
414    }
415
416    #[test]
417    fn test_flatten_yaml_sequence_mixed_float_string_fallback() {
418        // Mixed: float + string → falls back to string
419        let seq = vec![
420            YamlValue::Number(serde_yaml::Number::from(1.5f64)),
421            YamlValue::String("two".to_string()),
422        ];
423        let mut config = Config::new();
424        flatten_yaml_sequence("mixed", &seq, &mut config).unwrap();
425        assert!(config.contains("mixed"));
426    }
427
428    #[test]
429    fn test_flatten_yaml_sequence_mixed_bool_string_fallback() {
430        // Mixed: bool + string → falls back to string
431        let seq = vec![YamlValue::Bool(true), YamlValue::String("two".to_string())];
432        let mut config = Config::new();
433        flatten_yaml_sequence("mixed", &seq, &mut config).unwrap();
434        assert!(config.contains("mixed"));
435    }
436
437    #[test]
438    fn test_flatten_yaml_sequence_mixed_string_int_fallback() {
439        // Mixed: string + int → falls back to string
440        let seq = vec![
441            YamlValue::String("one".to_string()),
442            YamlValue::Number(serde_yaml::Number::from(2i64)),
443        ];
444        let mut config = Config::new();
445        flatten_yaml_sequence("mixed", &seq, &mut config).unwrap();
446        assert!(config.contains("mixed"));
447    }
448
449    #[test]
450    fn test_flatten_yaml_sequence_mixed_nested_value_returns_error() {
451        let seq = vec![
452            YamlValue::Number(serde_yaml::Number::from(1i64)),
453            YamlValue::Mapping(serde_yaml::Mapping::new()),
454        ];
455        let mut config = Config::new();
456        flatten_yaml_sequence("mixed", &seq, &mut config)
457            .expect_err("mixed YAML sequence with a nested value should fail");
458    }
459
460    #[test]
461    fn test_flatten_yaml_sequence_nested_mapping_returns_error() {
462        let seq = vec![YamlValue::Mapping(serde_yaml::Mapping::new())];
463        let mut config = Config::new();
464        flatten_yaml_sequence("nested", &seq, &mut config)
465            .expect_err("nested YAML mapping should fail sequence flattening");
466    }
467
468    #[test]
469    fn test_flatten_yaml_sequence_nested_sequence_returns_error() {
470        let seq = vec![YamlValue::Sequence(vec![YamlValue::Bool(true)])];
471        let mut config = Config::new();
472        flatten_yaml_sequence("nested", &seq, &mut config)
473            .expect_err("nested YAML sequence should fail sequence flattening");
474    }
475
476    #[test]
477    fn test_flatten_yaml_value_tagged() {
478        use serde_yaml::value::Tag;
479        use serde_yaml::value::TaggedValue;
480        let tagged = YamlValue::Tagged(Box::new(TaggedValue {
481            tag: Tag::new("!!str"),
482            value: YamlValue::String("hello".to_string()),
483        }));
484        let mut config = Config::new();
485        flatten_yaml_value("key", &tagged, &mut config).unwrap();
486        assert_eq!(config.get_string("key").unwrap(), "hello");
487    }
488
489    #[test]
490    fn test_flatten_yaml_value_number_no_i64() {
491        // A very large float that can't be represented as i64
492        let num = serde_yaml::Number::from(f64::MAX);
493        let val = YamlValue::Number(num);
494        let mut config = Config::new();
495        flatten_yaml_value("key", &val, &mut config).unwrap();
496        assert!(config.contains("key"));
497    }
498
499    #[test]
500    fn test_flatten_yaml_scalar_respects_final_property() {
501        let cases = [
502            YamlValue::Null,
503            YamlValue::Bool(true),
504            YamlValue::Number(serde_yaml::Number::from(1i64)),
505            YamlValue::Number(serde_yaml::Number::from(1.5f64)),
506            YamlValue::String("value".to_string()),
507            YamlValue::Tagged(Box::new(serde_yaml::value::TaggedValue {
508                tag: serde_yaml::value::Tag::new("!!str"),
509                value: YamlValue::String("tagged".to_string()),
510            })),
511        ];
512
513        for value in cases {
514            let mut config = config_with_final_property("locked");
515            expect_final_error(flatten_yaml_value("locked", &value, &mut config), "locked");
516        }
517    }
518
519    #[test]
520    fn test_flatten_yaml_sequence_respects_final_property() {
521        let cases = [
522            Vec::new(),
523            vec![
524                YamlValue::Number(serde_yaml::Number::from(1i64)),
525                YamlValue::Number(serde_yaml::Number::from(2i64)),
526            ],
527            vec![
528                YamlValue::Number(serde_yaml::Number::from(1.5f64)),
529                YamlValue::Number(serde_yaml::Number::from(2.5f64)),
530            ],
531            vec![YamlValue::Bool(true), YamlValue::Bool(false)],
532            vec![
533                YamlValue::String("one".to_string()),
534                YamlValue::String("two".to_string()),
535            ],
536            vec![
537                YamlValue::Number(serde_yaml::Number::from(1i64)),
538                YamlValue::Null,
539            ],
540        ];
541
542        for values in cases {
543            let mut config = config_with_final_property("locked");
544            expect_final_error(
545                flatten_yaml_sequence("locked", &values, &mut config),
546                "locked",
547            );
548        }
549    }
550
551    #[test]
552    fn test_unsupported_yaml_sequence_element_error_uses_root_label() {
553        let seq = vec![YamlValue::Mapping(serde_yaml::Mapping::new())];
554        let mut config = Config::new();
555        let err = flatten_yaml_sequence("", &seq, &mut config)
556            .expect_err("nested YAML sequence at root should be rejected");
557        assert!(err.to_string().contains("<root>"));
558    }
559}