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