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