Skip to main content

qubit_config/source/
toml_config_source.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2025 - 2026.
4 *    Haixing Hu, Qubit Co. Ltd.
5 *
6 *    All rights reserved.
7 *
8 ******************************************************************************/
9//! # TOML File Configuration Source
10//!
11//! Loads configuration from TOML format files.
12//!
13//! # Flattening Strategy
14//!
15//! Nested TOML tables are flattened using dot-separated keys.
16//! For example:
17//!
18//! ```toml
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 toml::{Table as TomlTable, Value as TomlValue};
35
36use crate::{Config, ConfigError, ConfigResult};
37
38use super::ConfigSource;
39
40/// Configuration source that loads from TOML format files
41///
42/// # Examples
43///
44/// ```rust,ignore
45/// use qubit_config::source::{TomlConfigSource, ConfigSource};
46/// use qubit_config::Config;
47///
48/// let source = TomlConfigSource::from_file("config.toml");
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 TomlConfigSource {
58    path: PathBuf,
59}
60
61impl TomlConfigSource {
62    /// Creates a new `TomlConfigSource` from a file path
63    ///
64    /// # Parameters
65    ///
66    /// * `path` - Path to the TOML 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 TomlConfigSource {
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 TOML file '{}': {}", self.path.display(), e),
81            ))
82        })?;
83
84        let table: TomlTable = content.parse().map_err(|e| {
85            ConfigError::ParseError(format!(
86                "Failed to parse TOML file '{}': {}",
87                self.path.display(),
88                e
89            ))
90        })?;
91
92        flatten_toml_value("", &TomlValue::Table(table), config)
93    }
94}
95
96/// Recursively flattens a TOML value into the config using dot-separated keys.
97///
98/// Scalar types are stored with their native types (integer → i64, float → f64,
99/// bool → bool, null/empty → empty property). String and datetime values are
100/// stored as `String`.
101pub(crate) fn flatten_toml_value(
102    prefix: &str,
103    value: &TomlValue,
104    config: &mut Config,
105) -> ConfigResult<()> {
106    match value {
107        TomlValue::Table(table) => {
108            for (k, v) in table {
109                let key = if prefix.is_empty() {
110                    k.clone()
111                } else {
112                    format!("{}.{}", prefix, k)
113                };
114                flatten_toml_value(&key, v, config)?;
115            }
116        }
117        TomlValue::Array(arr) => {
118            // Detect the element type of the first non-table/non-array item.
119            // All elements must be the same scalar type; mixed-type arrays fall
120            // back to string representation to avoid silent data loss.
121            flatten_toml_array(prefix, arr, config)?;
122        }
123        TomlValue::String(s) => {
124            config.set(prefix, s.clone())?;
125        }
126        TomlValue::Integer(i) => {
127            config.set(prefix, *i)?;
128        }
129        TomlValue::Float(f) => {
130            config.set(prefix, *f)?;
131        }
132        TomlValue::Boolean(b) => {
133            config.set(prefix, *b)?;
134        }
135        TomlValue::Datetime(dt) => {
136            config.set(prefix, dt.to_string())?;
137        }
138    }
139    Ok(())
140}
141
142/// Flattens a TOML array into multi-value config entries.
143///
144/// Homogeneous scalar arrays are stored with their native types.
145/// Mixed or nested arrays fall back to string representation.
146fn flatten_toml_array(prefix: &str, arr: &[TomlValue], config: &mut Config) -> ConfigResult<()> {
147    if arr.is_empty() {
148        return Ok(());
149    }
150
151    // Determine the dominant scalar type from the first element.
152    enum ArrayKind {
153        Integer,
154        Float,
155        Bool,
156        String,
157    }
158
159    let kind = match &arr[0] {
160        TomlValue::Integer(_) => ArrayKind::Integer,
161        TomlValue::Float(_) => ArrayKind::Float,
162        TomlValue::Boolean(_) => ArrayKind::Bool,
163        TomlValue::Table(_) => {
164            return Err(ConfigError::ParseError(format!(
165                "Unsupported nested TOML table inside array at key '{prefix}'"
166            )));
167        }
168        TomlValue::Array(_) => {
169            return Err(ConfigError::ParseError(format!(
170                "Unsupported nested TOML array at key '{prefix}'"
171            )));
172        }
173        _ => ArrayKind::String,
174    };
175
176    // Check that all elements match the first element's type; fall back to string if not.
177    let all_same = arr.iter().all(|item| {
178        matches!(
179            (&kind, item),
180            (ArrayKind::Integer, TomlValue::Integer(_))
181                | (ArrayKind::Float, TomlValue::Float(_))
182                | (ArrayKind::Bool, TomlValue::Boolean(_))
183                | (
184                    ArrayKind::String,
185                    TomlValue::String(_) | TomlValue::Datetime(_)
186                )
187        )
188    });
189
190    if !all_same {
191        // Mixed types → fall back to string
192        for item in arr {
193            config.add(prefix, toml_scalar_to_string(item, prefix)?)?;
194        }
195        return Ok(());
196    }
197
198    match kind {
199        ArrayKind::Integer => {
200            for item in arr {
201                if let TomlValue::Integer(i) = item {
202                    config.add(prefix, *i)?;
203                }
204            }
205        }
206        ArrayKind::Float => {
207            for item in arr {
208                if let TomlValue::Float(f) = item {
209                    config.add(prefix, *f)?;
210                }
211            }
212        }
213        ArrayKind::Bool => {
214            for item in arr {
215                if let TomlValue::Boolean(b) = item {
216                    config.add(prefix, *b)?;
217                }
218            }
219        }
220        ArrayKind::String => {
221            for item in arr {
222                config.add(prefix, toml_scalar_to_string(item, prefix)?)?;
223            }
224        }
225    }
226
227    Ok(())
228}
229
230/// Converts a TOML scalar value to a string (used as fallback for mixed arrays)
231fn toml_scalar_to_string(value: &TomlValue, key: &str) -> ConfigResult<String> {
232    match value {
233        TomlValue::String(s) => Ok(s.clone()),
234        TomlValue::Integer(i) => Ok(i.to_string()),
235        TomlValue::Float(f) => Ok(f.to_string()),
236        TomlValue::Boolean(b) => Ok(b.to_string()),
237        TomlValue::Datetime(dt) => Ok(dt.to_string()),
238        TomlValue::Array(_) | TomlValue::Table(_) => {
239            let key = if key.is_empty() { "<root>" } else { key };
240            Err(ConfigError::ParseError(format!(
241                "Unsupported nested TOML structure at key '{}'",
242                key
243            )))
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_toml_scalar_to_string_float() {
254        let val = TomlValue::Float(1.5);
255        assert_eq!(toml_scalar_to_string(&val, "key").unwrap(), "1.5");
256    }
257
258    #[test]
259    fn test_toml_scalar_to_string_bool() {
260        let val = TomlValue::Boolean(true);
261        assert_eq!(toml_scalar_to_string(&val, "key").unwrap(), "true");
262    }
263
264    #[test]
265    fn test_toml_scalar_to_string_nested_array_empty_key() {
266        let val = TomlValue::Array(vec![]);
267        let result = toml_scalar_to_string(&val, "");
268        assert!(result.is_err());
269        let msg = format!("{}", result.unwrap_err());
270        assert!(msg.contains("<root>"));
271    }
272
273    #[test]
274    fn test_toml_scalar_to_string_nested_table_with_key() {
275        let val = TomlValue::Table(toml::Table::new());
276        let result = toml_scalar_to_string(&val, "my.key");
277        assert!(result.is_err());
278        let msg = format!("{}", result.unwrap_err());
279        assert!(msg.contains("my.key"));
280    }
281
282    #[test]
283    fn test_flatten_toml_array_mixed_int_string_fallback() {
284        // Build a mixed array manually: first element is Integer, second is String
285        // This tests the all_same=false branch
286        let arr = vec![TomlValue::Integer(1), TomlValue::String("two".to_string())];
287        let mut config = Config::new();
288        flatten_toml_array("mixed", &arr, &mut config).unwrap();
289        // Should fall back to string representation
290        let vals: Vec<String> = config.get_list("mixed").unwrap();
291        assert_eq!(vals.len(), 2);
292    }
293
294    #[test]
295    fn test_flatten_toml_array_mixed_float_string_fallback() {
296        let arr = vec![TomlValue::Float(1.5), TomlValue::String("two".to_string())];
297        let mut config = Config::new();
298        flatten_toml_array("mixed", &arr, &mut config).unwrap();
299        let vals: Vec<String> = config.get_list("mixed").unwrap();
300        assert_eq!(vals.len(), 2);
301    }
302
303    #[test]
304    fn test_flatten_toml_array_mixed_bool_string_fallback() {
305        let arr = vec![
306            TomlValue::Boolean(true),
307            TomlValue::String("two".to_string()),
308        ];
309        let mut config = Config::new();
310        flatten_toml_array("mixed", &arr, &mut config).unwrap();
311        let vals: Vec<String> = config.get_list("mixed").unwrap();
312        assert_eq!(vals.len(), 2);
313    }
314}