use crate::error::{NomlError, Result};
use crate::parser::{parse, parse_from_file, Document};
use crate::schema::Schema;
use crate::value::Value;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Config {
document: Document,
values: Value,
source_path: Option<PathBuf>,
modified: bool,
}
#[derive(Debug, Default)]
pub struct ConfigBuilder {
allow_missing: bool,
defaults: BTreeMap<String, Value>,
validate: bool,
}
impl Config {
pub fn new() -> Self {
let empty_table = Value::empty_table();
let document = Document::new(crate::parser::AstNode::new(
crate::parser::ast::AstValue::Table {
entries: Vec::new(),
inline: false,
},
crate::parser::Span::new(0, 0, 1, 1, 1, 1),
));
Self {
values: empty_table,
document,
source_path: None,
modified: false,
}
}
pub fn from_string(content: &str) -> Result<Self> {
let document = parse(content)?;
let values = document.to_value()?;
Ok(Self {
document,
values,
source_path: None,
modified: false,
})
}
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
let document = parse_from_file(path)?;
let values = document.to_value()?;
Ok(Self {
document,
values,
source_path: Some(path.to_path_buf()),
modified: false,
})
}
pub fn builder() -> ConfigBuilder {
ConfigBuilder::default()
}
#[inline]
pub fn get(&self, key: &str) -> Option<&Value> {
self.values.get(key)
}
pub fn get_or<T>(&self, key: &str, _default: T) -> Result<&Value>
where
T: Into<Value>,
{
match self.get(key) {
Some(value) => Ok(value),
None => {
Err(NomlError::key_not_found(key))
}
}
}
pub fn get_or_insert<T>(&mut self, key: &str, default: T) -> Result<&Value>
where
T: Into<Value>,
{
if !self.values.contains_key(key) {
self.set(key, default.into())?;
}
self.values.get(key).ok_or_else(|| {
NomlError::validation(format!("Failed to get key '{key}' after insertion"))
})
}
pub fn set<T>(&mut self, key: &str, value: T) -> Result<()>
where
T: Into<Value>,
{
self.values.set(key, value.into())?;
self.modified = true;
Ok(())
}
pub fn remove(&mut self, key: &str) -> Result<Option<Value>> {
let result = self.values.remove(key)?;
if result.is_some() {
self.modified = true;
}
Ok(result)
}
pub fn contains_key(&self, key: &str) -> bool {
self.values.contains_key(key)
}
pub fn keys(&self) -> Vec<String> {
self.values.keys()
}
pub fn is_modified(&self) -> bool {
self.modified
}
pub fn mark_clean(&mut self) {
self.modified = false;
}
pub fn source_path(&self) -> Option<&Path> {
self.source_path.as_deref()
}
pub fn save(&self) -> Result<()> {
if let Some(path) = &self.source_path {
self.save_to_file(path)
} else {
Err(NomlError::validation(
"Cannot save configuration: no source file path",
))
}
}
pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let content = self.to_string_representation();
fs::write(path, content)
.map_err(|e| NomlError::io("Failed to write configuration file".to_string(), e))?;
Ok(())
}
pub fn as_value(&self) -> &Value {
&self.values
}
pub fn into_value(self) -> Value {
self.values
}
pub fn validate_schema(&self, schema: &Schema) -> Result<()> {
schema.validate(&self.values)
}
pub fn as_document(&self) -> &Document {
&self.document
}
pub fn merge(&mut self, other: &Config) -> Result<()> {
self.merge_value(&other.values)?;
self.modified = true;
Ok(())
}
pub fn stats(&self) -> ConfigStats {
ConfigStats {
key_count: self.count_keys(&self.values),
depth: self.max_depth(&self.values, 0),
comment_count: self.document.all_comments().len(),
has_arrays: self.has_arrays(&self.values),
has_nested_tables: self.has_nested_tables(&self.values),
}
}
fn merge_value(&mut self, other: &Value) -> Result<()> {
match (self.values.as_table_mut(), other.as_table()) {
(Ok(self_table), Ok(other_table)) => {
for (key, value) in other_table {
if let Some(existing) = self_table.get_mut(key) {
if existing.is_table() && value.is_table() {
Self::merge_tables(existing.as_table_mut()?, value.as_table()?)?;
} else {
*existing = value.clone();
}
} else {
self_table.insert(key.clone(), value.clone());
}
}
Ok(())
}
_ => Err(NomlError::validation("Cannot merge non-table values")),
}
}
fn merge_tables(
target: &mut BTreeMap<String, Value>,
source: &BTreeMap<String, Value>,
) -> Result<()> {
for (key, value) in source {
if let Some(existing) = target.get_mut(key) {
if existing.is_table() && value.is_table() {
Self::merge_tables(existing.as_table_mut()?, value.as_table()?)?;
} else {
*existing = value.clone();
}
} else {
target.insert(key.clone(), value.clone());
}
}
Ok(())
}
#[allow(clippy::only_used_in_recursion)]
fn count_keys(&self, value: &Value) -> usize {
match value {
Value::Table(table) => {
table.len() + table.values().map(|v| self.count_keys(v)).sum::<usize>()
}
Value::Array(arr) => arr.iter().map(|v| self.count_keys(v)).sum(),
_ => 0,
}
}
#[allow(clippy::only_used_in_recursion)]
fn max_depth(&self, value: &Value, current_depth: usize) -> usize {
match value {
Value::Table(table) => {
let max_child_depth = table
.values()
.map(|v| self.max_depth(v, current_depth + 1))
.max()
.unwrap_or(current_depth);
max_child_depth
}
Value::Array(arr) => {
let max_element_depth = arr
.iter()
.map(|v| self.max_depth(v, current_depth))
.max()
.unwrap_or(current_depth);
max_element_depth
}
_ => current_depth,
}
}
#[allow(clippy::only_used_in_recursion)]
fn has_arrays(&self, value: &Value) -> bool {
match value {
Value::Array(_) => true,
Value::Table(table) => table.values().any(|v| self.has_arrays(v)),
_ => false,
}
}
#[allow(clippy::only_used_in_recursion)]
fn has_nested_tables(&self, value: &Value) -> bool {
match value {
Value::Table(table) => table
.values()
.any(|v| v.is_table() || self.has_nested_tables(v)),
Value::Array(arr) => arr.iter().any(|v| self.has_nested_tables(v)),
_ => false,
}
}
fn to_string_representation(&self) -> String {
self.value_to_string(&self.values, 0, "")
}
fn value_to_string(&self, value: &Value, indent: usize, prefix: &str) -> String {
let indent_str = " ".repeat(indent);
match value {
Value::Table(table) => {
let mut result = String::new();
for (key, val) in table {
if !val.is_table() {
result.push_str(&format!(
"{}{} = {}\n",
indent_str,
key,
self.value_to_literal_string(val)
));
}
}
for (key, val) in table {
if val.is_table() {
let full_key = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
result.push('\n');
result.push_str(&format!("{indent_str}[{full_key}]\n"));
result.push_str(&self.value_to_string(val, indent, &full_key));
}
}
result
}
_ => self.value_to_literal_string(value),
}
}
#[allow(clippy::only_used_in_recursion)]
fn value_to_literal_string(&self, value: &Value) -> String {
match value {
Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Integer(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::String(s) => format!("\"{}\"", s.replace('"', "\\\"")),
Value::Array(arr) => {
let elements: Vec<String> = arr
.iter()
.map(|v| self.value_to_literal_string(v))
.collect();
format!("[{}]", elements.join(", "))
}
Value::Table(table) => {
let entries: Vec<String> = table
.iter()
.map(|(k, v)| format!("{} = {}", k, self.value_to_literal_string(v)))
.collect();
format!("{{ {} }}", entries.join(", "))
}
Value::Size(bytes) => format!("{bytes}B"),
Value::Duration(secs) => format!("{secs}s"),
Value::Binary(data) => format!("<{} bytes>", data.len()),
#[cfg(feature = "chrono")]
Value::DateTime(dt) => format!("\"{}\"", dt.format("%Y-%m-%dT%H:%M:%SZ")),
}
}
}
#[cfg(feature = "async")]
impl Config {
pub async fn load_async<P: AsRef<Path>>(path: P) -> Result<Self> {
let source = tokio::fs::read_to_string(path.as_ref())
.await
.map_err(|e| NomlError::io(path.as_ref().to_string_lossy().to_string(), e))?;
let document = crate::parser::parse_string(
&source,
Some(path.as_ref().to_string_lossy().to_string()),
)?;
let mut resolver = crate::resolver::Resolver::new();
let values = resolver.resolve(&document)?;
Ok(Config {
document,
values,
source_path: Some(path.as_ref().to_path_buf()),
modified: false,
})
}
pub async fn save_async<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let content = self.to_string_representation();
tokio::fs::write(path.as_ref(), content)
.await
.map_err(|e| NomlError::io(path.as_ref().to_string_lossy().to_string(), e))?;
Ok(())
}
pub async fn reload_async(&mut self) -> Result<()> {
match &self.source_path {
Some(path) => {
let reloaded = Self::load_async(path).await?;
self.document = reloaded.document;
self.values = reloaded.values;
self.modified = false;
Ok(())
}
None => Err(NomlError::validation(
"Cannot reload configuration: no source file path available",
)),
}
}
}
impl Default for Config {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ConfigStats {
pub key_count: usize,
pub depth: usize,
pub comment_count: usize,
pub has_arrays: bool,
pub has_nested_tables: bool,
}
impl ConfigBuilder {
pub fn allow_missing(mut self, allow: bool) -> Self {
self.allow_missing = allow;
self
}
pub fn default_value<T>(mut self, key: &str, value: T) -> Self
where
T: Into<Value>,
{
self.defaults.insert(key.to_string(), value.into());
self
}
pub fn validate(mut self, validate: bool) -> Self {
self.validate = validate;
self
}
pub fn build_from_file<P: AsRef<Path>>(self, path: P) -> Result<Config> {
let path = path.as_ref();
let mut config = if path.exists() {
Config::from_file(path)?
} else if self.allow_missing {
Config::new()
} else {
return Err(NomlError::io(
path.to_string_lossy().to_string(),
std::io::Error::new(std::io::ErrorKind::NotFound, "Configuration file not found"),
));
};
for (key, value) in self.defaults {
if !config.contains_key(&key) {
config.set(&key, value)?;
}
}
if self.validate {
}
config.mark_clean(); Ok(config)
}
pub fn build_from_string(self, content: &str) -> Result<Config> {
let mut config = Config::from_string(content)?;
for (key, value) in self.defaults {
if !config.contains_key(&key) {
config.set(&key, value)?;
}
}
if self.validate {
}
config.mark_clean();
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn config_creation() {
let config = Config::new();
assert!(!config.is_modified());
assert!(config.keys().is_empty());
assert!(config.source_path().is_none());
}
#[test]
fn config_from_string() {
let content = r#"
name = "test"
version = 1.0
[database]
host = "localhost"
port = 5432
"#;
let config = Config::from_string(content).unwrap();
assert_eq!(config.get("name").unwrap().as_string().unwrap(), "test");
assert_eq!(config.get("version").unwrap().as_float().unwrap(), 1.0);
assert_eq!(
config.get("database.host").unwrap().as_string().unwrap(),
"localhost"
);
assert_eq!(
config.get("database.port").unwrap().as_integer().unwrap(),
5432
);
assert!(!config.is_modified());
}
#[test]
fn config_modification() {
let mut config = Config::new();
config.set("name", "test_app").unwrap();
config.set("version", 1.5).unwrap();
config.set("server.host", "0.0.0.0").unwrap();
config.set("server.port", 8080).unwrap();
assert!(config.is_modified());
assert_eq!(config.get("name").unwrap().as_string().unwrap(), "test_app");
assert_eq!(config.get("version").unwrap().as_float().unwrap(), 1.5);
assert_eq!(
config.get("server.host").unwrap().as_string().unwrap(),
"0.0.0.0"
);
assert_eq!(
config.get("server.port").unwrap().as_integer().unwrap(),
8080
);
}
#[test]
fn config_removal() {
let mut config = Config::from_string(
r#"
name = "test"
version = 1.0
debug = true
"#,
)
.unwrap();
assert!(config.contains_key("debug"));
let removed = config.remove("debug").unwrap();
assert!(removed.is_some());
assert!(removed.unwrap().as_bool().unwrap());
assert!(!config.contains_key("debug"));
assert!(config.is_modified());
}
#[test]
fn config_get_or_insert() {
let mut config = Config::from_string(
r#"
name = "test"
"#,
)
.unwrap();
let name = config.get_or_insert("name", "default").unwrap();
assert_eq!(name.as_string().unwrap(), "test");
let version = config.get_or_insert("version", "1.0.0").unwrap();
assert_eq!(version.as_string().unwrap(), "1.0.0");
assert!(config.is_modified());
assert!(config.contains_key("version"));
}
#[test]
fn config_merge() {
let mut config1 = Config::from_string(
r#"
name = "app1"
version = "1.0"
[database]
host = "localhost"
"#,
)
.unwrap();
let config2 = Config::from_string(
r#"
version = "2.0"
author = "test"
[database]
port = 5432
[server]
host = "0.0.0.0"
"#,
)
.unwrap();
config1.merge(&config2).unwrap();
assert_eq!(config1.get("version").unwrap().as_string().unwrap(), "2.0");
assert_eq!(config1.get("name").unwrap().as_string().unwrap(), "app1");
assert_eq!(config1.get("author").unwrap().as_string().unwrap(), "test");
assert_eq!(
config1.get("database.host").unwrap().as_string().unwrap(),
"localhost"
);
assert_eq!(
config1.get("database.port").unwrap().as_integer().unwrap(),
5432
);
assert_eq!(
config1.get("server.host").unwrap().as_string().unwrap(),
"0.0.0.0"
);
assert!(config1.is_modified());
}
#[test]
fn config_stats() {
let config = Config::from_string(
r#"
name = "test"
items = [1, 2, 3]
[database]
host = "localhost"
[database.pool]
min = 5
max = 20
"#,
)
.unwrap();
let stats = config.stats();
assert_eq!(stats.key_count, 7); assert!(stats.depth >= 2); assert!(stats.has_arrays);
assert!(stats.has_nested_tables);
}
#[test]
fn config_builder() {
let config = Config::builder()
.default_value("name", "default_app")
.default_value("debug", true)
.build_from_string(
r#"
version = "1.0"
"#,
)
.unwrap();
assert_eq!(
config.get("name").unwrap().as_string().unwrap(),
"default_app"
);
assert!(config.get("debug").unwrap().as_bool().unwrap());
assert_eq!(config.get("version").unwrap().as_string().unwrap(), "1.0");
assert!(!config.is_modified()); }
#[test]
fn config_file_operations() {
let mut temp_file = NamedTempFile::new().unwrap();
write!(
temp_file,
r#"
name = "file_test"
version = 1.0
[database]
host = "localhost"
"#
)
.unwrap();
let mut config = Config::from_file(temp_file.path()).unwrap();
assert!(config.source_path().is_some());
config.set("version", 2.0).unwrap();
config.set("database.port", 5432).unwrap();
config.save().unwrap();
let config2 = Config::from_file(temp_file.path()).unwrap();
assert_eq!(config2.get("version").unwrap().as_float().unwrap(), 2.0);
assert_eq!(
config2.get("database.port").unwrap().as_integer().unwrap(),
5432
);
}
}