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        let mut staged = config.clone();
92        flatten_toml_value("", &TomlValue::Table(table), &mut staged)?;
93        *config = staged;
94        Ok(())
95    }
96}
97
98/// Recursively flattens a TOML value into the config using dot-separated keys.
99///
100/// Scalar types are stored with their native types (integer → i64, float → f64,
101/// bool → bool, null/empty → empty property). String and datetime values are
102/// stored as `String`.
103pub(crate) fn flatten_toml_value(
104    prefix: &str,
105    value: &TomlValue,
106    config: &mut Config,
107) -> ConfigResult<()> {
108    match value {
109        TomlValue::Table(table) => {
110            for (k, v) in table {
111                let key = if prefix.is_empty() {
112                    k.clone()
113                } else {
114                    format!("{}.{}", prefix, k)
115                };
116                flatten_toml_value(&key, v, config)?;
117            }
118        }
119        TomlValue::Array(arr) => {
120            // Detect the element type of the first non-table/non-array item.
121            // All elements must be the same scalar type; mixed-type arrays fall
122            // back to string representation to avoid silent data loss.
123            flatten_toml_array(prefix, arr, config)?;
124        }
125        TomlValue::String(s) => {
126            config.set(prefix, s.clone())?;
127        }
128        TomlValue::Integer(i) => {
129            config.set(prefix, *i)?;
130        }
131        TomlValue::Float(f) => {
132            config.set(prefix, *f)?;
133        }
134        TomlValue::Boolean(b) => {
135            config.set(prefix, *b)?;
136        }
137        TomlValue::Datetime(dt) => {
138            config.set(prefix, dt.to_string())?;
139        }
140    }
141    Ok(())
142}
143
144/// Flattens a TOML array into multi-value config entries.
145///
146/// Homogeneous scalar arrays are stored with their native types. Empty arrays
147/// are stored as explicit empty string lists because TOML carries no element
148/// type for them. Mixed or nested arrays fall back to string representation.
149fn flatten_toml_array(prefix: &str, arr: &[TomlValue], config: &mut Config) -> ConfigResult<()> {
150    if arr.is_empty() {
151        config.set(prefix, Vec::<String>::new())?;
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        let values = arr
197            .iter()
198            .map(|item| toml_scalar_to_string(item, prefix))
199            .collect::<ConfigResult<Vec<_>>>()?;
200        config.set(prefix, values)?;
201        return Ok(());
202    }
203
204    match kind {
205        ArrayKind::Integer => {
206            let values = arr
207                .iter()
208                .map(|item| {
209                    item.as_integer()
210                        .expect("TOML integer array was validated before insertion")
211                })
212                .collect::<Vec<_>>();
213            config.set(prefix, values)?;
214        }
215        ArrayKind::Float => {
216            let values = arr
217                .iter()
218                .map(|item| {
219                    item.as_float()
220                        .expect("TOML float array was validated before insertion")
221                })
222                .collect::<Vec<_>>();
223            config.set(prefix, values)?;
224        }
225        ArrayKind::Bool => {
226            let values = arr
227                .iter()
228                .map(|item| {
229                    item.as_bool()
230                        .expect("TOML bool array was validated before insertion")
231                })
232                .collect::<Vec<_>>();
233            config.set(prefix, values)?;
234        }
235        ArrayKind::String => {
236            let values = arr
237                .iter()
238                .map(|item| {
239                    toml_scalar_to_string(item, prefix)
240                        .expect("TOML string array was validated before insertion")
241                })
242                .collect::<Vec<_>>();
243            config.set(prefix, values)?;
244        }
245    }
246
247    Ok(())
248}
249
250/// Converts a TOML scalar value to a string (used as fallback for mixed arrays)
251fn toml_scalar_to_string(value: &TomlValue, key: &str) -> ConfigResult<String> {
252    match value {
253        TomlValue::String(s) => Ok(s.clone()),
254        TomlValue::Integer(i) => Ok(i.to_string()),
255        TomlValue::Float(f) => Ok(f.to_string()),
256        TomlValue::Boolean(b) => Ok(b.to_string()),
257        TomlValue::Datetime(dt) => Ok(dt.to_string()),
258        TomlValue::Array(_) | TomlValue::Table(_) => {
259            let key = if key.is_empty() { "<root>" } else { key };
260            Err(ConfigError::ParseError(format!(
261                "Unsupported nested TOML structure at key '{}'",
262                key
263            )))
264        }
265    }
266}