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. Empty arrays
149/// are stored as explicit empty string lists because TOML carries no element
150/// type for them. Mixed or nested arrays fall back to string representation.
151fn flatten_toml_array(prefix: &str, arr: &[TomlValue], config: &mut Config) -> ConfigResult<()> {
152    if arr.is_empty() {
153        config.set(prefix, Vec::<String>::new())?;
154        return Ok(());
155    }
156
157    // Determine the dominant scalar type from the first element.
158    enum ArrayKind {
159        Integer,
160        Float,
161        Bool,
162        String,
163    }
164
165    let kind = match &arr[0] {
166        TomlValue::Integer(_) => ArrayKind::Integer,
167        TomlValue::Float(_) => ArrayKind::Float,
168        TomlValue::Boolean(_) => ArrayKind::Bool,
169        TomlValue::Table(_) => {
170            return Err(ConfigError::ParseError(format!(
171                "Unsupported nested TOML table inside array at key '{prefix}'"
172            )));
173        }
174        TomlValue::Array(_) => {
175            return Err(ConfigError::ParseError(format!(
176                "Unsupported nested TOML array at key '{prefix}'"
177            )));
178        }
179        _ => ArrayKind::String,
180    };
181
182    // Check that all elements match the first element's type; fall back to string if not.
183    let all_same = arr.iter().all(|item| {
184        matches!(
185            (&kind, item),
186            (ArrayKind::Integer, TomlValue::Integer(_))
187                | (ArrayKind::Float, TomlValue::Float(_))
188                | (ArrayKind::Bool, TomlValue::Boolean(_))
189                | (
190                    ArrayKind::String,
191                    TomlValue::String(_) | TomlValue::Datetime(_)
192                )
193        )
194    });
195
196    if !all_same {
197        // Mixed types → fall back to string
198        let values = arr
199            .iter()
200            .map(|item| toml_scalar_to_string(item, prefix))
201            .collect::<ConfigResult<Vec<_>>>()?;
202        config.set(prefix, values)?;
203        return Ok(());
204    }
205
206    match kind {
207        ArrayKind::Integer => {
208            let values = arr
209                .iter()
210                .map(|item| {
211                    item.as_integer()
212                        .expect("TOML integer array was validated before insertion")
213                })
214                .collect::<Vec<_>>();
215            config.set(prefix, values)?;
216        }
217        ArrayKind::Float => {
218            let values = arr
219                .iter()
220                .map(|item| {
221                    item.as_float()
222                        .expect("TOML float array was validated before insertion")
223                })
224                .collect::<Vec<_>>();
225            config.set(prefix, values)?;
226        }
227        ArrayKind::Bool => {
228            let values = arr
229                .iter()
230                .map(|item| {
231                    item.as_bool()
232                        .expect("TOML bool array was validated before insertion")
233                })
234                .collect::<Vec<_>>();
235            config.set(prefix, values)?;
236        }
237        ArrayKind::String => {
238            let values = arr
239                .iter()
240                .map(|item| {
241                    toml_scalar_to_string(item, prefix)
242                        .expect("TOML string array was validated before insertion")
243                })
244                .collect::<Vec<_>>();
245            config.set(prefix, values)?;
246        }
247    }
248
249    Ok(())
250}
251
252/// Converts a TOML scalar value to a string (used as fallback for mixed arrays)
253fn toml_scalar_to_string(value: &TomlValue, key: &str) -> ConfigResult<String> {
254    match value {
255        TomlValue::String(s) => Ok(s.clone()),
256        TomlValue::Integer(i) => Ok(i.to_string()),
257        TomlValue::Float(f) => Ok(f.to_string()),
258        TomlValue::Boolean(b) => Ok(b.to_string()),
259        TomlValue::Datetime(dt) => Ok(dt.to_string()),
260        TomlValue::Array(_) | TomlValue::Table(_) => {
261            let key = if key.is_empty() { "<root>" } else { key };
262            Err(ConfigError::ParseError(format!(
263                "Unsupported nested TOML structure at key '{}'",
264                key
265            )))
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::Property;
274    use qubit_value::MultiValues;
275
276    fn config_with_final_property(name: &str) -> Config {
277        let mut config = Config::new();
278        let mut property = Property::with_value(name, MultiValues::String(vec!["old".to_string()]));
279        property.set_final(true);
280        config.insert_property(name, property).unwrap();
281        config
282    }
283
284    fn expect_final_error(result: ConfigResult<()>, name: &str) {
285        let err = result.expect_err("writing a final TOML property should fail");
286        assert_eq!(
287            err.to_string(),
288            format!("Property '{name}' is final and cannot be overridden"),
289        );
290    }
291
292    #[test]
293    fn test_toml_scalar_to_string_float() {
294        let val = TomlValue::Float(1.5);
295        assert_eq!(toml_scalar_to_string(&val, "key").unwrap(), "1.5");
296    }
297
298    #[test]
299    fn test_toml_scalar_to_string_bool() {
300        let val = TomlValue::Boolean(true);
301        assert_eq!(toml_scalar_to_string(&val, "key").unwrap(), "true");
302    }
303
304    #[test]
305    fn test_toml_scalar_to_string_datetime() {
306        let val = TomlValue::Datetime(
307            "1979-05-27T07:32:00Z"
308                .parse()
309                .expect("test TOML datetime should parse"),
310        );
311        assert_eq!(
312            toml_scalar_to_string(&val, "key").unwrap(),
313            "1979-05-27T07:32:00Z",
314        );
315    }
316
317    #[test]
318    fn test_toml_scalar_to_string_nested_array_empty_key() {
319        let val = TomlValue::Array(vec![]);
320        let result = toml_scalar_to_string(&val, "");
321        assert!(result.is_err());
322        let msg = format!("{}", result.unwrap_err());
323        assert!(msg.contains("<root>"));
324    }
325
326    #[test]
327    fn test_toml_scalar_to_string_nested_table_with_key() {
328        let val = TomlValue::Table(toml::Table::new());
329        let result = toml_scalar_to_string(&val, "my.key");
330        assert!(result.is_err());
331        let msg = format!("{}", result.unwrap_err());
332        assert!(msg.contains("my.key"));
333    }
334
335    #[test]
336    fn test_flatten_toml_array_mixed_int_string_fallback() {
337        // Build a mixed array manually: first element is Integer, second is String
338        // This tests the all_same=false branch
339        let arr = vec![TomlValue::Integer(1), TomlValue::String("two".to_string())];
340        let mut config = Config::new();
341        flatten_toml_array("mixed", &arr, &mut config).unwrap();
342        // Should fall back to string representation
343        let vals: Vec<String> = config.get_list("mixed").unwrap();
344        assert_eq!(vals.len(), 2);
345    }
346
347    #[test]
348    fn test_flatten_toml_array_mixed_float_string_fallback() {
349        let arr = vec![TomlValue::Float(1.5), TomlValue::String("two".to_string())];
350        let mut config = Config::new();
351        flatten_toml_array("mixed", &arr, &mut config).unwrap();
352        let vals: Vec<String> = config.get_list("mixed").unwrap();
353        assert_eq!(vals.len(), 2);
354    }
355
356    #[test]
357    fn test_flatten_toml_array_mixed_bool_string_fallback() {
358        let arr = vec![
359            TomlValue::Boolean(true),
360            TomlValue::String("two".to_string()),
361        ];
362        let mut config = Config::new();
363        flatten_toml_array("mixed", &arr, &mut config).unwrap();
364        let vals: Vec<String> = config.get_list("mixed").unwrap();
365        assert_eq!(vals.len(), 2);
366    }
367
368    #[test]
369    fn test_flatten_toml_array_mixed_nested_value_returns_error() {
370        let arr = vec![TomlValue::Integer(1), TomlValue::Array(vec![])];
371        let mut config = Config::new();
372        flatten_toml_array("mixed", &arr, &mut config)
373            .expect_err("mixed TOML array with a nested value should fail");
374    }
375
376    #[test]
377    fn test_flatten_toml_scalar_respects_final_property() {
378        let datetime = "1979-05-27T07:32:00Z"
379            .parse()
380            .expect("test TOML datetime should parse");
381        let cases = [
382            TomlValue::Integer(1),
383            TomlValue::Float(1.5),
384            TomlValue::Boolean(true),
385            TomlValue::Datetime(datetime),
386        ];
387
388        for value in cases {
389            let mut config = config_with_final_property("locked");
390            expect_final_error(flatten_toml_value("locked", &value, &mut config), "locked");
391        }
392    }
393
394    #[test]
395    fn test_flatten_toml_array_respects_final_property() {
396        let cases = [
397            Vec::new(),
398            vec![TomlValue::Integer(1), TomlValue::Integer(2)],
399            vec![TomlValue::Float(1.5), TomlValue::Float(2.5)],
400            vec![TomlValue::Boolean(true), TomlValue::Boolean(false)],
401            vec![
402                TomlValue::String("one".to_string()),
403                TomlValue::String("two".to_string()),
404            ],
405            vec![TomlValue::Integer(1), TomlValue::String("two".to_string())],
406        ];
407
408        for values in cases {
409            let mut config = config_with_final_property("locked");
410            expect_final_error(flatten_toml_array("locked", &values, &mut config), "locked");
411        }
412    }
413}