Skip to main content

qubit_config/source/
toml_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//! # TOML File Configuration Source
11//!
12//! Loads configuration from TOML format files.
13//!
14//! # Flattening Strategy
15//!
16//! Nested TOML tables are flattened using dot-separated keys.
17//! For example:
18//!
19//! ```toml
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 toml::{Table as TomlTable, Value as TomlValue};
33
34use crate::{Config, ConfigError, ConfigResult};
35
36use super::ConfigSource;
37
38/// Configuration source that loads from TOML format files
39///
40/// # Examples
41///
42/// ```rust
43/// use qubit_config::source::{TomlConfigSource, ConfigSource};
44/// use qubit_config::Config;
45///
46/// let temp_dir = tempfile::tempdir().unwrap();
47/// let path = temp_dir.path().join("config.toml");
48/// std::fs::write(&path, "server.port = 8080\n").unwrap();
49/// let source = TomlConfigSource::from_file(path);
50/// let mut config = Config::new();
51/// source.load(&mut config).unwrap();
52/// assert_eq!(config.get::<i64>("server.port").unwrap(), 8080);
53/// ```
54///
55#[derive(Debug, Clone)]
56pub struct TomlConfigSource {
57    path: PathBuf,
58}
59
60impl TomlConfigSource {
61    /// Creates a new `TomlConfigSource` from a file path
62    ///
63    /// # Parameters
64    ///
65    /// * `path` - Path to the TOML file
66    #[inline]
67    pub fn from_file<P: AsRef<Path>>(path: P) -> Self {
68        Self {
69            path: path.as_ref().to_path_buf(),
70        }
71    }
72}
73
74impl ConfigSource for TomlConfigSource {
75    fn load(&self, config: &mut Config) -> ConfigResult<()> {
76        let content = std::fs::read_to_string(&self.path).map_err(|e| {
77            ConfigError::IoError(std::io::Error::new(
78                e.kind(),
79                format!("Failed to read TOML file '{}': {}", self.path.display(), e),
80            ))
81        })?;
82
83        let table: TomlTable = content.parse().map_err(|e| {
84            ConfigError::ParseError(format!(
85                "Failed to parse TOML file '{}': {}",
86                self.path.display(),
87                e
88            ))
89        })?;
90
91        flatten_toml_value("", &TomlValue::Table(table), config)
92    }
93}
94
95/// Recursively flattens a TOML value into the config using dot-separated keys.
96///
97/// Scalar types are stored with their native types (integer → i64, float → f64,
98/// bool → bool, null/empty → empty property). String and datetime values are
99/// stored as `String`.
100pub(crate) fn flatten_toml_value(
101    prefix: &str,
102    value: &TomlValue,
103    config: &mut Config,
104) -> ConfigResult<()> {
105    match value {
106        TomlValue::Table(table) => {
107            for (k, v) in table {
108                let key = if prefix.is_empty() {
109                    k.clone()
110                } else {
111                    format!("{}.{}", prefix, k)
112                };
113                flatten_toml_value(&key, v, config)?;
114            }
115        }
116        TomlValue::Array(arr) => {
117            // Detect the element type of the first non-table/non-array item.
118            // All elements must be the same scalar type; mixed-type arrays fall
119            // back to string representation to avoid silent data loss.
120            flatten_toml_array(prefix, arr, config)?;
121        }
122        TomlValue::String(s) => {
123            config.set(prefix, s.clone())?;
124        }
125        TomlValue::Integer(i) => {
126            config.set(prefix, *i)?;
127        }
128        TomlValue::Float(f) => {
129            config.set(prefix, *f)?;
130        }
131        TomlValue::Boolean(b) => {
132            config.set(prefix, *b)?;
133        }
134        TomlValue::Datetime(dt) => {
135            config.set(prefix, dt.to_string())?;
136        }
137    }
138    Ok(())
139}
140
141/// Flattens a TOML array into multi-value config entries.
142///
143/// Homogeneous scalar arrays are stored with their native types. Empty arrays
144/// are stored as explicit empty string lists because TOML carries no element
145/// type for them. 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        config.set(prefix, Vec::<String>::new())?;
149        return Ok(());
150    }
151
152    // Determine the dominant scalar type from the first element.
153    enum ArrayKind {
154        Integer,
155        Float,
156        Bool,
157        String,
158    }
159
160    let kind = match &arr[0] {
161        TomlValue::Integer(_) => ArrayKind::Integer,
162        TomlValue::Float(_) => ArrayKind::Float,
163        TomlValue::Boolean(_) => ArrayKind::Bool,
164        TomlValue::Table(_) => {
165            return Err(ConfigError::ParseError(format!(
166                "Unsupported nested TOML table inside array at key '{prefix}'"
167            )));
168        }
169        TomlValue::Array(_) => {
170            return Err(ConfigError::ParseError(format!(
171                "Unsupported nested TOML array at key '{prefix}'"
172            )));
173        }
174        _ => ArrayKind::String,
175    };
176
177    // Check that all elements match the first element's type; fall back to string if not.
178    let all_same = arr.iter().all(|item| {
179        matches!(
180            (&kind, item),
181            (ArrayKind::Integer, TomlValue::Integer(_))
182                | (ArrayKind::Float, TomlValue::Float(_))
183                | (ArrayKind::Bool, TomlValue::Boolean(_))
184                | (
185                    ArrayKind::String,
186                    TomlValue::String(_) | TomlValue::Datetime(_)
187                )
188        )
189    });
190
191    if !all_same {
192        // Mixed types → fall back to string
193        let values = arr
194            .iter()
195            .map(|item| toml_scalar_to_string(item, prefix))
196            .collect::<ConfigResult<Vec<_>>>()?;
197        config.set(prefix, values)?;
198        return Ok(());
199    }
200
201    match kind {
202        ArrayKind::Integer => {
203            let values = arr
204                .iter()
205                .map(|item| {
206                    item.as_integer()
207                        .expect("TOML integer array was validated before insertion")
208                })
209                .collect::<Vec<_>>();
210            config.set(prefix, values)?;
211        }
212        ArrayKind::Float => {
213            let values = arr
214                .iter()
215                .map(|item| {
216                    item.as_float()
217                        .expect("TOML float array was validated before insertion")
218                })
219                .collect::<Vec<_>>();
220            config.set(prefix, values)?;
221        }
222        ArrayKind::Bool => {
223            let values = arr
224                .iter()
225                .map(|item| {
226                    item.as_bool()
227                        .expect("TOML bool array was validated before insertion")
228                })
229                .collect::<Vec<_>>();
230            config.set(prefix, values)?;
231        }
232        ArrayKind::String => {
233            let values = arr
234                .iter()
235                .map(|item| {
236                    toml_scalar_to_string(item, prefix)
237                        .expect("TOML string array was validated before insertion")
238                })
239                .collect::<Vec<_>>();
240            config.set(prefix, values)?;
241        }
242    }
243
244    Ok(())
245}
246
247/// Converts a TOML scalar value to a string (used as fallback for mixed arrays)
248fn toml_scalar_to_string(value: &TomlValue, key: &str) -> ConfigResult<String> {
249    match value {
250        TomlValue::String(s) => Ok(s.clone()),
251        TomlValue::Integer(i) => Ok(i.to_string()),
252        TomlValue::Float(f) => Ok(f.to_string()),
253        TomlValue::Boolean(b) => Ok(b.to_string()),
254        TomlValue::Datetime(dt) => Ok(dt.to_string()),
255        TomlValue::Array(_) | TomlValue::Table(_) => {
256            let key = if key.is_empty() { "<root>" } else { key };
257            Err(ConfigError::ParseError(format!(
258                "Unsupported nested TOML structure at key '{}'",
259                key
260            )))
261        }
262    }
263}