prefer_db 0.3.4

Database source for prefer configuration library
Documentation
//! Database source for prefer configuration library.
//!
//! This crate provides a `DbSource` that implements `prefer::Source`,
//! allowing configuration to be loaded from a database and layered
//! with other prefer sources.
//!
//! # Example
//!
//! ```no_run
//! use prefer::{Config, ConfigBuilder};
//! use prefer_db::{DbSource, ConfigLoader, ConfigEntry};
//! use async_trait::async_trait;
//!
//! struct MyDbLoader {
//!     // your database connection
//! }
//!
//! #[async_trait]
//! impl ConfigLoader for MyDbLoader {
//!     async fn load_config(&self) -> Option<ConfigEntry> {
//!         // Load from your database
//!         Some(ConfigEntry {
//!             format: "json".to_string(),
//!             data: r#"{"key": "value"}"#.to_string(),
//!         })
//!     }
//!
//!     fn name(&self) -> &str {
//!         "my_database"
//!     }
//! }
//!
//! #[tokio::main]
//! async fn main() -> prefer::Result<()> {
//!     let config = Config::builder()
//!         .add_source(DbSource::new(MyDbLoader { /* ... */ }))
//!         .add_file("config.toml")  // File overrides DB
//!         .build()
//!         .await?;
//!
//!     Ok(())
//! }
//! ```

use async_trait::async_trait;
use prefer::source::Source;
use prefer::{ConfigValue, Error, Result};
use std::collections::HashMap;

/// A configuration entry loaded from the database.
#[derive(Debug, Clone)]
pub struct ConfigEntry {
    /// The format of the data (e.g., "json", "toml", "yaml").
    pub format: String,
    /// The raw configuration data as a string.
    pub data: String,
}

/// Trait for loading configuration from a database.
///
/// Implement this trait for your specific database backend.
#[async_trait]
pub trait ConfigLoader: Send + Sync {
    /// Load the latest configuration entry from the database.
    ///
    /// Returns `None` if no configuration is stored.
    async fn load_config(&self) -> Option<ConfigEntry>;

    /// Get a human-readable name for this loader (used in error messages).
    fn name(&self) -> &str;
}

/// A prefer Source that loads configuration from a database.
///
/// Wraps any type implementing `ConfigLoader` and converts the
/// stored configuration data to `prefer::ConfigValue`.
pub struct DbSource<L: ConfigLoader> {
    loader: L,
}

impl<L: ConfigLoader> DbSource<L> {
    /// Create a new database source with the given loader.
    pub fn new(loader: L) -> Self {
        Self { loader }
    }
}

#[async_trait]
impl<L: ConfigLoader + 'static> Source for DbSource<L> {
    async fn load(&self) -> Result<ConfigValue> {
        let entry = match self.loader.load_config().await {
            Some(entry) => entry,
            None => return Ok(ConfigValue::Object(HashMap::new())),
        };

        parse_config_data(&entry.data, &entry.format)
    }

    fn name(&self) -> &str {
        self.loader.name()
    }
}

/// Parse configuration data from a string based on format.
fn parse_config_data(data: &str, format: &str) -> Result<ConfigValue> {
    match format.to_lowercase().as_str() {
        "json" => parse_json(data),
        "toml" => parse_toml(data),
        "yaml" | "yml" => parse_yaml(data),
        _ => parse_json(data), // Default to JSON
    }
}

fn parse_json(data: &str) -> Result<ConfigValue> {
    let value = jzon::parse(data).map_err(|e| Error::ParseError {
        format: "json".to_string(),
        path: std::path::PathBuf::from("<database>"),
        source: e.to_string().into(),
    })?;
    Ok(json_to_config_value(value))
}

fn parse_toml(data: &str) -> Result<ConfigValue> {
    let doc = data
        .parse::<toml_edit::DocumentMut>()
        .map_err(|e| Error::ParseError {
            format: "toml".to_string(),
            path: std::path::PathBuf::from("<database>"),
            source: e.to_string().into(),
        })?;
    Ok(toml_to_config_value(doc.as_item()))
}

fn parse_yaml(data: &str) -> Result<ConfigValue> {
    let docs = yaml_rust2::YamlLoader::load_from_str(data).map_err(|e| Error::ParseError {
        format: "yaml".to_string(),
        path: std::path::PathBuf::from("<database>"),
        source: e.to_string().into(),
    })?;

    match docs.into_iter().next() {
        Some(doc) => Ok(yaml_to_config_value(&doc)),
        None => Ok(ConfigValue::Object(HashMap::new())),
    }
}

/// Convert jzon::JsonValue to prefer::ConfigValue.
fn json_to_config_value(value: jzon::JsonValue) -> ConfigValue {
    use jzon::JsonValue;

    match &value {
        JsonValue::Null => ConfigValue::Null,
        JsonValue::Boolean(b) => ConfigValue::Bool(*b),
        JsonValue::Number(_) => {
            // Try to preserve integer precision using methods on JsonValue
            if let Some(i) = value.as_i64() {
                ConfigValue::Integer(i)
            } else if let Some(f) = value.as_f64() {
                ConfigValue::Float(f)
            } else {
                ConfigValue::Null
            }
        }
        JsonValue::Short(s) => ConfigValue::String(s.to_string()),
        JsonValue::String(s) => ConfigValue::String(s.clone()),
        JsonValue::Array(arr) => {
            ConfigValue::Array(arr.iter().cloned().map(json_to_config_value).collect())
        }
        JsonValue::Object(obj) => ConfigValue::Object(
            obj.iter()
                .map(|(k, v)| (k.to_string(), json_to_config_value(v.clone())))
                .collect(),
        ),
    }
}

/// Convert toml_edit::Item to prefer::ConfigValue.
fn toml_to_config_value(item: &toml_edit::Item) -> ConfigValue {
    match item {
        toml_edit::Item::None => ConfigValue::Null,
        toml_edit::Item::Value(v) => toml_value_to_config_value(v),
        toml_edit::Item::Table(t) => ConfigValue::Object(
            t.iter()
                .map(|(k, v)| (k.to_string(), toml_to_config_value(v)))
                .collect(),
        ),
        toml_edit::Item::ArrayOfTables(arr) => ConfigValue::Array(
            arr.iter()
                .map(|t| {
                    ConfigValue::Object(
                        t.iter()
                            .map(|(k, v)| (k.to_string(), toml_to_config_value(v)))
                            .collect(),
                    )
                })
                .collect(),
        ),
    }
}

fn toml_value_to_config_value(value: &toml_edit::Value) -> ConfigValue {
    match value {
        toml_edit::Value::Boolean(b) => ConfigValue::Bool(*b.value()),
        toml_edit::Value::Integer(i) => ConfigValue::Integer(*i.value()),
        toml_edit::Value::Float(f) => ConfigValue::Float(*f.value()),
        toml_edit::Value::String(s) => ConfigValue::String(s.value().to_string()),
        toml_edit::Value::Datetime(dt) => ConfigValue::String(dt.to_string()),
        toml_edit::Value::Array(arr) => {
            ConfigValue::Array(arr.iter().map(toml_value_to_config_value).collect())
        }
        toml_edit::Value::InlineTable(t) => ConfigValue::Object(
            t.iter()
                .map(|(k, v)| (k.to_string(), toml_value_to_config_value(v)))
                .collect(),
        ),
    }
}

/// Convert yaml_rust2::Yaml to prefer::ConfigValue.
fn yaml_to_config_value(value: &yaml_rust2::Yaml) -> ConfigValue {
    match value {
        yaml_rust2::Yaml::Null => ConfigValue::Null,
        yaml_rust2::Yaml::Boolean(b) => ConfigValue::Bool(*b),
        yaml_rust2::Yaml::Integer(i) => ConfigValue::Integer(*i),
        yaml_rust2::Yaml::Real(s) => s
            .parse::<f64>()
            .map(ConfigValue::Float)
            .unwrap_or(ConfigValue::Null),
        yaml_rust2::Yaml::String(s) => ConfigValue::String(s.clone()),
        yaml_rust2::Yaml::Array(arr) => {
            ConfigValue::Array(arr.iter().map(yaml_to_config_value).collect())
        }
        yaml_rust2::Yaml::Hash(map) => ConfigValue::Object(
            map.iter()
                .filter_map(|(k, v)| k.as_str().map(|s| (s.to_string(), yaml_to_config_value(v))))
                .collect(),
        ),
        yaml_rust2::Yaml::Alias(_) => ConfigValue::Null,
        yaml_rust2::Yaml::BadValue => ConfigValue::Null,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct TestLoader {
        entry: Option<ConfigEntry>,
    }

    #[async_trait]
    impl ConfigLoader for TestLoader {
        async fn load_config(&self) -> Option<ConfigEntry> {
            self.entry.clone()
        }

        fn name(&self) -> &str {
            "test"
        }
    }

    #[tokio::test]
    async fn test_load_json() {
        let loader = TestLoader {
            entry: Some(ConfigEntry {
                format: "json".to_string(),
                data: r#"{"key": "value", "num": 42}"#.to_string(),
            }),
        };
        let source = DbSource::new(loader);
        let config = source.load().await.unwrap();

        assert_eq!(config.get("key").unwrap().as_str(), Some("value"));
        assert_eq!(config.get("num").unwrap().as_i64(), Some(42));
    }

    #[tokio::test]
    async fn test_load_toml() {
        let loader = TestLoader {
            entry: Some(ConfigEntry {
                format: "toml".to_string(),
                data: r#"
                    key = "value"
                    num = 42
                "#
                .to_string(),
            }),
        };
        let source = DbSource::new(loader);
        let config = source.load().await.unwrap();

        assert_eq!(config.get("key").unwrap().as_str(), Some("value"));
        assert_eq!(config.get("num").unwrap().as_i64(), Some(42));
    }

    #[tokio::test]
    async fn test_load_yaml() {
        let loader = TestLoader {
            entry: Some(ConfigEntry {
                format: "yaml".to_string(),
                data: "key: value\nnum: 42".to_string(),
            }),
        };
        let source = DbSource::new(loader);
        let config = source.load().await.unwrap();

        assert_eq!(config.get("key").unwrap().as_str(), Some("value"));
        assert_eq!(config.get("num").unwrap().as_i64(), Some(42));
    }

    #[tokio::test]
    async fn test_load_empty() {
        let loader = TestLoader { entry: None };
        let source = DbSource::new(loader);
        let config = source.load().await.unwrap();

        assert!(config.as_object().unwrap().is_empty());
    }

    #[tokio::test]
    async fn test_nested_json() {
        let loader = TestLoader {
            entry: Some(ConfigEntry {
                format: "json".to_string(),
                data: r#"{"database": {"host": "localhost", "port": 5432}}"#.to_string(),
            }),
        };
        let source = DbSource::new(loader);
        let config = source.load().await.unwrap();

        let db = config.get("database").unwrap();
        assert_eq!(db.get("host").unwrap().as_str(), Some("localhost"));
        assert_eq!(db.get("port").unwrap().as_i64(), Some(5432));
    }
}