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,ignore
45/// use qubit_config::source::{YamlConfigSource, ConfigSource};
46/// use qubit_config::Config;
47///
48/// let source = YamlConfigSource::from_file("config.yaml");
49/// let mut config = Config::new();
50/// source.load(&mut config).unwrap();
51/// ```
52///
53/// # Author
54///
55/// Haixing Hu
56#[derive(Debug, Clone)]
57pub struct YamlConfigSource {
58    path: PathBuf,
59}
60
61impl YamlConfigSource {
62    /// Creates a new `YamlConfigSource` from a file path
63    ///
64    /// # Parameters
65    ///
66    /// * `path` - Path to the YAML file
67    #[inline]
68    pub fn from_file<P: AsRef<Path>>(path: P) -> Self {
69        Self {
70            path: path.as_ref().to_path_buf(),
71        }
72    }
73}
74
75impl ConfigSource for YamlConfigSource {
76    fn load(&self, config: &mut Config) -> ConfigResult<()> {
77        let content = std::fs::read_to_string(&self.path).map_err(|e| {
78            ConfigError::IoError(std::io::Error::new(
79                e.kind(),
80                format!("Failed to read YAML file '{}': {}", self.path.display(), e),
81            ))
82        })?;
83
84        let value: YamlValue = serde_yaml::from_str(&content).map_err(|e| {
85            ConfigError::ParseError(format!(
86                "Failed to parse YAML file '{}': {}",
87                self.path.display(),
88                e
89            ))
90        })?;
91
92        flatten_yaml_value("", &value, config)
93    }
94}
95
96/// Recursively flattens a YAML value into the config using dot-separated keys.
97///
98/// Scalar types are stored with their native types where possible:
99/// - Integer numbers → i64
100/// - Floating-point numbers → f64
101/// - Booleans → bool
102/// - Strings → String
103/// - Null → empty property (is_null returns true)
104pub(crate) fn flatten_yaml_value(
105    prefix: &str,
106    value: &YamlValue,
107    config: &mut Config,
108) -> ConfigResult<()> {
109    match value {
110        YamlValue::Mapping(map) => {
111            for (k, v) in map {
112                let key_str = yaml_key_to_string(k)?;
113                let key = if prefix.is_empty() {
114                    key_str
115                } else {
116                    format!("{}.{}", prefix, key_str)
117                };
118                flatten_yaml_value(&key, v, config)?;
119            }
120        }
121        YamlValue::Sequence(seq) => {
122            flatten_yaml_sequence(prefix, seq, config)?;
123        }
124        YamlValue::Null => {
125            // Null values are stored as empty properties to preserve null semantics.
126            // Use properties_mut() to insert an empty property directly.
127            use crate::Property;
128            use qubit_common::DataType;
129            use qubit_value::MultiValues;
130            config
131                .properties_mut()
132                .entry(prefix.to_string())
133                .or_insert_with(|| {
134                    Property::with_value(prefix, MultiValues::Empty(DataType::String))
135                });
136        }
137        YamlValue::Bool(b) => {
138            config.set(prefix, *b)?;
139        }
140        YamlValue::Number(n) => {
141            if let Some(i) = n.as_i64() {
142                config.set(prefix, i)?;
143            } else if let Some(f) = n.as_f64() {
144                config.set(prefix, f)?;
145            } else {
146                config.set(prefix, n.to_string())?;
147            }
148        }
149        YamlValue::String(s) => {
150            config.set(prefix, s.clone())?;
151        }
152        YamlValue::Tagged(tagged) => {
153            flatten_yaml_value(prefix, &tagged.value, config)?;
154        }
155    }
156    Ok(())
157}
158
159/// Flattens a YAML sequence into multi-value config entries.
160///
161/// Homogeneous scalar sequences are stored with their native types.
162/// Mixed or nested sequences fall back to string representation.
163fn flatten_yaml_sequence(prefix: &str, seq: &[YamlValue], config: &mut Config) -> ConfigResult<()> {
164    if seq.is_empty() {
165        return Ok(());
166    }
167
168    enum SeqKind {
169        Integer,
170        Float,
171        Bool,
172        String,
173    }
174
175    let kind = match &seq[0] {
176        YamlValue::Number(n) if n.is_i64() => SeqKind::Integer,
177        YamlValue::Number(_) => SeqKind::Float,
178        YamlValue::Bool(_) => SeqKind::Bool,
179        YamlValue::Mapping(_) | YamlValue::Sequence(_) => {
180            // Nested structures: fall back to string
181            for item in seq {
182                config.add(prefix, yaml_scalar_to_string(item))?;
183            }
184            return Ok(());
185        }
186        _ => SeqKind::String,
187    };
188
189    let all_same = seq.iter().all(|item| match (&kind, item) {
190        (SeqKind::Integer, YamlValue::Number(n)) => n.is_i64(),
191        (SeqKind::Float, YamlValue::Number(_)) => true,
192        (SeqKind::Bool, YamlValue::Bool(_)) => true,
193        (SeqKind::String, YamlValue::String(_)) => true,
194        _ => false,
195    });
196
197    if !all_same {
198        for item in seq {
199            config.add(prefix, yaml_scalar_to_string(item))?;
200        }
201        return Ok(());
202    }
203
204    match kind {
205        SeqKind::Integer => {
206            for item in seq {
207                if let YamlValue::Number(n) = item {
208                    if let Some(i) = n.as_i64() {
209                        config.add(prefix, i)?;
210                    }
211                }
212            }
213        }
214        SeqKind::Float => {
215            for item in seq {
216                if let YamlValue::Number(n) = item {
217                    if let Some(f) = n.as_f64() {
218                        config.add(prefix, f)?;
219                    }
220                }
221            }
222        }
223        SeqKind::Bool => {
224            for item in seq {
225                if let YamlValue::Bool(b) = item {
226                    config.add(prefix, *b)?;
227                }
228            }
229        }
230        SeqKind::String => {
231            for item in seq {
232                config.add(prefix, yaml_scalar_to_string(item))?;
233            }
234        }
235    }
236
237    Ok(())
238}
239
240/// Converts a YAML key to a string
241fn yaml_key_to_string(value: &YamlValue) -> ConfigResult<String> {
242    match value {
243        YamlValue::String(s) => Ok(s.clone()),
244        YamlValue::Number(n) => Ok(n.to_string()),
245        YamlValue::Bool(b) => Ok(b.to_string()),
246        YamlValue::Null => Ok("null".to_string()),
247        _ => Err(ConfigError::ParseError(format!(
248            "Unsupported YAML mapping key type: {value:?}"
249        ))),
250    }
251}
252
253/// Converts a YAML scalar value to a string (fallback for mixed-type sequences)
254fn yaml_scalar_to_string(value: &YamlValue) -> String {
255    match value {
256        YamlValue::String(s) => s.clone(),
257        YamlValue::Number(n) => n.to_string(),
258        YamlValue::Bool(b) => b.to_string(),
259        YamlValue::Null => String::new(),
260        YamlValue::Sequence(_) | YamlValue::Mapping(_) | YamlValue::Tagged(_) => String::new(),
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn test_yaml_key_to_string_number() {
270        let key = YamlValue::Number(serde_yaml::Number::from(42));
271        assert_eq!(yaml_key_to_string(&key).unwrap(), "42");
272    }
273
274    #[test]
275    fn test_yaml_key_to_string_bool() {
276        let key = YamlValue::Bool(true);
277        assert_eq!(yaml_key_to_string(&key).unwrap(), "true");
278    }
279
280    #[test]
281    fn test_yaml_key_to_string_null() {
282        let key = YamlValue::Null;
283        assert_eq!(yaml_key_to_string(&key).unwrap(), "null");
284    }
285
286    #[test]
287    fn test_yaml_scalar_to_string_bool() {
288        assert_eq!(yaml_scalar_to_string(&YamlValue::Bool(false)), "false");
289    }
290
291    #[test]
292    fn test_yaml_scalar_to_string_null() {
293        assert_eq!(yaml_scalar_to_string(&YamlValue::Null), "");
294    }
295
296    #[test]
297    fn test_yaml_scalar_to_string_sequence() {
298        assert_eq!(yaml_scalar_to_string(&YamlValue::Sequence(vec![])), "");
299    }
300
301    #[test]
302    fn test_yaml_scalar_to_string_mapping() {
303        assert_eq!(
304            yaml_scalar_to_string(&YamlValue::Mapping(serde_yaml::Mapping::new())),
305            ""
306        );
307    }
308
309    #[test]
310    fn test_flatten_yaml_sequence_mixed_int_null_fallback() {
311        // Mixed: int + null → falls back to string
312        let seq = vec![
313            YamlValue::Number(serde_yaml::Number::from(1i64)),
314            YamlValue::Null,
315        ];
316        let mut config = Config::new();
317        flatten_yaml_sequence("mixed", &seq, &mut config).unwrap();
318        // Should fall back to string representation
319        assert!(config.contains("mixed"));
320    }
321
322    #[test]
323    fn test_flatten_yaml_sequence_mixed_float_string_fallback() {
324        // Mixed: float + string → falls back to string
325        let seq = vec![
326            YamlValue::Number(serde_yaml::Number::from(1.5f64)),
327            YamlValue::String("two".to_string()),
328        ];
329        let mut config = Config::new();
330        flatten_yaml_sequence("mixed", &seq, &mut config).unwrap();
331        assert!(config.contains("mixed"));
332    }
333
334    #[test]
335    fn test_flatten_yaml_sequence_mixed_bool_string_fallback() {
336        // Mixed: bool + string → falls back to string
337        let seq = vec![YamlValue::Bool(true), YamlValue::String("two".to_string())];
338        let mut config = Config::new();
339        flatten_yaml_sequence("mixed", &seq, &mut config).unwrap();
340        assert!(config.contains("mixed"));
341    }
342
343    #[test]
344    fn test_flatten_yaml_sequence_mixed_string_int_fallback() {
345        // Mixed: string + int → falls back to string
346        let seq = vec![
347            YamlValue::String("one".to_string()),
348            YamlValue::Number(serde_yaml::Number::from(2i64)),
349        ];
350        let mut config = Config::new();
351        flatten_yaml_sequence("mixed", &seq, &mut config).unwrap();
352        assert!(config.contains("mixed"));
353    }
354
355    #[test]
356    fn test_flatten_yaml_value_tagged() {
357        use serde_yaml::value::Tag;
358        use serde_yaml::value::TaggedValue;
359        let tagged = YamlValue::Tagged(Box::new(TaggedValue {
360            tag: Tag::new("!!str"),
361            value: YamlValue::String("hello".to_string()),
362        }));
363        let mut config = Config::new();
364        flatten_yaml_value("key", &tagged, &mut config).unwrap();
365        assert_eq!(config.get_string("key").unwrap(), "hello");
366    }
367
368    #[test]
369    fn test_flatten_yaml_value_number_no_i64() {
370        // A very large float that can't be represented as i64
371        let num = serde_yaml::Number::from(f64::MAX);
372        let val = YamlValue::Number(num);
373        let mut config = Config::new();
374        flatten_yaml_value("key", &val, &mut config).unwrap();
375        assert!(config.contains("key"));
376    }
377}