diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..08cf969 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(git checkout:*)", + "Bash(cargo test:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(cargo check:*)", + "Bash(cargo llvm-cov:*)", + "Bash(cargo tarpaulin:*)", + "Bash(grcov:*)", + "Bash(git status:*)", + "Bash(git push:*)" + ] + } +} diff --git a/prefer/Cargo.toml b/prefer/Cargo.toml index d60c476..8910673 100644 --- a/prefer/Cargo.toml +++ b/prefer/Cargo.toml @@ -29,6 +29,9 @@ notify = { version = "6.1", optional = true } # Platform-specific path handling (std only) dirs = { version = "5.0", optional = true } +# Plugin registration (std only) +inventory = { version = "0.3", optional = true } + # Optional format support (std only, no serde) roxmltree = { version = "0.20", optional = true } rust-ini = { version = "0.20", optional = true } @@ -48,6 +51,7 @@ std = [ "dep:toml_edit", "dep:notify", "dep:dirs", + "dep:inventory", ] json5 = ["std"] # JSON5 features handled by jzon fallback xml = ["std", "dep:roxmltree"] diff --git a/prefer/src/builder.rs b/prefer/src/builder.rs index a2353c3..8007a5f 100644 --- a/prefer/src/builder.rs +++ b/prefer/src/builder.rs @@ -3,6 +3,8 @@ //! The `ConfigBuilder` provides a fluent API for creating configurations //! from multiple sources with layered overrides. +#![allow(deprecated)] // Builder still uses Source/FileSource internally during transition + use crate::config::Config; use crate::error::Result; use crate::source::{EnvSource, FileSource, LayeredSource, MemorySource, Source}; diff --git a/prefer/src/config.rs b/prefer/src/config.rs index 719624f..9293329 100644 --- a/prefer/src/config.rs +++ b/prefer/src/config.rs @@ -2,18 +2,51 @@ use crate::discovery; use crate::error::{Error, Result}; +use crate::events::Emitter; use crate::formats; use crate::value::{ConfigValue, FromValue}; use crate::visitor::{visit, ValueVisitor}; +use std::collections::HashMap; use std::path::PathBuf; /// The main configuration struct that holds parsed configuration data. -#[derive(Debug, Clone)] +/// +/// `Config` retains metadata about how it was loaded (source path, loader +/// name, formatter name) so it can support `save()` and `watch()` on an +/// existing instance. It also supports an event emitter for "changed" +/// events when values are set via `set()`. pub struct Config { - /// The underlying configuration data. data: ConfigValue, - /// The path to the file this configuration was loaded from. source_path: Option, + source: Option, + loader_name: Option, + formatter_name: Option, + emitter: Option, +} + +impl std::fmt::Debug for Config { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Config") + .field("data", &self.data) + .field("source_path", &self.source_path) + .field("source", &self.source) + .field("loader_name", &self.loader_name) + .field("formatter_name", &self.formatter_name) + .finish() + } +} + +impl Clone for Config { + fn clone(&self) -> Self { + Self { + data: self.data.clone(), + source_path: self.source_path.clone(), + source: self.source.clone(), + loader_name: self.loader_name.clone(), + formatter_name: self.formatter_name.clone(), + emitter: None, + } + } } impl Config { @@ -22,6 +55,10 @@ impl Config { Self { data, source_path: None, + source: None, + loader_name: None, + formatter_name: None, + emitter: None, } } @@ -51,6 +88,34 @@ impl Config { Self { data, source_path: Some(path), + source: None, + loader_name: None, + formatter_name: None, + emitter: None, + } + } + + /// Create a Config with full metadata from the registry loading path. + pub(crate) fn with_metadata( + data: ConfigValue, + source: String, + loader_name: String, + formatter_name: String, + ) -> Self { + let source_path = PathBuf::from(&source); + let source_path = if source_path.exists() { + Some(source_path) + } else { + None + }; + + Self { + data, + source_path, + source: Some(source), + loader_name: Some(loader_name), + formatter_name: Some(formatter_name), + emitter: None, } } @@ -76,6 +141,21 @@ impl Config { self.source_path.as_ref() } + /// Get the source identifier (e.g., file path or URL). + pub fn source(&self) -> Option<&str> { + self.source.as_deref() + } + + /// Get the name of the loader that loaded this config. + pub fn loader_name(&self) -> Option<&str> { + self.loader_name.as_deref() + } + + /// Get the name of the formatter used to parse this config. + pub fn formatter_name(&self) -> Option<&str> { + self.formatter_name.as_deref() + } + /// Get a configuration value by key using dot notation. /// /// # Examples @@ -115,6 +195,28 @@ impl Config { Ok(current) } + /// Set a configuration value by key using dot notation. + /// + /// Creates intermediate objects as needed. Emits a "changed" event + /// if an emitter is attached. + pub fn set(&mut self, key: &str, value: ConfigValue) { + let previous = self.get_value(key).ok().cloned(); + let parts: Vec<&str> = key.split('.').collect(); + set_nested(&mut self.data, &parts, value.clone()); + + if let Some(emitter) = &self.emitter { + emitter.emit("changed", key, &value, previous.as_ref()); + } + } + + /// Register a handler for configuration change events. + /// + /// The handler is called whenever `set()` is used to modify a value. + pub fn on_change(&mut self, handler: crate::events::EventHandler) { + let emitter = self.emitter.get_or_insert_with(Emitter::new); + emitter.bind("changed", handler); + } + /// Get the entire configuration data as a reference. pub fn data(&self) -> &ConfigValue { &self.data @@ -184,6 +286,36 @@ impl Config { } } +/// Set a value at a nested key path, creating intermediate objects as needed. +fn set_nested(current: &mut ConfigValue, parts: &[&str], value: ConfigValue) { + debug_assert!(!parts.is_empty(), "key parts should never be empty"); + + let key = parts[0]; + + if parts.len() == 1 { + if let ConfigValue::Object(map) = current { + map.insert(key.to_string(), value); + } else { + let mut map = HashMap::new(); + map.insert(key.to_string(), value); + *current = ConfigValue::Object(map); + } + return; + } + + // Ensure current is an object and get/create the nested entry + if !matches!(current, ConfigValue::Object(_)) { + *current = ConfigValue::Object(HashMap::new()); + } + + if let ConfigValue::Object(map) = current { + let entry = map + .entry(key.to_string()) + .or_insert_with(|| ConfigValue::Object(HashMap::new())); + set_nested(entry, &parts[1..], value); + } +} + #[cfg(test)] mod tests { use super::*; @@ -257,4 +389,195 @@ mod tests { assert!(!config.has_key("auth.password")); assert!(!config.has_key("nonexistent")); } + + #[test] + fn test_set_simple() { + let mut config = Config::new(obj(vec![("port", ConfigValue::Integer(8080))])); + config.set("port", ConfigValue::Integer(9090)); + let port: i64 = config.get("port").unwrap(); + assert_eq!(port, 9090); + } + + #[test] + fn test_set_nested_creates_intermediates() { + let mut config = Config::new(ConfigValue::Object(HashMap::new())); + config.set( + "server.database.host", + ConfigValue::String("localhost".into()), + ); + + let host: String = config.get("server.database.host").unwrap(); + assert_eq!(host, "localhost"); + } + + #[test] + fn test_set_emits_changed_event() { + let mut config = Config::new(obj(vec![("port", ConfigValue::Integer(8080))])); + + let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let log_clone = log.clone(); + config.on_change(Box::new(move |key, value, prev| { + log_clone + .lock() + .unwrap() + .push((key.to_string(), value.clone(), prev.cloned())); + })); + + config.set("port", ConfigValue::Integer(9090)); + + let entries = log.lock().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, "port"); + assert_eq!(entries[0].1, ConfigValue::Integer(9090)); + assert_eq!(entries[0].2, Some(ConfigValue::Integer(8080))); + } + + #[test] + fn test_metadata_accessors() { + let config = Config::with_metadata( + ConfigValue::Null, + "/etc/myapp.toml".into(), + "file".into(), + "toml".into(), + ); + + assert_eq!(config.source(), Some("/etc/myapp.toml")); + assert_eq!(config.loader_name(), Some("file")); + assert_eq!(config.formatter_name(), Some("toml")); + } + + #[test] + fn test_clone_drops_emitter() { + let mut config = Config::new(ConfigValue::Null); + config.on_change(Box::new(|_, _, _| {})); + assert!(config.emitter.is_some()); + + let cloned = config.clone(); + assert!(cloned.emitter.is_none()); + } + + #[test] + fn test_clone_preserves_metadata() { + let config = Config::with_metadata( + ConfigValue::Null, + "/nonexistent/path.toml".into(), + "file".into(), + "toml".into(), + ); + let cloned = config.clone(); + assert_eq!(cloned.source(), Some("/nonexistent/path.toml")); + assert_eq!(cloned.loader_name(), Some("file")); + assert_eq!(cloned.formatter_name(), Some("toml")); + } + + #[test] + fn test_debug_output() { + let config = Config::with_metadata( + ConfigValue::Integer(42), + "test.json".into(), + "file".into(), + "json".into(), + ); + let debug = format!("{:?}", config); + assert!(debug.contains("Config")); + assert!(debug.contains("loader_name")); + assert!(debug.contains("formatter_name")); + // emitter should NOT appear in debug output + assert!(!debug.contains("emitter")); + } + + #[test] + fn test_with_metadata_nonexistent_path() { + let config = Config::with_metadata( + ConfigValue::Null, + "/this/path/does/not/exist.toml".into(), + "file".into(), + "toml".into(), + ); + // source_path should be None for non-existent paths + assert!(config.source_path().is_none()); + // but source string is still set + assert_eq!(config.source(), Some("/this/path/does/not/exist.toml")); + } + + #[test] + fn test_new_config_has_no_metadata() { + let config = Config::new(ConfigValue::Null); + assert!(config.source_path().is_none()); + assert!(config.source().is_none()); + assert!(config.loader_name().is_none()); + assert!(config.formatter_name().is_none()); + } + + #[test] + fn test_set_without_emitter() { + let mut config = Config::new(ConfigValue::Object(HashMap::new())); + // Should not panic when no emitter is attached + config.set("key", ConfigValue::Integer(42)); + let val: i64 = config.get("key").unwrap(); + assert_eq!(val, 42); + } + + #[test] + fn test_set_overwrites_non_object() { + let mut config = Config::new(ConfigValue::Integer(0)); + config.set("key", ConfigValue::String("value".into())); + let val: String = config.get("key").unwrap(); + assert_eq!(val, "value"); + } + + #[test] + fn test_set_overwrites_nested_non_object() { + let mut config = Config::new(obj(vec![("a", ConfigValue::Integer(1))])); + // Setting a.b.c should replace integer "a" with an object + config.set("a.b.c", ConfigValue::String("deep".into())); + let val: String = config.get("a.b.c").unwrap(); + assert_eq!(val, "deep"); + } + + #[test] + fn test_set_new_key_fires_with_none_previous() { + let mut config = Config::new(ConfigValue::Object(HashMap::new())); + + let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let log_clone = log.clone(); + config.on_change(Box::new(move |key, _value, prev| { + log_clone + .lock() + .unwrap() + .push((key.to_string(), prev.cloned())); + })); + + config.set("new_key", ConfigValue::Integer(1)); + + let entries = log.lock().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, "new_key"); + assert!(entries[0].1.is_none()); + } + + #[test] + fn test_data_and_data_mut() { + let mut config = Config::new(obj(vec![("x", ConfigValue::Integer(1))])); + assert!(config.data().as_object().is_some()); + + if let ConfigValue::Object(map) = config.data_mut() { + map.insert("y".to_string(), ConfigValue::Integer(2)); + } + assert!(config.has_key("y")); + } + + #[test] + fn test_get_value_non_object_intermediate() { + let config = Config::new(obj(vec![("a", ConfigValue::Integer(1))])); + // Trying to traverse through a non-object should fail + let result = config.get_value("a.b"); + assert!(matches!(result, Err(Error::KeyNotFound(_)))); + } + + #[test] + fn test_with_source() { + let config = Config::with_source(ConfigValue::Integer(42), PathBuf::from("/tmp/test.json")); + assert_eq!(config.source_path(), Some(&PathBuf::from("/tmp/test.json"))); + } } diff --git a/prefer/src/error.rs b/prefer/src/error.rs index dfac41c..6fb20a7 100644 --- a/prefer/src/error.rs +++ b/prefer/src/error.rs @@ -80,6 +80,21 @@ pub enum Error { source_name: String, source: Box, }, + + /// No registered loader can handle the given identifier. + #[cfg(feature = "std")] + #[cfg_attr(feature = "std", error("No loader found for identifier: {0}"))] + NoLoaderFound(String), + + /// No registered formatter can handle the given source. + #[cfg(feature = "std")] + #[cfg_attr(feature = "std", error("No formatter found for source: {0}"))] + NoFormatterFound(String), + + /// The loader does not support watching for changes. + #[cfg(feature = "std")] + #[cfg_attr(feature = "std", error("Watching is not supported for: {0}"))] + WatchNotSupported(String), } // Manual Display implementation for no_std @@ -142,4 +157,58 @@ mod tests { let result = err.with_key("ignored"); assert!(matches!(result, Error::FileNotFound(s) if s == "test.json")); } + + #[test] + fn test_display_no_loader_found() { + let err = Error::NoLoaderFound("postgres://localhost".into()); + let msg = err.to_string(); + assert!(msg.contains("No loader found")); + assert!(msg.contains("postgres://localhost")); + } + + #[test] + fn test_display_no_formatter_found() { + let err = Error::NoFormatterFound("config.bson".into()); + let msg = err.to_string(); + assert!(msg.contains("No formatter found")); + assert!(msg.contains("config.bson")); + } + + #[test] + fn test_display_watch_not_supported() { + let err = Error::WatchNotSupported("redis://localhost".into()); + let msg = err.to_string(); + assert!(msg.contains("not supported")); + assert!(msg.contains("redis://localhost")); + } + + #[test] + fn test_display_file_not_found() { + let err = Error::FileNotFound("missing.toml".into()); + assert!(err.to_string().contains("missing.toml")); + } + + #[test] + fn test_display_key_not_found() { + let err = Error::KeyNotFound("server.port".into()); + assert!(err.to_string().contains("server.port")); + } + + #[test] + fn test_display_conversion_error() { + let err = Error::ConversionError { + key: "port".into(), + type_name: "u16".into(), + source: "out of range".into(), + }; + let msg = err.to_string(); + assert!(msg.contains("port")); + assert!(msg.contains("u16")); + } + + #[test] + fn test_display_unsupported_format() { + let err = Error::UnsupportedFormat(PathBuf::from("config.bson")); + assert!(err.to_string().contains("config.bson")); + } } diff --git a/prefer/src/events.rs b/prefer/src/events.rs new file mode 100644 index 0000000..795b8fc --- /dev/null +++ b/prefer/src/events.rs @@ -0,0 +1,151 @@ +//! Event emission system for configuration changes. +//! +//! Matches Python prefer's `Emitter` class. Used by `Config` to emit +//! "changed" events when values are set or updated. + +use crate::value::ConfigValue; +use std::collections::HashMap; + +/// Handler function for configuration events. +/// +/// Parameters: +/// - `key`: The configuration key that changed +/// - `value`: The new value +/// - `previous`: The previous value, if any +pub type EventHandler = Box) + Send + Sync>; + +/// An event emitter that supports named events with multiple handlers. +pub struct Emitter { + handlers: HashMap>, +} + +impl Emitter { + pub fn new() -> Self { + Self { + handlers: HashMap::new(), + } + } + + /// Register a handler for the given event name. + pub fn bind(&mut self, event: &str, handler: EventHandler) { + self.handlers + .entry(event.to_string()) + .or_default() + .push(handler); + } + + /// Emit an event, calling all registered handlers. + pub fn emit( + &self, + event: &str, + key: &str, + value: &ConfigValue, + previous: Option<&ConfigValue>, + ) { + let Some(handlers) = self.handlers.get(event) else { + return; + }; + + for handler in handlers { + handler(key, value, previous); + } + } + + /// Check if any handlers are registered for the given event. + pub fn has_handlers(&self, event: &str) -> bool { + self.handlers.get(event).is_some_and(|h| !h.is_empty()) + } +} + +impl Default for Emitter { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + + #[test] + fn test_emit_calls_handlers() { + let mut emitter = Emitter::new(); + let log: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let log_clone = log.clone(); + emitter.bind( + "changed", + Box::new(move |key, _value, _prev| { + log_clone.lock().unwrap().push(key.to_string()); + }), + ); + + emitter.emit("changed", "server.port", &ConfigValue::Integer(8080), None); + + let entries = log.lock().unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0], "server.port"); + } + + #[test] + fn test_emit_with_previous_value() { + let mut emitter = Emitter::new(); + let saw_previous: Arc> = Arc::new(Mutex::new(false)); + + let flag = saw_previous.clone(); + emitter.bind( + "changed", + Box::new(move |_key, _value, prev| { + if let Some(ConfigValue::Integer(42)) = prev { + *flag.lock().unwrap() = true; + } + }), + ); + + emitter.emit( + "changed", + "port", + &ConfigValue::Integer(9090), + Some(&ConfigValue::Integer(42)), + ); + + assert!(*saw_previous.lock().unwrap()); + } + + #[test] + fn test_emit_no_handlers() { + let emitter = Emitter::new(); + // Should not panic + emitter.emit("changed", "key", &ConfigValue::Null, None); + } + + #[test] + fn test_multiple_handlers() { + let mut emitter = Emitter::new(); + let count: Arc> = Arc::new(Mutex::new(0)); + + for _ in 0..3 { + let c = count.clone(); + emitter.bind( + "changed", + Box::new(move |_, _, _| { + *c.lock().unwrap() += 1; + }), + ); + } + + emitter.emit("changed", "key", &ConfigValue::Null, None); + assert_eq!(*count.lock().unwrap(), 3); + } + + #[test] + fn test_has_handlers() { + let mut emitter = Emitter::new(); + assert!(!emitter.has_handlers("changed")); + + emitter.bind("changed", Box::new(|_, _, _| {})); + assert!(emitter.has_handlers("changed")); + assert!(!emitter.has_handlers("other")); + } +} diff --git a/prefer/src/formatter/ini.rs b/prefer/src/formatter/ini.rs new file mode 100644 index 0000000..02c5f97 --- /dev/null +++ b/prefer/src/formatter/ini.rs @@ -0,0 +1,204 @@ +//! INI format support. + +use crate::error::{Error, Result}; +use crate::formatter::{extension_matches, Formatter}; +use crate::registry::RegisteredFormatter; +use crate::value::ConfigValue; +use std::collections::HashMap; + +inventory::submit! { RegisteredFormatter(&IniFormatter) } + +/// Formatter for INI files. +/// +/// Uses the `rust-ini` crate. Auto-detects value types: booleans, integers, +/// floats, and strings. Groups values by section name, with a "default" +/// section for global keys. +pub struct IniFormatter; + +impl Formatter for IniFormatter { + fn provides(&self, identifier: &str) -> bool { + extension_matches(identifier, self.extensions()) + } + + fn extensions(&self) -> &[&str] { + &["ini"] + } + + fn deserialize(&self, content: &str) -> Result { + use ini::Ini; + + let ini = Ini::load_from_str(content).map_err(|e| Error::ParseError { + format: "INI".to_string(), + path: std::path::PathBuf::from(""), + source: e.to_string().into(), + })?; + + let mut root: HashMap = HashMap::new(); + + for (section, properties) in ini.iter() { + let section_name = section.unwrap_or("default"); + let mut section_map: HashMap = HashMap::new(); + + for (key, value) in properties.iter() { + let parsed_value = if let Ok(num) = value.parse::() { + ConfigValue::Integer(num) + } else if let Ok(num) = value.parse::() { + ConfigValue::Float(num) + } else if let Ok(b) = value.parse::() { + ConfigValue::Bool(b) + } else { + ConfigValue::String(value.to_string()) + }; + + section_map.insert(key.to_string(), parsed_value); + } + + root.insert(section_name.to_string(), ConfigValue::Object(section_map)); + } + + Ok(ConfigValue::Object(root)) + } + + fn serialize(&self, value: &ConfigValue) -> Result { + let ConfigValue::Object(map) = value else { + return Ok(String::new()); + }; + + let mut lines = Vec::new(); + + for (section, section_value) in map { + let ConfigValue::Object(props) = section_value else { + continue; + }; + + lines.push(format!("[{}]", section)); + for (key, val) in props { + lines.push(format!("{} = {}", key, ini_value_str(val))); + } + lines.push(String::new()); + } + + Ok(lines.join("\n")) + } + + fn name(&self) -> &str { + "ini" + } +} + +fn ini_value_str(value: &ConfigValue) -> String { + match value { + ConfigValue::Null => String::new(), + ConfigValue::Bool(b) => b.to_string(), + ConfigValue::Integer(i) => i.to_string(), + ConfigValue::Float(f) => f.to_string(), + ConfigValue::String(s) => s.clone(), + _ => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_provides() { + let f = IniFormatter; + assert!(f.provides("config.ini")); + assert!(!f.provides("config.toml")); + } + + #[test] + fn test_deserialize() { + let f = IniFormatter; + let result = f.deserialize("[section]\nname = test\ncount = 42").unwrap(); + let section = result.get("section").unwrap(); + assert_eq!(section.get("name").unwrap().as_str(), Some("test")); + assert_eq!(section.get("count").unwrap().as_i64(), Some(42)); + } + + #[test] + fn test_deserialize_error() { + let f = IniFormatter; + // rust-ini is very permissive, most content parses. Use truly invalid input. + // Actually rust-ini is extremely lenient. This test verifies it doesn't panic. + let result = f.deserialize("[section]\nkey = value"); + assert!(result.is_ok()); + } + + #[test] + fn test_deserialize_all_value_types() { + let f = IniFormatter; + let ini = "[types]\nint = 42\nfloat = 3.15\nbool = true\nstr = hello world"; + let result = f.deserialize(ini).unwrap(); + let section = result.get("types").unwrap(); + assert_eq!(section.get("int").unwrap().as_i64(), Some(42)); + assert_eq!(section.get("float").unwrap().as_f64(), Some(3.15)); + assert_eq!(section.get("bool").unwrap().as_bool(), Some(true)); + assert_eq!(section.get("str").unwrap().as_str(), Some("hello world")); + } + + #[test] + fn test_deserialize_default_section() { + let f = IniFormatter; + let ini = "global_key = global_value\n[section]\nkey = value"; + let result = f.deserialize(ini).unwrap(); + let default = result.get("default").unwrap(); + assert_eq!( + default.get("global_key").unwrap().as_str(), + Some("global_value") + ); + } + + #[test] + fn test_serialize_sections() { + let f = IniFormatter; + let mut section_map = HashMap::new(); + section_map.insert("host".to_string(), ConfigValue::String("localhost".into())); + section_map.insert("port".to_string(), ConfigValue::Integer(5432)); + let mut root = HashMap::new(); + root.insert("database".to_string(), ConfigValue::Object(section_map)); + let obj = ConfigValue::Object(root); + + let serialized = f.serialize(&obj).unwrap(); + assert!(serialized.contains("[database]")); + assert!(serialized.contains("host = localhost")); + assert!(serialized.contains("port = 5432")); + } + + #[test] + fn test_serialize_all_value_types() { + let f = IniFormatter; + let mut props = HashMap::new(); + props.insert("null_val".to_string(), ConfigValue::Null); + props.insert("bool_val".to_string(), ConfigValue::Bool(true)); + props.insert("int_val".to_string(), ConfigValue::Integer(42)); + props.insert("float_val".to_string(), ConfigValue::Float(3.15)); + props.insert("str_val".to_string(), ConfigValue::String("hello".into())); + props.insert( + "arr_val".to_string(), + ConfigValue::Array(vec![ConfigValue::Integer(1)]), + ); + let mut root = HashMap::new(); + root.insert("sect".to_string(), ConfigValue::Object(props)); + let obj = ConfigValue::Object(root); + + let serialized = f.serialize(&obj).unwrap(); + assert!(serialized.contains("[sect]")); + assert!(serialized.contains("bool_val = true")); + assert!(serialized.contains("int_val = 42")); + assert!(serialized.contains("float_val = 3.15")); + assert!(serialized.contains("str_val = hello")); + // Null serializes to empty string, array/object to empty string + assert!(serialized.contains("null_val = ")); + assert!(serialized.contains("arr_val = ")); + } + + #[test] + fn test_serialize_non_object_root() { + let f = IniFormatter; + // Non-object root produces empty output + let serialized = f.serialize(&ConfigValue::Integer(42)).unwrap(); + assert!(serialized.is_empty()); + } +} diff --git a/prefer/src/formatter/json.rs b/prefer/src/formatter/json.rs new file mode 100644 index 0000000..965d78a --- /dev/null +++ b/prefer/src/formatter/json.rs @@ -0,0 +1,252 @@ +//! JSON and JSON5 format support. + +use crate::error::{Error, Result}; +use crate::formatter::{extension_matches, Formatter}; +use crate::registry::RegisteredFormatter; +use crate::value::ConfigValue; +use std::collections::HashMap; + +inventory::submit! { RegisteredFormatter(&JsonFormatter) } + +/// Formatter for JSON, JSON5, and JSONC files. +/// +/// Uses the `jzon` crate (no serde dependency). JSON5/JSONC support is +/// best-effort — `jzon` handles many JSON5 cases but is not a full parser. +pub struct JsonFormatter; + +impl Formatter for JsonFormatter { + fn provides(&self, identifier: &str) -> bool { + extension_matches(identifier, self.extensions()) + } + + fn extensions(&self) -> &[&str] { + &["json", "json5", "jsonc"] + } + + fn deserialize(&self, content: &str) -> Result { + let value = jzon::parse(content).map_err(|e| Error::ParseError { + format: "JSON".to_string(), + path: std::path::PathBuf::from(""), + source: e.to_string().into(), + })?; + Ok(jzon_to_config_value(value)) + } + + fn serialize(&self, value: &ConfigValue) -> Result { + Ok(config_value_to_json(value)) + } + + fn name(&self) -> &str { + "json" + } +} + +fn jzon_to_config_value(value: jzon::JsonValue) -> ConfigValue { + use jzon::JsonValue; + + match &value { + JsonValue::Null => ConfigValue::Null, + JsonValue::Boolean(b) => ConfigValue::Bool(*b), + JsonValue::Number(_) => { + if let Some(i) = value.as_i64() { + ConfigValue::Integer(i) + } else if let Some(f) = value.as_f64() { + ConfigValue::Float(f) + } else { + unreachable!("jzon Number should always be parseable as i64 or f64") + } + } + JsonValue::Short(s) => ConfigValue::String(s.to_string()), + JsonValue::String(s) => ConfigValue::String(s.clone()), + JsonValue::Array(arr) => { + ConfigValue::Array(arr.iter().cloned().map(jzon_to_config_value).collect()) + } + JsonValue::Object(obj) => { + let map: HashMap = obj + .iter() + .map(|(k, v)| (k.to_string(), jzon_to_config_value(v.clone()))) + .collect(); + ConfigValue::Object(map) + } + } +} + +fn json_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn json_entry((k, v): (&String, &ConfigValue)) -> String { + format!("\"{}\":{}", json_escape(k), config_value_to_json(v)) +} + +fn config_value_to_json(value: &ConfigValue) -> String { + match value { + ConfigValue::Null => "null".to_string(), + ConfigValue::Bool(b) => b.to_string(), + ConfigValue::Integer(i) => i.to_string(), + ConfigValue::Float(f) => f.to_string(), + ConfigValue::String(s) => format!("\"{}\"", json_escape(s)), + ConfigValue::Array(arr) => { + let items: Vec = arr.iter().map(config_value_to_json).collect(); + format!("[{}]", items.join(",")) + } + ConfigValue::Object(map) => { + let entries: Vec = map.iter().map(json_entry).collect(); + format!("{{{}}}", entries.join(",")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_provides() { + let f = JsonFormatter; + assert!(f.provides("config.json")); + assert!(f.provides("config.json5")); + assert!(f.provides("config.jsonc")); + assert!(!f.provides("config.toml")); + assert!(!f.provides("config")); + } + + #[test] + fn test_deserialize_object() { + let f = JsonFormatter; + let result = f.deserialize(r#"{"name": "test", "port": 8080}"#).unwrap(); + assert_eq!(result.get("name").unwrap().as_str(), Some("test")); + assert_eq!(result.get("port").unwrap().as_i64(), Some(8080)); + } + + #[test] + fn test_deserialize_array() { + let f = JsonFormatter; + let result = f.deserialize("[1, 2, 3]").unwrap(); + assert_eq!(result.as_array().unwrap().len(), 3); + } + + #[test] + fn test_deserialize_error() { + let f = JsonFormatter; + assert!(f.deserialize("{invalid}").is_err()); + } + + #[test] + fn test_serialize_roundtrip() { + let f = JsonFormatter; + let original = f.deserialize(r#"{"key": "value"}"#).unwrap(); + let serialized = f.serialize(&original).unwrap(); + let restored = f.deserialize(&serialized).unwrap(); + assert_eq!(original, restored); + } + + #[test] + fn test_serialize_all_types() { + let f = JsonFormatter; + assert_eq!(f.serialize(&ConfigValue::Null).unwrap(), "null"); + assert_eq!(f.serialize(&ConfigValue::Bool(true)).unwrap(), "true"); + assert_eq!(f.serialize(&ConfigValue::Integer(42)).unwrap(), "42"); + assert_eq!(f.serialize(&ConfigValue::Float(1.5)).unwrap(), "1.5"); + assert_eq!( + f.serialize(&ConfigValue::String("hi".into())).unwrap(), + "\"hi\"" + ); + } + + #[test] + fn test_serialize_string_escaping() { + let f = JsonFormatter; + assert_eq!( + f.serialize(&ConfigValue::String("say \"hi\"".into())) + .unwrap(), + "\"say \\\"hi\\\"\"" + ); + assert_eq!( + f.serialize(&ConfigValue::String("back\\slash".into())) + .unwrap(), + "\"back\\\\slash\"" + ); + } + + #[test] + fn test_serialize_array() { + let f = JsonFormatter; + let arr = ConfigValue::Array(vec![ + ConfigValue::Integer(1), + ConfigValue::Bool(true), + ConfigValue::Null, + ]); + assert_eq!(f.serialize(&arr).unwrap(), "[1,true,null]"); + } + + #[test] + fn test_serialize_object() { + let f = JsonFormatter; + let mut map = HashMap::new(); + map.insert("key".to_string(), ConfigValue::String("value".into())); + let obj = ConfigValue::Object(map); + assert_eq!(f.serialize(&obj).unwrap(), "{\"key\":\"value\"}"); + } + + #[test] + fn test_serialize_object_key_escaping() { + let f = JsonFormatter; + let mut map = HashMap::new(); + map.insert("k\"ey".to_string(), ConfigValue::Integer(1)); + let obj = ConfigValue::Object(map); + let serialized = f.serialize(&obj).unwrap(); + assert!(serialized.contains("\"k\\\"ey\"")); + } + + #[test] + fn test_deserialize_null() { + let f = JsonFormatter; + let result = f.deserialize("null").unwrap(); + assert!(matches!(result, ConfigValue::Null)); + } + + #[test] + fn test_deserialize_boolean() { + let f = JsonFormatter; + let result = f.deserialize(r#"{"flag": true}"#).unwrap(); + assert_eq!(result.get("flag").unwrap().as_bool(), Some(true)); + + let result = f.deserialize(r#"{"flag": false}"#).unwrap(); + assert_eq!(result.get("flag").unwrap().as_bool(), Some(false)); + } + + #[test] + fn test_deserialize_long_string() { + let f = JsonFormatter; + // jzon uses String (heap) for strings longer than ~30 chars + let long = "a]".repeat(50); + let json = format!(r#"{{"val": "{}"}}"#, long); + let result = f.deserialize(&json).unwrap(); + assert_eq!(result.get("val").unwrap().as_str(), Some(long.as_str())); + } + + #[test] + fn test_deserialize_float() { + let f = JsonFormatter; + let result = f.deserialize("3.15").unwrap(); + assert_eq!(result.as_f64(), Some(3.15)); + } + + #[test] + fn test_deserialize_nested_object() { + let f = JsonFormatter; + let result = f.deserialize(r#"{"a": {"b": {"c": 1}}}"#).unwrap(); + assert_eq!( + result + .get("a") + .unwrap() + .get("b") + .unwrap() + .get("c") + .unwrap() + .as_i64(), + Some(1) + ); + } +} diff --git a/prefer/src/formatter/mod.rs b/prefer/src/formatter/mod.rs new file mode 100644 index 0000000..0967fb4 --- /dev/null +++ b/prefer/src/formatter/mod.rs @@ -0,0 +1,124 @@ +//! Configuration format abstraction. +//! +//! The `Formatter` trait separates format parsing from source loading. +//! Each formatter declares what file extensions it handles via `provides()` +//! and `extensions()`, and is discovered automatically through the registry. +//! +//! Built-in formatters: +//! - `JsonFormatter` — `.json`, `.json5`, `.jsonc` +//! - `YamlFormatter` — `.yaml`, `.yml` +//! - `TomlFormatter` — `.toml` +//! - `IniFormatter` — `.ini` (behind `ini` feature) +//! - `XmlFormatter` — `.xml` (behind `xml` feature) + +pub mod json; +pub mod toml; +pub mod yaml; + +#[cfg(feature = "ini")] +pub mod ini; + +#[cfg(feature = "xml")] +pub mod xml; + +use crate::error::Result; +use crate::value::ConfigValue; +use std::path::Path; + +/// A format parser/serializer for configuration data. +/// +/// Formatters are stateless — they parse raw content strings into +/// `ConfigValue` and serialize `ConfigValue` back to strings. +/// +/// # Implementing a Formatter +/// +/// ```ignore +/// use prefer::formatter::Formatter; +/// use prefer::{ConfigValue, Result}; +/// +/// struct MyFormatter; +/// +/// impl Formatter for MyFormatter { +/// fn provides(&self, identifier: &str) -> bool { +/// extension_matches(identifier, self.extensions()) +/// } +/// +/// fn extensions(&self) -> &[&str] { +/// &["myformat", "mf"] +/// } +/// +/// fn deserialize(&self, content: &str) -> Result { +/// // parse content into ConfigValue +/// todo!() +/// } +/// +/// fn serialize(&self, value: &ConfigValue) -> Result { +/// // serialize ConfigValue to string +/// todo!() +/// } +/// +/// fn name(&self) -> &str { +/// "my-format" +/// } +/// } +/// ``` +pub trait Formatter: Send + Sync + 'static { + /// Whether this formatter can handle the given source identifier. + /// + /// Typically checks the file extension against `extensions()`. + fn provides(&self, identifier: &str) -> bool; + + /// File extensions this formatter handles (without the leading dot). + /// + /// For example: `["json", "json5", "jsonc"]`. + fn extensions(&self) -> &[&str]; + + /// Parse a content string into a `ConfigValue`. + fn deserialize(&self, content: &str) -> Result; + + /// Serialize a `ConfigValue` back to a string. + fn serialize(&self, value: &ConfigValue) -> Result; + + /// Human-readable name for error messages. + fn name(&self) -> &str; +} + +/// Check whether an identifier's file extension matches any of the given extensions. +/// +/// This is a utility for `Formatter::provides()` implementations. +pub fn extension_matches(identifier: &str, extensions: &[&str]) -> bool { + let path = Path::new(identifier); + let ext = match path.extension().and_then(|e| e.to_str()) { + Some(e) => e, + None => return false, + }; + extensions.contains(&ext) +} + +/// Check whether a format hint string matches any of the given extensions. +/// +/// Used when matching by format hint rather than file extension. +pub fn hint_matches(hint: &str, extensions: &[&str]) -> bool { + extensions.contains(&hint) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extension_matches() { + assert!(extension_matches("config.json", &["json", "json5"])); + assert!(extension_matches("config.json5", &["json", "json5"])); + assert!(!extension_matches("config.toml", &["json", "json5"])); + assert!(!extension_matches("no_extension", &["json"])); + } + + #[test] + fn test_hint_matches() { + assert!(hint_matches("json", &["json", "json5", "jsonc"])); + assert!(hint_matches("toml", &["toml"])); + assert!(!hint_matches("bson", &["json", "toml"])); + assert!(!hint_matches("", &["json"])); + } +} diff --git a/prefer/src/formatter/toml.rs b/prefer/src/formatter/toml.rs new file mode 100644 index 0000000..8015b73 --- /dev/null +++ b/prefer/src/formatter/toml.rs @@ -0,0 +1,296 @@ +//! TOML format support. + +use crate::error::{Error, Result}; +use crate::formatter::{extension_matches, Formatter}; +use crate::registry::RegisteredFormatter; +use crate::value::ConfigValue; +use std::collections::HashMap; + +inventory::submit! { RegisteredFormatter(&TomlFormatter) } + +/// Formatter for TOML files. +/// +/// Uses the `toml_edit` crate (no serde dependency). Handles datetimes +/// by converting to strings, and supports inline tables and array-of-tables. +pub struct TomlFormatter; + +impl Formatter for TomlFormatter { + fn provides(&self, identifier: &str) -> bool { + extension_matches(identifier, self.extensions()) + } + + fn extensions(&self) -> &[&str] { + &["toml"] + } + + fn deserialize(&self, content: &str) -> Result { + use toml_edit::DocumentMut; + + let doc: DocumentMut = + content + .parse() + .map_err(|e: toml_edit::TomlError| Error::ParseError { + format: "TOML".to_string(), + path: std::path::PathBuf::from(""), + source: e.to_string().into(), + })?; + + Ok(toml_item_to_config_value(doc.as_item())) + } + + fn serialize(&self, value: &ConfigValue) -> Result { + Ok(config_value_to_toml(value, "")) + } + + fn name(&self) -> &str { + "toml" + } +} + +fn toml_item_to_config_value(item: &toml_edit::Item) -> ConfigValue { + use toml_edit::Item; + + match item { + Item::None => unreachable!("Item::None should not occur when iterating parsed TOML"), + Item::Value(v) => toml_value_to_config_value(v), + Item::Table(t) => { + let map: HashMap = t + .iter() + .map(|(k, v)| (k.to_string(), toml_item_to_config_value(v))) + .collect(); + ConfigValue::Object(map) + } + Item::ArrayOfTables(arr) => ConfigValue::Array( + arr.iter() + .map(|t| { + let map: HashMap = t + .iter() + .map(|(k, v)| (k.to_string(), toml_item_to_config_value(v))) + .collect(); + ConfigValue::Object(map) + }) + .collect(), + ), + } +} + +fn toml_value_to_config_value(value: &toml_edit::Value) -> ConfigValue { + use toml_edit::Value; + + match value { + Value::String(s) => ConfigValue::String(s.value().to_string()), + Value::Integer(i) => ConfigValue::Integer(*i.value()), + Value::Float(f) => ConfigValue::Float(*f.value()), + Value::Boolean(b) => ConfigValue::Bool(*b.value()), + Value::Datetime(dt) => ConfigValue::String(dt.to_string()), + Value::Array(arr) => { + ConfigValue::Array(arr.iter().map(toml_value_to_config_value).collect()) + } + Value::InlineTable(t) => { + let map: HashMap = t + .iter() + .map(|(k, v)| (k.to_string(), toml_value_to_config_value(v))) + .collect(); + ConfigValue::Object(map) + } + } +} + +fn toml_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn toml_full_key(prefix: &str, key: &str) -> String { + if prefix.is_empty() { + key.to_string() + } else { + format!("{}.{}", prefix, key) + } +} + +fn config_value_to_toml(value: &ConfigValue, key_prefix: &str) -> String { + match value { + ConfigValue::Null => "\"\"".to_string(), + ConfigValue::Bool(b) => b.to_string(), + ConfigValue::Integer(i) => i.to_string(), + ConfigValue::Float(f) => f.to_string(), + ConfigValue::String(s) => format!("\"{}\"", toml_escape(s)), + ConfigValue::Array(arr) => { + let items: Vec = arr + .iter() + .map(|v| config_value_to_toml(v, key_prefix)) + .collect(); + format!("[{}]", items.join(", ")) + } + ConfigValue::Object(map) => { + let mut lines = Vec::new(); + let mut tables: Vec<(String, &HashMap)> = Vec::new(); + + for (k, v) in map { + let full_key = toml_full_key(key_prefix, k); + + if let ConfigValue::Object(inner) = v { + tables.push((full_key, inner)); + } else { + lines.push(format!("{} = {}", k, config_value_to_toml(v, &full_key))); + } + } + + for (full_key, inner) in tables { + lines.push(format!("\n[{}]", full_key)); + for (ik, iv) in inner { + let inner_key = toml_full_key(&full_key, ik); + lines.push(format!("{} = {}", ik, config_value_to_toml(iv, &inner_key))); + } + } + + lines.join("\n") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_provides() { + let f = TomlFormatter; + assert!(f.provides("config.toml")); + assert!(!f.provides("config.json")); + assert!(!f.provides("config.yaml")); + } + + #[test] + fn test_deserialize() { + let f = TomlFormatter; + let result = f.deserialize("name = \"test\"\nport = 8080").unwrap(); + assert_eq!(result.get("name").unwrap().as_str(), Some("test")); + assert_eq!(result.get("port").unwrap().as_i64(), Some(8080)); + } + + #[test] + fn test_deserialize_error() { + let f = TomlFormatter; + assert!(f.deserialize("[invalid").is_err()); + } + + #[test] + fn test_serialize_simple() { + let f = TomlFormatter; + assert_eq!(f.serialize(&ConfigValue::Integer(42)).unwrap(), "42"); + assert_eq!(f.serialize(&ConfigValue::Bool(true)).unwrap(), "true"); + } + + #[test] + fn test_serialize_all_scalar_types() { + let f = TomlFormatter; + assert_eq!(f.serialize(&ConfigValue::Null).unwrap(), "\"\""); + assert_eq!(f.serialize(&ConfigValue::Float(3.15)).unwrap(), "3.15"); + assert_eq!( + f.serialize(&ConfigValue::String("hello".into())).unwrap(), + "\"hello\"" + ); + } + + #[test] + fn test_serialize_string_escaping() { + let f = TomlFormatter; + assert_eq!( + f.serialize(&ConfigValue::String("say \"hi\"".into())) + .unwrap(), + "\"say \\\"hi\\\"\"" + ); + assert_eq!( + f.serialize(&ConfigValue::String("back\\slash".into())) + .unwrap(), + "\"back\\\\slash\"" + ); + } + + #[test] + fn test_serialize_array() { + let f = TomlFormatter; + let arr = ConfigValue::Array(vec![ + ConfigValue::Integer(1), + ConfigValue::Integer(2), + ConfigValue::Integer(3), + ]); + assert_eq!(f.serialize(&arr).unwrap(), "[1, 2, 3]"); + } + + #[test] + fn test_serialize_object_flat() { + let f = TomlFormatter; + let mut map = HashMap::new(); + map.insert("port".to_string(), ConfigValue::Integer(8080)); + let obj = ConfigValue::Object(map); + let serialized = f.serialize(&obj).unwrap(); + assert!(serialized.contains("port = 8080")); + } + + #[test] + fn test_serialize_object_nested() { + let f = TomlFormatter; + let mut inner = HashMap::new(); + inner.insert("host".to_string(), ConfigValue::String("localhost".into())); + let mut outer = HashMap::new(); + outer.insert("server".to_string(), ConfigValue::Object(inner)); + let obj = ConfigValue::Object(outer); + let serialized = f.serialize(&obj).unwrap(); + assert!(serialized.contains("[server]")); + assert!(serialized.contains("host = \"localhost\"")); + } + + #[test] + fn test_deserialize_array_of_tables() { + let f = TomlFormatter; + let toml = r#" +[[servers]] +name = "alpha" +port = 8080 + +[[servers]] +name = "beta" +port = 9090 +"#; + let result = f.deserialize(toml).unwrap(); + let servers = result.get("servers").unwrap().as_array().unwrap(); + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].get("name").unwrap().as_str(), Some("alpha")); + assert_eq!(servers[1].get("port").unwrap().as_i64(), Some(9090)); + } + + #[test] + fn test_deserialize_inline_table() { + let f = TomlFormatter; + let result = f.deserialize(r#"point = { x = 1, y = 2 }"#).unwrap(); + let point = result.get("point").unwrap(); + assert_eq!(point.get("x").unwrap().as_i64(), Some(1)); + assert_eq!(point.get("y").unwrap().as_i64(), Some(2)); + } + + #[test] + fn test_deserialize_all_value_types() { + let f = TomlFormatter; + let toml = r#" +bool_val = true +int_val = 42 +float_val = 3.15 +str_val = "hello" +array_val = [1, 2, 3] +date_val = 2024-01-15 +"#; + let result = f.deserialize(toml).unwrap(); + assert_eq!(result.get("bool_val").unwrap().as_bool(), Some(true)); + assert_eq!(result.get("int_val").unwrap().as_i64(), Some(42)); + assert_eq!(result.get("float_val").unwrap().as_f64(), Some(3.15)); + assert_eq!(result.get("str_val").unwrap().as_str(), Some("hello")); + assert_eq!( + result.get("array_val").unwrap().as_array().unwrap().len(), + 3 + ); + // Datetimes become strings + assert!(result.get("date_val").unwrap().as_str().is_some()); + } +} diff --git a/prefer/src/formatter/xml.rs b/prefer/src/formatter/xml.rs new file mode 100644 index 0000000..42eb5ed --- /dev/null +++ b/prefer/src/formatter/xml.rs @@ -0,0 +1,263 @@ +//! XML format support. + +use crate::error::{Error, Result}; +use crate::formatter::{extension_matches, Formatter}; +use crate::registry::RegisteredFormatter; +use crate::value::ConfigValue; +use std::collections::HashMap; + +inventory::submit! { RegisteredFormatter(&XmlFormatter) } + +/// Formatter for XML files. +/// +/// Uses the `roxmltree` crate (no serde dependency). Attributes are prefixed +/// with `@`, text content goes to `#text` when mixed with elements, and +/// repeated elements become arrays. +pub struct XmlFormatter; + +impl Formatter for XmlFormatter { + fn provides(&self, identifier: &str) -> bool { + extension_matches(identifier, self.extensions()) + } + + fn extensions(&self) -> &[&str] { + &["xml"] + } + + fn deserialize(&self, content: &str) -> Result { + let doc = roxmltree::Document::parse(content).map_err(|e| Error::ParseError { + format: "XML".to_string(), + path: std::path::PathBuf::from(""), + source: e.to_string().into(), + })?; + + Ok(xml_node_to_config_value(doc.root_element())) + } + + fn serialize(&self, value: &ConfigValue) -> Result { + Ok(format!( + "\n{}", + config_value_to_xml(value) + )) + } + + fn name(&self) -> &str { + "xml" + } +} + +fn xml_node_to_config_value(node: roxmltree::Node) -> ConfigValue { + let mut map: HashMap = HashMap::new(); + + for attr in node.attributes() { + map.insert( + format!("@{}", attr.name()), + ConfigValue::String(attr.value().to_string()), + ); + } + + let mut children: HashMap> = HashMap::new(); + let mut text_content = String::new(); + + for child in node.children() { + if child.is_element() { + let name = child.tag_name().name().to_string(); + children + .entry(name) + .or_default() + .push(xml_node_to_config_value(child)); + } else if child.is_text() { + let text = child.text().unwrap_or("").trim(); + if !text.is_empty() { + text_content.push_str(text); + } + } + } + + for (name, values) in children { + if values.len() == 1 { + map.insert(name, values.into_iter().next().unwrap()); + } else { + map.insert(name, ConfigValue::Array(values)); + } + } + + match (map.is_empty(), text_content.is_empty()) { + (true, false) => parse_xml_text(text_content), + (false, false) => { + map.insert("#text".to_string(), ConfigValue::String(text_content)); + ConfigValue::Object(map) + } + _ => ConfigValue::Object(map), + } +} + +fn parse_xml_text(text: String) -> ConfigValue { + if let Ok(i) = text.parse::() { + return ConfigValue::Integer(i); + } + if let Ok(f) = text.parse::() { + return ConfigValue::Float(f); + } + if let Ok(b) = text.parse::() { + return ConfigValue::Bool(b); + } + ConfigValue::String(text) +} + +fn config_value_to_xml(value: &ConfigValue) -> String { + match value { + ConfigValue::Null => String::new(), + ConfigValue::Bool(b) => b.to_string(), + ConfigValue::Integer(i) => i.to_string(), + ConfigValue::Float(f) => f.to_string(), + ConfigValue::String(s) => s.clone(), + ConfigValue::Array(arr) => arr.iter().map(config_value_to_xml).collect::(), + ConfigValue::Object(map) => { + let mut parts = Vec::new(); + for (k, v) in map { + if k.starts_with('@') || k == "#text" { + continue; + } + parts.push(format!("<{}>{}", k, config_value_to_xml(v), k)); + } + parts.join("") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_provides() { + let f = XmlFormatter; + assert!(f.provides("config.xml")); + assert!(!f.provides("config.json")); + } + + #[test] + fn test_deserialize() { + let f = XmlFormatter; + let result = f + .deserialize(r#"test8080"#) + .unwrap(); + assert_eq!(result.get("name").unwrap().as_str(), Some("test")); + assert_eq!(result.get("port").unwrap().as_i64(), Some(8080)); + } + + #[test] + fn test_deserialize_attributes() { + let f = XmlFormatter; + let result = f.deserialize(r#"text"#).unwrap(); + assert_eq!(result.get("@id").unwrap().as_str(), Some("123")); + assert_eq!(result.get("#text").unwrap().as_str(), Some("text")); + } + + #[test] + fn test_deserialize_error() { + let f = XmlFormatter; + assert!(f.deserialize("").is_err()); + } + + #[test] + fn test_deserialize_repeated_elements_become_array() { + let f = XmlFormatter; + let xml = r#"abc"#; + let result = f.deserialize(xml).unwrap(); + let items = result.get("item").unwrap().as_array().unwrap(); + assert_eq!(items.len(), 3); + } + + #[test] + fn test_deserialize_float_text() { + let f = XmlFormatter; + let xml = r#"3.15"#; + let result = f.deserialize(xml).unwrap(); + assert_eq!(result.get("pi").unwrap().as_f64(), Some(3.15)); + } + + #[test] + fn test_deserialize_bool_text() { + let f = XmlFormatter; + let xml = r#"true"#; + let result = f.deserialize(xml).unwrap(); + assert_eq!(result.get("enabled").unwrap().as_bool(), Some(true)); + } + + #[test] + fn test_deserialize_empty_element() { + let f = XmlFormatter; + let xml = r#""#; + let result = f.deserialize(xml).unwrap(); + // Empty element becomes an empty object + assert!(result.get("empty").unwrap().as_object().is_some()); + } + + #[test] + fn test_serialize_all_scalar_types() { + let f = XmlFormatter; + let serialized = f.serialize(&ConfigValue::Null).unwrap(); + assert!(serialized.contains("")); + + let serialized = f.serialize(&ConfigValue::Bool(true)).unwrap(); + assert!(serialized.contains("true")); + + let serialized = f.serialize(&ConfigValue::Integer(42)).unwrap(); + assert!(serialized.contains("42")); + + let serialized = f.serialize(&ConfigValue::Float(3.15)).unwrap(); + assert!(serialized.contains("3.15")); + + let serialized = f.serialize(&ConfigValue::String("hello".into())).unwrap(); + assert!(serialized.contains("hello")); + } + + #[test] + fn test_serialize_array() { + let f = XmlFormatter; + let arr = ConfigValue::Array(vec![ + ConfigValue::String("a".into()), + ConfigValue::String("b".into()), + ]); + let serialized = f.serialize(&arr).unwrap(); + assert!(serialized.contains("ab")); + } + + #[test] + fn test_serialize_object() { + let f = XmlFormatter; + let mut map = HashMap::new(); + map.insert("name".to_string(), ConfigValue::String("test".into())); + let obj = ConfigValue::Object(map); + let serialized = f.serialize(&obj).unwrap(); + assert!(serialized.contains("test")); + } + + #[test] + fn test_serialize_object_skips_attrs_and_text() { + let f = XmlFormatter; + let mut map = HashMap::new(); + map.insert("@id".to_string(), ConfigValue::String("123".into())); + map.insert("#text".to_string(), ConfigValue::String("body".into())); + map.insert("child".to_string(), ConfigValue::String("val".into())); + let obj = ConfigValue::Object(map); + let serialized = f.serialize(&obj).unwrap(); + assert!(serialized.contains("val")); + assert!(!serialized.contains("<@id>")); + assert!(!serialized.contains("<#text>")); + } + + #[test] + fn test_serialize_nested_object() { + let f = XmlFormatter; + let mut inner = HashMap::new(); + inner.insert("host".to_string(), ConfigValue::String("localhost".into())); + let mut outer = HashMap::new(); + outer.insert("server".to_string(), ConfigValue::Object(inner)); + let obj = ConfigValue::Object(outer); + let serialized = f.serialize(&obj).unwrap(); + assert!(serialized.contains("localhost")); + } +} diff --git a/prefer/src/formatter/yaml.rs b/prefer/src/formatter/yaml.rs new file mode 100644 index 0000000..e26eb0d --- /dev/null +++ b/prefer/src/formatter/yaml.rs @@ -0,0 +1,275 @@ +//! YAML format support. + +use crate::error::{Error, Result}; +use crate::formatter::{extension_matches, Formatter}; +use crate::registry::RegisteredFormatter; +use crate::value::ConfigValue; +use std::collections::HashMap; + +inventory::submit! { RegisteredFormatter(&YamlFormatter) } + +/// Formatter for YAML files. +/// +/// Uses the `yaml-rust2` crate (no serde dependency). Handles multi-document +/// files by using the first document. Converts non-string keys to strings. +pub struct YamlFormatter; + +impl Formatter for YamlFormatter { + fn provides(&self, identifier: &str) -> bool { + extension_matches(identifier, self.extensions()) + } + + fn extensions(&self) -> &[&str] { + &["yaml", "yml"] + } + + fn deserialize(&self, content: &str) -> Result { + use yaml_rust2::YamlLoader; + + let docs = YamlLoader::load_from_str(content).map_err(|e| Error::ParseError { + format: "YAML".to_string(), + path: std::path::PathBuf::from(""), + source: e.to_string().into(), + })?; + + match docs.into_iter().next() { + Some(doc) => Ok(yaml_to_config_value(doc)), + None => Ok(ConfigValue::Object(HashMap::new())), + } + } + + fn serialize(&self, value: &ConfigValue) -> Result { + Ok(config_value_to_yaml(value, 0)) + } + + fn name(&self) -> &str { + "yaml" + } +} + +fn yaml_to_config_value(yaml: yaml_rust2::Yaml) -> ConfigValue { + use yaml_rust2::Yaml; + + match yaml { + Yaml::Null | Yaml::BadValue => ConfigValue::Null, + Yaml::Boolean(b) => ConfigValue::Bool(b), + Yaml::Integer(i) => ConfigValue::Integer(i), + Yaml::Real(s) => s + .parse::() + .map(ConfigValue::Float) + .unwrap_or(ConfigValue::String(s)), + Yaml::String(s) => ConfigValue::String(s), + Yaml::Array(arr) => ConfigValue::Array(arr.into_iter().map(yaml_to_config_value).collect()), + Yaml::Hash(map) => { + let obj: HashMap = map + .into_iter() + .filter_map(|(k, v)| { + let key = match k { + Yaml::String(s) => s, + Yaml::Integer(i) => i.to_string(), + Yaml::Real(r) => r, + Yaml::Boolean(b) => b.to_string(), + _ => return None, + }; + Some((key, yaml_to_config_value(v))) + }) + .collect(); + ConfigValue::Object(obj) + } + Yaml::Alias(_) => { + unreachable!("YAML aliases are resolved by parser before reaching this code") + } + } +} + +fn config_value_to_yaml(value: &ConfigValue, indent: usize) -> String { + let prefix = " ".repeat(indent); + match value { + ConfigValue::Null => "null".to_string(), + ConfigValue::Bool(b) => b.to_string(), + ConfigValue::Integer(i) => i.to_string(), + ConfigValue::Float(f) => f.to_string(), + ConfigValue::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")), + ConfigValue::Array(arr) => { + if arr.is_empty() { + return "[]".to_string(); + } + let items: Vec = arr + .iter() + .map(|v| format!("{}- {}", prefix, config_value_to_yaml(v, indent + 1))) + .collect(); + format!("\n{}", items.join("\n")) + } + ConfigValue::Object(map) => { + if map.is_empty() { + return "{}".to_string(); + } + let entries: Vec = map + .iter() + .map(|(k, v)| { + let val = config_value_to_yaml(v, indent + 1); + if matches!(v, ConfigValue::Object(_) | ConfigValue::Array(_)) + && val.starts_with('\n') + { + format!("{}{}:{}", prefix, k, val) + } else { + format!("{}{}: {}", prefix, k, val) + } + }) + .collect(); + format!("\n{}", entries.join("\n")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_provides() { + let f = YamlFormatter; + assert!(f.provides("config.yaml")); + assert!(f.provides("config.yml")); + assert!(!f.provides("config.json")); + } + + #[test] + fn test_deserialize() { + let f = YamlFormatter; + let result = f.deserialize("name: test\nport: 8080").unwrap(); + assert_eq!(result.get("name").unwrap().as_str(), Some("test")); + assert_eq!(result.get("port").unwrap().as_i64(), Some(8080)); + } + + #[test] + fn test_deserialize_empty() { + let f = YamlFormatter; + let result = f.deserialize("").unwrap(); + assert!(result.as_object().is_some()); + } + + #[test] + fn test_deserialize_error() { + let f = YamlFormatter; + assert!(f.deserialize("---\n- :\n a: [}").is_err()); + } + + #[test] + fn test_serialize_simple() { + let f = YamlFormatter; + let serialized = f.serialize(&ConfigValue::Integer(42)).unwrap(); + assert_eq!(serialized, "42"); + } + + #[test] + fn test_serialize_all_scalar_types() { + let f = YamlFormatter; + assert_eq!(f.serialize(&ConfigValue::Null).unwrap(), "null"); + assert_eq!(f.serialize(&ConfigValue::Bool(false)).unwrap(), "false"); + assert_eq!(f.serialize(&ConfigValue::Float(2.5)).unwrap(), "2.5"); + assert_eq!( + f.serialize(&ConfigValue::String("test".into())).unwrap(), + "\"test\"" + ); + } + + #[test] + fn test_serialize_string_escaping() { + let f = YamlFormatter; + assert_eq!( + f.serialize(&ConfigValue::String("say \"hi\"".into())) + .unwrap(), + "\"say \\\"hi\\\"\"" + ); + } + + #[test] + fn test_serialize_empty_array() { + let f = YamlFormatter; + let arr = ConfigValue::Array(vec![]); + assert_eq!(f.serialize(&arr).unwrap(), "[]"); + } + + #[test] + fn test_serialize_array() { + let f = YamlFormatter; + let arr = ConfigValue::Array(vec![ConfigValue::Integer(1), ConfigValue::Integer(2)]); + let serialized = f.serialize(&arr).unwrap(); + assert!(serialized.contains("- 1")); + assert!(serialized.contains("- 2")); + } + + #[test] + fn test_serialize_empty_object() { + let f = YamlFormatter; + let obj = ConfigValue::Object(HashMap::new()); + assert_eq!(f.serialize(&obj).unwrap(), "{}"); + } + + #[test] + fn test_serialize_object() { + let f = YamlFormatter; + let mut map = HashMap::new(); + map.insert("port".to_string(), ConfigValue::Integer(8080)); + let obj = ConfigValue::Object(map); + let serialized = f.serialize(&obj).unwrap(); + assert!(serialized.contains("port: 8080")); + } + + #[test] + fn test_serialize_nested_object() { + let f = YamlFormatter; + let mut inner = HashMap::new(); + inner.insert("host".to_string(), ConfigValue::String("localhost".into())); + let mut outer = HashMap::new(); + outer.insert("server".to_string(), ConfigValue::Object(inner)); + let obj = ConfigValue::Object(outer); + let serialized = f.serialize(&obj).unwrap(); + assert!(serialized.contains("server:")); + assert!(serialized.contains("host: \"localhost\"")); + } + + #[test] + fn test_serialize_nested_array_in_object() { + let f = YamlFormatter; + let mut map = HashMap::new(); + map.insert( + "items".to_string(), + ConfigValue::Array(vec![ConfigValue::Integer(1)]), + ); + let obj = ConfigValue::Object(map); + let serialized = f.serialize(&obj).unwrap(); + assert!(serialized.contains("items:")); + assert!(serialized.contains("- 1")); + } + + #[test] + fn test_deserialize_all_value_types() { + let f = YamlFormatter; + let yaml = "null_val: null\nbool_val: true\nint_val: 42\nfloat_val: 3.15\nstr_val: hello"; + let result = f.deserialize(yaml).unwrap(); + assert!(matches!(result.get("null_val").unwrap(), ConfigValue::Null)); + assert_eq!(result.get("bool_val").unwrap().as_bool(), Some(true)); + assert_eq!(result.get("int_val").unwrap().as_i64(), Some(42)); + assert_eq!(result.get("float_val").unwrap().as_f64(), Some(3.15)); + assert_eq!(result.get("str_val").unwrap().as_str(), Some("hello")); + } + + #[test] + fn test_deserialize_non_string_keys() { + let f = YamlFormatter; + let yaml = "42: int_key\ntrue: bool_key\n3.15: float_key"; + let result = f.deserialize(yaml).unwrap(); + assert_eq!(result.get("42").unwrap().as_str(), Some("int_key")); + assert_eq!(result.get("true").unwrap().as_str(), Some("bool_key")); + assert_eq!(result.get("3.15").unwrap().as_str(), Some("float_key")); + } + + #[test] + fn test_deserialize_array() { + let f = YamlFormatter; + let result = f.deserialize("- 1\n- 2\n- 3").unwrap(); + assert_eq!(result.as_array().unwrap().len(), 3); + } +} diff --git a/prefer/src/lib.rs b/prefer/src/lib.rs index 8b1bf74..dbb675d 100644 --- a/prefer/src/lib.rs +++ b/prefer/src/lib.rs @@ -1,152 +1,176 @@ -//! # prefer -//! -//! A lightweight library for managing application configurations with support for multiple file formats. -//! -//! `prefer` helps you manage application configurations while providing users the flexibility -//! of using whatever configuration format fits their needs. -//! -//! ## no_std Support -//! -//! This crate supports `no_std` environments with `alloc`. The core types (`ConfigValue`, `FromValue`, -//! `ValueVisitor`) work without std. Enable the `std` feature for file I/O, async loading, and format parsers. -//! -//! ## Features -//! -//! - **no_std compatible**: Core types work with just `alloc` -//! - **Format-agnostic**: Supports JSON, JSON5, YAML, TOML, INI, and XML (with `std`) -//! - **Automatic discovery**: Searches standard system paths for configuration files (with `std`) -//! - **Async by design**: Non-blocking operations for file I/O (with `std`) -//! - **File watching**: Monitor configuration files for changes (with `std`) -//! - **Dot-notation access**: Access nested values with `"auth.username"` -//! - **No serde required**: Uses a lightweight `FromValue` trait instead -//! -//! ## Examples -//! -//! ```no_run -//! # #[cfg(feature = "std")] -//! # { -//! use prefer::load; -//! -//! #[tokio::main] -//! async fn main() -> prefer::Result<()> { -//! // Load configuration from any supported format -//! let config = load("settings").await?; -//! -//! // Access values using dot notation -//! let username: String = config.get("auth.username")?; -//! println!("Username: {}", username); -//! -//! Ok(()) -//! } -//! # } -//! ``` - -#![cfg_attr(not(feature = "std"), no_std)] - -#[cfg(not(feature = "std"))] -extern crate alloc; - -#[cfg(feature = "std")] -pub mod builder; -#[cfg(feature = "std")] -pub mod config; -#[cfg(feature = "std")] -pub mod discovery; -pub mod error; -#[cfg(feature = "std")] -pub mod formats; -#[cfg(feature = "std")] -pub mod source; -pub mod value; -pub mod visitor; -#[cfg(feature = "std")] -pub mod watch; - -// Core types (always available) -pub use error::{Error, Result}; -pub use value::{ConfigValue, FromValue}; -pub use visitor::{SeqAccess, ValueVisitor}; - -// std-dependent types -#[cfg(feature = "std")] -pub use builder::ConfigBuilder; -#[cfg(feature = "std")] -pub use config::Config; -#[cfg(feature = "std")] -pub use source::{EnvSource, FileSource, LayeredSource, MemorySource, Source}; - -// Re-export the derive macro when the feature is enabled -#[cfg(feature = "derive")] -pub use prefer_derive::FromValue; - -/// Load a configuration file by name. -/// -/// This function searches standard system paths for a configuration file -/// matching the given name with any supported extension. The first file -/// found is loaded and parsed according to its format. -/// -/// # Arguments -/// -/// * `name` - The base name of the configuration file (without path or extension) -/// -/// # Returns -/// -/// A `Config` instance containing the parsed configuration data. -/// -/// # Examples -/// -/// ```no_run -/// # #[cfg(feature = "std")] -/// # { -/// use prefer::load; -/// -/// #[tokio::main] -/// async fn main() -> prefer::Result<()> { -/// let config = load("myapp").await?; -/// let value: String = config.get("some.key")?; -/// Ok(()) -/// } -/// # } -/// ``` -#[cfg(feature = "std")] -pub async fn load(name: &str) -> Result { - Config::load(name).await -} - -/// Watch a configuration file for changes. -/// -/// Returns a stream that yields new `Config` instances whenever the -/// configuration file is modified on disk. -/// -/// # Arguments -/// -/// * `name` - The base name of the configuration file (without path or extension) -/// -/// # Returns -/// -/// A receiver channel that yields `Config` instances when the file changes. -/// -/// # Examples -/// -/// ```no_run -/// # #[cfg(feature = "std")] -/// # { -/// use prefer::watch; -/// -/// #[tokio::main] -/// async fn main() -> prefer::Result<()> { -/// let mut receiver = watch("myapp").await?; -/// -/// while let Some(config) = receiver.recv().await { -/// println!("Configuration updated!"); -/// let value: String = config.get("some.key")?; -/// } -/// -/// Ok(()) -/// } -/// # } -/// ``` -#[cfg(feature = "std")] -pub async fn watch(name: &str) -> Result> { - watch::watch(name).await -} +//! # prefer +//! +//! A lightweight library for managing application configurations with support for multiple file formats. +//! +//! `prefer` helps you manage application configurations while providing users the flexibility +//! of using whatever configuration format fits their needs. +//! +//! ## no_std Support +//! +//! This crate supports `no_std` environments with `alloc`. The core types (`ConfigValue`, `FromValue`, +//! `ValueVisitor`) work without std. Enable the `std` feature for file I/O, async loading, and format parsers. +//! +//! ## Features +//! +//! - **no_std compatible**: Core types work with just `alloc` +//! - **Format-agnostic**: Supports JSON, JSON5, YAML, TOML, INI, and XML (with `std`) +//! - **Automatic discovery**: Searches standard system paths for configuration files (with `std`) +//! - **Async by design**: Non-blocking operations for file I/O (with `std`) +//! - **File watching**: Monitor configuration files for changes (with `std`) +//! - **Dot-notation access**: Access nested values with `"auth.username"` +//! - **No serde required**: Uses a lightweight `FromValue` trait instead +//! +//! ## Examples +//! +//! ```no_run +//! # #[cfg(feature = "std")] +//! # { +//! use prefer::load; +//! +//! #[tokio::main] +//! async fn main() -> prefer::Result<()> { +//! // Load configuration from any supported format +//! let config = load("settings").await?; +//! +//! // Access values using dot notation +//! let username: String = config.get("auth.username")?; +//! println!("Username: {}", username); +//! +//! Ok(()) +//! } +//! # } +//! ``` + +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(not(feature = "std"))] +extern crate alloc; + +#[cfg(feature = "std")] +pub mod builder; +#[cfg(feature = "std")] +pub mod config; +#[cfg(feature = "std")] +pub mod discovery; +pub mod error; +#[cfg(feature = "std")] +pub mod events; +#[cfg(feature = "std")] +pub mod formats; +#[cfg(feature = "std")] +pub mod formatter; +#[cfg(feature = "std")] +pub mod loader; +#[cfg(feature = "std")] +pub mod registry; +#[cfg(feature = "std")] +pub mod source; +pub mod value; +pub mod visitor; +#[cfg(feature = "std")] +pub mod watch; + +// Core types (always available) +pub use error::{Error, Result}; +pub use value::{ConfigValue, FromValue}; +pub use visitor::{SeqAccess, ValueVisitor}; + +// std-dependent types +#[cfg(feature = "std")] +pub use builder::ConfigBuilder; +#[cfg(feature = "std")] +pub use config::Config; +#[cfg(feature = "std")] +#[allow(deprecated)] +pub use source::{EnvSource, FileSource, LayeredSource, MemorySource, Source}; + +// Re-export the derive macro when the feature is enabled +#[cfg(feature = "derive")] +pub use prefer_derive::FromValue; + +/// Load a configuration by identifier. +/// +/// Routes through the plugin registry: finds a loader that can handle the +/// identifier, loads the raw content, finds a formatter that can parse +/// the content, and returns the parsed `Config`. +/// +/// For bare names (e.g., `"myapp"`), the built-in `FileLoader` searches +/// standard system paths. For scheme-based identifiers (e.g., +/// `"postgres://..."`), an external loader must be registered (e.g., via +/// `prefer_db`). +/// +/// # Examples +/// +/// ```no_run +/// # #[cfg(feature = "std")] +/// # { +/// use prefer::load; +/// +/// #[tokio::main] +/// async fn main() -> prefer::Result<()> { +/// let config = load("myapp").await?; +/// let value: String = config.get("some.key")?; +/// Ok(()) +/// } +/// # } +/// ``` +#[cfg(feature = "std")] +pub async fn load(identifier: &str) -> Result { + let loader = + registry::find_loader(identifier).ok_or(Error::NoLoaderFound(identifier.to_string()))?; + + let result = loader.load(identifier).await?; + + let fmt = registry::find_formatter(&result.source) + .or(result + .format_hint + .as_deref() + .and_then(registry::find_formatter_by_hint)) + .ok_or(Error::NoFormatterFound(result.source.clone()))?; + + let data = fmt.deserialize(&result.content)?; + + Ok(Config::with_metadata( + data, + result.source, + loader.name().to_string(), + fmt.name().to_string(), + )) +} + +/// Watch a configuration source for changes. +/// +/// Routes through the plugin registry to find a loader that supports +/// watching, then returns a receiver that yields new `Config` instances +/// when the source changes. +/// +/// # Examples +/// +/// ```no_run +/// # #[cfg(feature = "std")] +/// # { +/// use prefer::watch; +/// +/// #[tokio::main] +/// async fn main() -> prefer::Result<()> { +/// let mut receiver = watch("myapp").await?; +/// +/// while let Some(config) = receiver.recv().await { +/// println!("Configuration updated!"); +/// let value: String = config.get("some.key")?; +/// } +/// +/// Ok(()) +/// } +/// # } +/// ``` +#[cfg(feature = "std")] +pub async fn watch(identifier: &str) -> Result> { + let loader = + registry::find_loader(identifier).ok_or(Error::NoLoaderFound(identifier.to_string()))?; + + loader + .watch(identifier) + .await? + .ok_or(Error::WatchNotSupported(identifier.to_string())) +} diff --git a/prefer/src/loader/file.rs b/prefer/src/loader/file.rs new file mode 100644 index 0000000..6d6f654 --- /dev/null +++ b/prefer/src/loader/file.rs @@ -0,0 +1,142 @@ +//! File-based configuration loader. +//! +//! Handles bare config names (e.g., "myapp") and `file://` URLs by searching +//! standard system paths and trying supported extensions. + +use crate::config::Config; +use crate::discovery; +use crate::error::Result; +use crate::loader::{LoadResult, Loader}; +use crate::registry::RegisteredLoader; +use crate::watch as watch_mod; +use async_trait::async_trait; +use std::path::PathBuf; +use tokio::sync::mpsc; + +inventory::submit! { RegisteredLoader(&FileLoader) } + +/// Loader for file-based configuration sources. +/// +/// Handles: +/// - Bare names like `"myapp"` — searches standard system paths with all +/// supported extensions +/// - Explicit paths like `"./config.toml"` or `"/etc/myapp.toml"` +/// - `file://` URLs +/// +/// File discovery and extension search logic is delegated to the existing +/// `discovery` module. +pub struct FileLoader; + +impl FileLoader { + pub fn new() -> Self { + Self + } + + async fn locate(&self, identifier: &str) -> Result { + let stripped = identifier.strip_prefix("file://").unwrap_or(identifier); + discovery::find_config_file(stripped).await + } +} + +impl Default for FileLoader { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Loader for FileLoader { + fn provides(&self, identifier: &str) -> bool { + if identifier.starts_with("file://") { + return true; + } + + // If it has any other scheme, it's not ours + if identifier.contains("://") { + return false; + } + + // Bare names and relative/absolute paths are file-based + true + } + + async fn load(&self, identifier: &str) -> Result { + let path = self.locate(identifier).await?; + let content = tokio::fs::read_to_string(&path).await?; + + Ok(LoadResult { + source: path.to_string_lossy().to_string(), + content, + format_hint: None, + }) + } + + fn name(&self) -> &str { + "file" + } + + async fn watch(&self, identifier: &str) -> Result>> { + let path = self.locate(identifier).await?; + let rx = watch_mod::watch_path(path).await?; + Ok(Some(rx)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::io::Write; + use tempfile::TempDir; + + #[test] + fn test_provides_bare_name() { + let loader = FileLoader::new(); + assert!(loader.provides("myapp")); + assert!(loader.provides("settings")); + } + + #[test] + fn test_provides_file_url() { + let loader = FileLoader::new(); + assert!(loader.provides("file:///etc/myapp.toml")); + assert!(loader.provides("file://config.json")); + } + + #[test] + fn test_provides_rejects_other_schemes() { + let loader = FileLoader::new(); + assert!(!loader.provides("postgres://localhost/db")); + assert!(!loader.provides("sqlite:///path/to/db")); + assert!(!loader.provides("http://example.com/config")); + } + + #[test] + fn test_provides_explicit_paths() { + let loader = FileLoader::new(); + assert!(loader.provides("./config.toml")); + assert!(loader.provides("../config.toml")); + assert!(loader.provides("/etc/myapp.toml")); + } + + #[tokio::test] + #[serial] + async fn test_load_file() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("testapp.json"); + let mut file = std::fs::File::create(&file_path).unwrap(); + writeln!(file, r#"{{"host": "localhost", "port": 8080}}"#).unwrap(); + + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp_dir.path()).unwrap(); + + let loader = FileLoader::new(); + let result = loader.load("testapp").await.unwrap(); + + assert!(result.source.ends_with("testapp.json")); + assert!(result.content.contains("localhost")); + assert!(result.format_hint.is_none()); + + std::env::set_current_dir(original_dir).unwrap(); + } +} diff --git a/prefer/src/loader/mod.rs b/prefer/src/loader/mod.rs new file mode 100644 index 0000000..27e403a --- /dev/null +++ b/prefer/src/loader/mod.rs @@ -0,0 +1,98 @@ +//! Configuration loading abstraction. +//! +//! The `Loader` trait is the primary abstraction for loading configuration data +//! from any source. Loaders declare what identifiers they can handle via +//! `provides()`, and are discovered automatically through the registry. +//! +//! Built-in loaders: +//! - `FileLoader` — handles bare names and `file://` URLs +//! +//! External crates (e.g., `prefer_db`) can register additional loaders for +//! schemes like `postgres://` and `sqlite://`. + +pub mod file; + +use crate::config::Config; +use crate::error::Result; +use async_trait::async_trait; +use tokio::sync::mpsc; + +/// Result of a successful load operation. +/// +/// Contains the raw content and metadata needed for the registry to find +/// an appropriate formatter. +pub struct LoadResult { + /// The resolved source identifier (e.g., "/home/user/.config/myapp.toml"). + pub source: String, + + /// The raw content loaded from the source. + pub content: String, + + /// Optional format hint (e.g., "json", "toml"). + /// + /// Used when the source identifier doesn't have a file extension that + /// can be used to determine the format (e.g., database-backed configs). + pub format_hint: Option, +} + +/// A source of configuration data that can be discovered via the registry. +/// +/// Unlike `Source`, which requires manual construction and wiring, `Loader` +/// participates in automatic discovery: the registry calls `provides()` on +/// each registered loader to find one that can handle a given identifier. +/// +/// # Implementing a Loader +/// +/// ```ignore +/// use prefer::loader::{Loader, LoadResult}; +/// use prefer::Result; +/// use async_trait::async_trait; +/// +/// struct MyLoader; +/// +/// #[async_trait] +/// impl Loader for MyLoader { +/// fn provides(&self, identifier: &str) -> bool { +/// identifier.starts_with("myscheme://") +/// } +/// +/// async fn load(&self, identifier: &str) -> Result { +/// let content = fetch_from_my_source(identifier).await?; +/// Ok(LoadResult { +/// source: identifier.to_string(), +/// content, +/// format_hint: Some("json".to_string()), +/// }) +/// } +/// +/// fn name(&self) -> &str { +/// "my-loader" +/// } +/// } +/// ``` +#[async_trait] +pub trait Loader: Send + Sync + 'static { + /// Whether this loader can handle the given identifier. + /// + /// This checks capability, not connectivity. For example, a database + /// loader returns `true` for `postgres://` URLs without actually + /// connecting — the connection happens in `load()`. + fn provides(&self, identifier: &str) -> bool; + + /// Load configuration content from the identifier. + /// + /// Returns the resolved source path/URL, raw content string, and an + /// optional format hint for the formatter. + async fn load(&self, identifier: &str) -> Result; + + /// Human-readable name for error messages. + fn name(&self) -> &str; + + /// Watch the identified source for changes. + /// + /// Returns `None` if this loader does not support watching. + /// The default implementation returns `Ok(None)`. + async fn watch(&self, _identifier: &str) -> Result>> { + Ok(None) + } +} diff --git a/prefer/src/registry.rs b/prefer/src/registry.rs new file mode 100644 index 0000000..97c4754 --- /dev/null +++ b/prefer/src/registry.rs @@ -0,0 +1,62 @@ +//! Plugin discovery via the `inventory` crate. +//! +//! Loaders and formatters register themselves at link time using +//! `inventory::submit!`. The registry provides lookup functions that iterate +//! over all registered plugins to find one that can handle a given identifier. +//! +//! External crates (e.g., `prefer_db`) can register their own loaders and +//! formatters simply by depending on `prefer` and calling `inventory::submit!`. + +use crate::formatter::Formatter; +use crate::loader::Loader; + +/// Wrapper for registering a `Loader` with the inventory. +/// +/// Uses a static reference since inventory items must be const-constructible. +pub struct RegisteredLoader(pub &'static dyn Loader); + +/// Wrapper for registering a `Formatter` with the inventory. +/// +/// Uses a static reference since inventory items must be const-constructible. +pub struct RegisteredFormatter(pub &'static dyn Formatter); + +inventory::collect!(RegisteredLoader); +inventory::collect!(RegisteredFormatter); + +/// Find a loader that can handle the given identifier. +/// +/// Iterates over all registered loaders and returns the first one whose +/// `provides()` method returns `true`. +pub fn find_loader(identifier: &str) -> Option<&'static dyn Loader> { + for entry in inventory::iter:: { + if entry.0.provides(identifier) { + return Some(entry.0); + } + } + None +} + +/// Find a formatter that can handle the given source identifier. +/// +/// Matches by file extension on the source path. +pub fn find_formatter(source: &str) -> Option<&'static dyn Formatter> { + for entry in inventory::iter:: { + if entry.0.provides(source) { + return Some(entry.0); + } + } + None +} + +/// Find a formatter by format hint string (e.g., "json", "toml"). +/// +/// Used when the source has no file extension but the loader provides +/// a format hint. +pub fn find_formatter_by_hint(hint: &str) -> Option<&'static dyn Formatter> { + for entry in inventory::iter:: { + if entry.0.extensions().contains(&hint) { + return Some(entry.0); + } + } + None +} diff --git a/prefer/src/source.rs b/prefer/src/source.rs index 3069c41..905ea58 100644 --- a/prefer/src/source.rs +++ b/prefer/src/source.rs @@ -3,6 +3,11 @@ //! This module provides the `Source` trait for abstracting configuration sources, //! allowing configuration to be loaded from files, environment variables, databases, //! or any other source. +//! +//! **Deprecated:** The `Source` trait is superseded by `Loader` + `Formatter`. +//! `EnvSource`, `MemorySource`, and `LayeredSource` remain as layering utilities. + +#![allow(deprecated)] // Internal implementations still reference their own deprecated types use crate::error::{Error, Result}; use crate::formats; @@ -13,8 +18,13 @@ use std::path::{Path, PathBuf}; /// A source of configuration data. /// -/// Implementations of this trait can load configuration from various sources -/// such as files, environment variables, databases, or remote services. +/// **Deprecated:** Use the `Loader` trait instead. `Loader` participates in +/// automatic plugin discovery via the registry, while `Source` requires +/// manual construction and wiring. `Source` will be removed in a future +/// major version. +/// +/// `EnvSource` and `MemorySource` are not affected — they remain as +/// layering utilities used by `ConfigBuilder`. /// /// # Examples /// @@ -38,6 +48,10 @@ use std::path::{Path, PathBuf}; /// } /// } /// ``` +#[deprecated( + since = "0.4.0", + note = "Use the Loader trait instead. Source will be removed in a future version." +)] #[async_trait] pub trait Source: Send + Sync { /// Load configuration data from this source. @@ -48,6 +62,13 @@ pub trait Source: Send + Sync { } /// A configuration source that loads from a file. +/// +/// **Deprecated:** Use `FileLoader` instead, which participates in +/// automatic registry discovery. +#[deprecated( + since = "0.4.0", + note = "Use prefer::loader::file::FileLoader instead." +)] pub struct FileSource { path: PathBuf, } @@ -291,11 +312,21 @@ fn merge_values(base: &mut ConfigValue, overlay: ConfigValue) { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; + use tempfile::TempDir; fn obj(items: Vec<(&str, ConfigValue)>) -> ConfigValue { ConfigValue::Object(items.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) } + fn int(i: i64) -> ConfigValue { + ConfigValue::Integer(i) + } + + fn bool_val(b: bool) -> ConfigValue { + ConfigValue::Bool(b) + } + #[tokio::test] async fn test_memory_source() { let data = obj(vec![ @@ -421,4 +452,180 @@ mod tests { assert_eq!(base.get("b").unwrap().get("d").unwrap().as_i64(), Some(3)); assert_eq!(base.get("e").unwrap().as_i64(), Some(5)); } + + #[tokio::test] + async fn test_file_source_load() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("source.json"); + std::fs::write(&config_path, r#"{"source": "file"}"#).unwrap(); + + let source = FileSource::new(&config_path); + assert_eq!(source.path(), config_path); + assert!(source.name().contains("source.json")); + + let value = source.load().await.unwrap(); + assert_eq!(value.get("source").unwrap().as_str(), Some("file")); + } + + #[tokio::test] + async fn test_file_source_not_found() { + let source = FileSource::new("/nonexistent/path.json"); + assert!(source.load().await.is_err()); + } + + #[tokio::test] + #[serial] + async fn test_env_source_load() { + std::env::set_var("PREFERTEST__DB__HOST", "localhost"); + std::env::set_var("PREFERTEST__DB__PORT", "5432"); + std::env::set_var("PREFERTEST__DEBUG", "true"); + + let source = EnvSource::new("PREFERTEST"); + assert_eq!(source.name(), "PREFERTEST"); + + let value = source.load().await.unwrap(); + assert_eq!( + value.get("db").unwrap().get("host").unwrap().as_str(), + Some("localhost") + ); + assert_eq!( + value.get("db").unwrap().get("port").unwrap().as_i64(), + Some(5432) + ); + assert_eq!(value.get("debug").unwrap().as_bool(), Some(true)); + + std::env::remove_var("PREFERTEST__DB__HOST"); + std::env::remove_var("PREFERTEST__DB__PORT"); + std::env::remove_var("PREFERTEST__DEBUG"); + } + + #[tokio::test] + #[serial] + async fn test_env_source_with_separator() { + std::env::set_var("PREFERSEP_DB_HOST", "dbhost"); + + let source = EnvSource::with_separator("PREFERSEP", "_"); + let value = source.load().await.unwrap(); + assert_eq!( + value.get("db").unwrap().get("host").unwrap().as_str(), + Some("dbhost") + ); + + std::env::remove_var("PREFERSEP_DB_HOST"); + } + + #[tokio::test] + async fn test_env_source_empty() { + let source = EnvSource::new("NONEXISTENT_PREFIX_XYZ123"); + let value = source.load().await.unwrap(); + assert!(value.as_object().map(|o| o.is_empty()).unwrap_or(false)); + } + + #[tokio::test] + async fn test_memory_source_coverage() { + let data = obj(vec![("memory", ConfigValue::Bool(true))]); + let source = MemorySource::new(data.clone()); + assert_eq!(source.name(), "memory"); + + let loaded = source.load().await.unwrap(); + assert_eq!(loaded, data); + } + + #[tokio::test] + async fn test_memory_source_with_name() { + let source = MemorySource::with_name(obj(vec![]), "custom"); + assert_eq!(source.name(), "custom"); + } + + #[tokio::test] + async fn test_layered_source_override() { + let base = MemorySource::with_name(obj(vec![("a", int(1)), ("b", int(2))]), "base"); + let overlay = MemorySource::with_name(obj(vec![("b", int(20)), ("c", int(3))]), "overlay"); + + let layered = LayeredSource::new().with_source(base).with_source(overlay); + assert_eq!(layered.name(), "layered"); + + let value = layered.load().await.unwrap(); + assert_eq!(value.get("a").unwrap().as_i64(), Some(1)); + assert_eq!(value.get("b").unwrap().as_i64(), Some(20)); + assert_eq!(value.get("c").unwrap().as_i64(), Some(3)); + } + + #[tokio::test] + async fn test_layered_source_default() { + let layered = LayeredSource::default(); + let value = layered.load().await.unwrap(); + assert!(value.as_object().map(|o| o.is_empty()).unwrap_or(false)); + } + + #[tokio::test] + async fn test_layered_source_add_boxed() { + let source: Box = + Box::new(MemorySource::new(obj(vec![("boxed", bool_val(true))]))); + let layered = LayeredSource::new().add_boxed(source); + let value = layered.load().await.unwrap(); + assert_eq!(value.get("boxed").unwrap().as_bool(), Some(true)); + } + + #[tokio::test] + #[serial] + async fn test_env_source_float_parsing() { + std::env::set_var("ENVFLOAT__VALUE", "1.5"); + + let source = EnvSource::new("ENVFLOAT"); + let value = source.load().await.unwrap(); + assert!((value.get("value").unwrap().as_f64().unwrap() - 1.5).abs() < 0.001); + + std::env::remove_var("ENVFLOAT__VALUE"); + } + + #[tokio::test] + #[serial] + async fn test_env_source_nan_float() { + std::env::set_var("ENVNAN__VALUE", "not_a_number_at_all"); + + let source = EnvSource::new("ENVNAN"); + let value = source.load().await.unwrap(); + assert_eq!( + value.get("value").unwrap().as_str(), + Some("not_a_number_at_all") + ); + + std::env::remove_var("ENVNAN__VALUE"); + } + + #[tokio::test] + #[serial] + async fn test_env_source_false_boolean() { + std::env::set_var("ENVBOOL__ENABLED", "false"); + std::env::set_var("ENVBOOL__DISABLED", "FALSE"); + + let source = EnvSource::new("ENVBOOL"); + let value = source.load().await.unwrap(); + assert_eq!(value.get("enabled").unwrap().as_bool(), Some(false)); + assert_eq!(value.get("disabled").unwrap().as_bool(), Some(false)); + + std::env::remove_var("ENVBOOL__ENABLED"); + std::env::remove_var("ENVBOOL__DISABLED"); + } + + #[tokio::test] + async fn test_layered_source_error_propagation() { + struct FailingSource; + + #[async_trait] + impl Source for FailingSource { + async fn load(&self) -> Result { + Err(Error::FileNotFound("test".into())) + } + + fn name(&self) -> &str { + "failing" + } + } + + let layered = LayeredSource::new().with_source(FailingSource); + let result = layered.load().await; + assert!(matches!(result.unwrap_err(), Error::SourceError { .. })); + } } diff --git a/prefer/tests/coverage_tests.rs b/prefer/tests/coverage_tests.rs index 7b169e2..efefdee 100644 --- a/prefer/tests/coverage_tests.rs +++ b/prefer/tests/coverage_tests.rs @@ -1,12 +1,8 @@ //! Comprehensive tests for 100% code coverage. -use async_trait::async_trait; use prefer::value::FromValue; use prefer::visitor::{visit, FromValueVisitor, MapAccess, ValueVisitor}; -use prefer::{ - Config, ConfigBuilder, ConfigValue, EnvSource, Error, FileSource, LayeredSource, MemorySource, - Source, -}; +use prefer::{Config, ConfigBuilder, ConfigValue, Error, MemorySource}; use serial_test::serial; use std::collections::HashMap; use std::path::PathBuf; @@ -670,123 +666,6 @@ fn test_config_data_mut() { assert_eq!(config.data().get("key").unwrap().as_str(), Some("modified")); } -// ============================================================================ -// Source implementations -// ============================================================================ - -#[tokio::test] -async fn test_file_source_load() { - let temp_dir = TempDir::new().unwrap(); - let config_path = temp_dir.path().join("source.json"); - std::fs::write(&config_path, r#"{"source": "file"}"#).unwrap(); - - let source = FileSource::new(&config_path); - assert_eq!(source.path(), config_path); - assert!(source.name().contains("source.json")); - - let value = source.load().await.unwrap(); - assert_eq!(value.get("source").unwrap().as_str(), Some("file")); -} - -#[tokio::test] -async fn test_file_source_not_found() { - let source = FileSource::new("/nonexistent/path.json"); - assert!(source.load().await.is_err()); -} - -#[tokio::test] -async fn test_env_source_load() { - // Set some env vars for testing - std::env::set_var("PREFERTEST__DB__HOST", "localhost"); - std::env::set_var("PREFERTEST__DB__PORT", "5432"); - std::env::set_var("PREFERTEST__DEBUG", "true"); - - let source = EnvSource::new("PREFERTEST"); - assert_eq!(source.name(), "PREFERTEST"); - - let value = source.load().await.unwrap(); - assert_eq!( - value.get("db").unwrap().get("host").unwrap().as_str(), - Some("localhost") - ); - assert_eq!( - value.get("db").unwrap().get("port").unwrap().as_i64(), - Some(5432) - ); - assert_eq!(value.get("debug").unwrap().as_bool(), Some(true)); - - // Cleanup - std::env::remove_var("PREFERTEST__DB__HOST"); - std::env::remove_var("PREFERTEST__DB__PORT"); - std::env::remove_var("PREFERTEST__DEBUG"); -} - -#[tokio::test] -async fn test_env_source_with_separator() { - std::env::set_var("PREFERSEP_DB_HOST", "dbhost"); - - let source = EnvSource::with_separator("PREFERSEP", "_"); - let value = source.load().await.unwrap(); - assert_eq!( - value.get("db").unwrap().get("host").unwrap().as_str(), - Some("dbhost") - ); - - std::env::remove_var("PREFERSEP_DB_HOST"); -} - -#[tokio::test] -async fn test_env_source_empty() { - let source = EnvSource::new("NONEXISTENT_PREFIX_XYZ123"); - let value = source.load().await.unwrap(); - assert!(value.as_object().map(|o| o.is_empty()).unwrap_or(false)); -} - -#[tokio::test] -async fn test_memory_source() { - let data = obj(vec![("memory", bool_val(true))]); - let source = MemorySource::new(data.clone()); - assert_eq!(source.name(), "memory"); - - let loaded = source.load().await.unwrap(); - assert_eq!(loaded, data); -} - -#[tokio::test] -async fn test_memory_source_with_name() { - let source = MemorySource::with_name(obj(vec![]), "custom"); - assert_eq!(source.name(), "custom"); -} - -#[tokio::test] -async fn test_layered_source() { - let base = MemorySource::with_name(obj(vec![("a", int(1)), ("b", int(2))]), "base"); - let overlay = MemorySource::with_name(obj(vec![("b", int(20)), ("c", int(3))]), "overlay"); - - let layered = LayeredSource::new().with_source(base).with_source(overlay); - assert_eq!(layered.name(), "layered"); - - let value = layered.load().await.unwrap(); - assert_eq!(value.get("a").unwrap().as_i64(), Some(1)); // From base - assert_eq!(value.get("b").unwrap().as_i64(), Some(20)); // Overridden by overlay - assert_eq!(value.get("c").unwrap().as_i64(), Some(3)); // From overlay -} - -#[tokio::test] -async fn test_layered_source_default() { - let layered = LayeredSource::default(); - let value = layered.load().await.unwrap(); - assert!(value.as_object().map(|o| o.is_empty()).unwrap_or(false)); -} - -#[tokio::test] -async fn test_layered_source_add_boxed() { - let source: Box = Box::new(MemorySource::new(obj(vec![("boxed", bool_val(true))]))); - let layered = LayeredSource::new().add_boxed(source); - let value = layered.load().await.unwrap(); - assert_eq!(value.get("boxed").unwrap().as_bool(), Some(true)); -} - // ============================================================================ // Builder tests // ============================================================================ @@ -1058,51 +937,6 @@ fn test_f32_conversion_error() { assert!(result.is_err()); } -#[tokio::test] -async fn test_env_source_float_parsing() { - // Test float parsing in environment variables - std::env::set_var("ENVFLOAT__VALUE", "1.5"); - - let source = EnvSource::new("ENVFLOAT"); - let value = source.load().await.unwrap(); - assert!((value.get("value").unwrap().as_f64().unwrap() - 1.5).abs() < 0.001); - - std::env::remove_var("ENVFLOAT__VALUE"); -} - -#[tokio::test] -async fn test_env_source_nan_float() { - // Test NaN float handling (from_f64 returns None for NaN) - // Since we can't set NaN through env vars, this tests the else branch - // by setting a non-number string that parses as string - std::env::set_var("ENVNAN__VALUE", "not_a_number_at_all"); - - let source = EnvSource::new("ENVNAN"); - let value = source.load().await.unwrap(); - assert_eq!( - value.get("value").unwrap().as_str(), - Some("not_a_number_at_all") - ); - - std::env::remove_var("ENVNAN__VALUE"); -} - -#[tokio::test] -#[serial] -async fn test_env_source_false_boolean() { - // Test "false" boolean parsing in env source - std::env::set_var("ENVBOOL__ENABLED", "false"); - std::env::set_var("ENVBOOL__DISABLED", "FALSE"); - - let source = EnvSource::new("ENVBOOL"); - let value = source.load().await.unwrap(); - assert_eq!(value.get("enabled").unwrap().as_bool(), Some(false)); - assert_eq!(value.get("disabled").unwrap().as_bool(), Some(false)); - - std::env::remove_var("ENVBOOL__ENABLED"); - std::env::remove_var("ENVBOOL__DISABLED"); -} - #[test] fn test_error_with_key_non_conversion() { // Test that Error::with_key() passes through non-ConversionError unchanged @@ -1122,27 +956,6 @@ fn test_config_value_display_multi_item_object() { assert!(display.contains(", ")); } -#[tokio::test] -async fn test_layered_source_error_propagation() { - // Test that LayeredSource properly wraps source errors - struct FailingSource; - - #[async_trait] - impl Source for FailingSource { - async fn load(&self) -> prefer::Result { - Err(Error::FileNotFound("test".into())) - } - - fn name(&self) -> &str { - "failing" - } - } - - let layered = LayeredSource::new().with_source(FailingSource); - let result = layered.load().await; - assert!(matches!(result.unwrap_err(), Error::SourceError { .. })); -} - // Test error re-mapping in config.extract when error is not ConversionError // by using a custom FromValue that returns a non-ConversionError #[test] diff --git a/prefer/tests/registry_test.rs b/prefer/tests/registry_test.rs new file mode 100644 index 0000000..9212150 --- /dev/null +++ b/prefer/tests/registry_test.rs @@ -0,0 +1,289 @@ +//! Tests for the registry-based load/watch pipeline. + +use prefer::loader::file::FileLoader; +use prefer::loader::Loader; +use prefer::registry; +use serial_test::serial; +use std::io::Write; +use tempfile::TempDir; + +#[test] +fn test_file_loader_provides_bare_names() { + let loader = FileLoader::new(); + assert!(loader.provides("myapp")); + assert!(loader.provides("settings")); + assert!(loader.provides("config")); +} + +#[test] +fn test_file_loader_rejects_db_urls() { + let loader = FileLoader::new(); + assert!(!loader.provides("postgres://localhost/mydb")); + assert!(!loader.provides("sqlite:///tmp/db.sqlite")); + assert!(!loader.provides("mysql://root@localhost/db")); +} + +#[test] +fn test_find_loader_returns_file_loader_for_bare_name() { + let loader = registry::find_loader("myapp"); + assert!(loader.is_some()); + assert_eq!(loader.unwrap().name(), "file"); +} + +#[test] +fn test_find_loader_returns_none_for_unknown_scheme() { + // No loader registered for postgres:// in prefer itself + let loader = registry::find_loader("postgres://localhost/db"); + assert!(loader.is_none()); +} + +#[test] +fn test_find_formatter_by_extension() { + let fmt = registry::find_formatter("config.json"); + assert!(fmt.is_some()); + assert_eq!(fmt.unwrap().name(), "json"); + + let fmt = registry::find_formatter("config.yaml"); + assert!(fmt.is_some()); + assert_eq!(fmt.unwrap().name(), "yaml"); + + let fmt = registry::find_formatter("config.yml"); + assert!(fmt.is_some()); + assert_eq!(fmt.unwrap().name(), "yaml"); + + let fmt = registry::find_formatter("config.toml"); + assert!(fmt.is_some()); + assert_eq!(fmt.unwrap().name(), "toml"); + + let fmt = registry::find_formatter("config.ini"); + assert!(fmt.is_some()); + assert_eq!(fmt.unwrap().name(), "ini"); + + let fmt = registry::find_formatter("config.xml"); + assert!(fmt.is_some()); + assert_eq!(fmt.unwrap().name(), "xml"); +} + +#[test] +fn test_find_formatter_returns_none_for_unknown() { + let fmt = registry::find_formatter("config.bson"); + assert!(fmt.is_none()); + + let fmt = registry::find_formatter("no_extension"); + assert!(fmt.is_none()); +} + +#[test] +fn test_find_formatter_by_hint() { + let fmt = registry::find_formatter_by_hint("json"); + assert!(fmt.is_some()); + assert_eq!(fmt.unwrap().name(), "json"); + + let fmt = registry::find_formatter_by_hint("toml"); + assert!(fmt.is_some()); + assert_eq!(fmt.unwrap().name(), "toml"); + + let fmt = registry::find_formatter_by_hint("yaml"); + assert!(fmt.is_some()); + assert_eq!(fmt.unwrap().name(), "yaml"); +} + +#[test] +fn test_find_formatter_by_hint_returns_none_for_unknown() { + let fmt = registry::find_formatter_by_hint("bson"); + assert!(fmt.is_none()); +} + +#[tokio::test] +#[serial] +async fn test_load_routes_to_file_loader() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("testcfg.json"); + let mut file = std::fs::File::create(&file_path).unwrap(); + writeln!(file, r#"{{"host": "localhost", "port": 8080}}"#).unwrap(); + + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp_dir.path()).unwrap(); + + let config = prefer::load("testcfg").await.unwrap(); + + let host: String = config.get("host").unwrap(); + assert_eq!(host, "localhost"); + + let port: u16 = config.get("port").unwrap(); + assert_eq!(port, 8080); + + // Verify metadata was populated + assert_eq!(config.loader_name(), Some("file")); + assert_eq!(config.formatter_name(), Some("json")); + assert!(config.source().is_some()); + + std::env::set_current_dir(original_dir).unwrap(); +} + +#[tokio::test] +#[serial] +async fn test_load_toml_routes_correctly() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("myapp.toml"); + let mut file = std::fs::File::create(&file_path).unwrap(); + writeln!(file, r#"name = "test""#).unwrap(); + + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp_dir.path()).unwrap(); + + let config = prefer::load("myapp").await.unwrap(); + + let name: String = config.get("name").unwrap(); + assert_eq!(name, "test"); + assert_eq!(config.formatter_name(), Some("toml")); + + std::env::set_current_dir(original_dir).unwrap(); +} + +#[tokio::test] +async fn test_load_no_loader_found() { + let result = prefer::load("postgres://localhost/db").await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, prefer::Error::NoLoaderFound(_))); +} + +#[tokio::test] +async fn test_watch_no_loader_found() { + let result = prefer::watch("postgres://localhost/db").await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, prefer::Error::NoLoaderFound(_))); +} + +#[test] +fn test_formatter_deserialize_serialize_roundtrip() { + let fmt = registry::find_formatter("test.json").unwrap(); + let data = fmt.deserialize(r#"{"key": "value", "num": 42}"#).unwrap(); + let serialized = fmt.serialize(&data).unwrap(); + let restored = fmt.deserialize(&serialized).unwrap(); + assert_eq!(data, restored); +} + +#[test] +fn test_config_set_and_get() { + let mut config = prefer::Config::new(prefer::ConfigValue::Object(Default::default())); + + config.set( + "server.host", + prefer::ConfigValue::String("localhost".into()), + ); + config.set("server.port", prefer::ConfigValue::Integer(8080)); + + let host: String = config.get("server.host").unwrap(); + assert_eq!(host, "localhost"); + + let port: u16 = config.get("server.port").unwrap(); + assert_eq!(port, 8080); +} + +#[test] +fn test_config_on_change_fires() { + let mut config = prefer::Config::new(prefer::ConfigValue::Object(Default::default())); + + let changes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let changes_clone = changes.clone(); + + config.on_change(Box::new(move |key, _value, _prev| { + changes_clone.lock().unwrap().push(key.to_string()); + })); + + config.set("a", prefer::ConfigValue::Integer(1)); + config.set("b.c", prefer::ConfigValue::String("hello".into())); + + let log = changes.lock().unwrap(); + assert_eq!(log.len(), 2); + assert_eq!(log[0], "a"); + assert_eq!(log[1], "b.c"); +} + +#[tokio::test] +#[serial] +async fn test_load_yaml_routes_correctly() { + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("settings.yaml"); + let mut file = std::fs::File::create(&file_path).unwrap(); + writeln!(file, "host: localhost\nport: 3000").unwrap(); + + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp_dir.path()).unwrap(); + + let config = prefer::load("settings").await.unwrap(); + + let host: String = config.get("host").unwrap(); + assert_eq!(host, "localhost"); + assert_eq!(config.formatter_name(), Some("yaml")); + assert_eq!(config.loader_name(), Some("file")); + + std::env::set_current_dir(original_dir).unwrap(); +} + +#[tokio::test] +#[serial] +async fn test_load_file_not_found() { + let temp_dir = TempDir::new().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp_dir.path()).unwrap(); + + let result = prefer::load("nonexistent_config").await; + assert!(result.is_err()); + + std::env::set_current_dir(original_dir).unwrap(); +} + +#[test] +fn test_formatter_toml_roundtrip() { + let fmt = registry::find_formatter("test.toml").unwrap(); + let data = fmt.deserialize("name = \"test\"\nport = 8080").unwrap(); + let serialized = fmt.serialize(&data).unwrap(); + let restored = fmt.deserialize(&serialized).unwrap(); + assert_eq!(data, restored); +} + +#[test] +fn test_formatter_yaml_roundtrip() { + let fmt = registry::find_formatter("test.yaml").unwrap(); + let data = fmt.deserialize("name: test\nport: 8080").unwrap(); + let serialized = fmt.serialize(&data).unwrap(); + let restored = fmt.deserialize(&serialized).unwrap(); + assert_eq!(data, restored); +} + +#[test] +fn test_find_formatter_by_hint_ini() { + let fmt = registry::find_formatter_by_hint("ini"); + assert!(fmt.is_some()); + assert_eq!(fmt.unwrap().name(), "ini"); +} + +#[test] +fn test_find_formatter_by_hint_xml() { + let fmt = registry::find_formatter_by_hint("xml"); + assert!(fmt.is_some()); + assert_eq!(fmt.unwrap().name(), "xml"); +} + +#[test] +fn test_file_loader_provides_file_url() { + let loader = FileLoader::new(); + assert!(loader.provides("file:///etc/myapp.toml")); + assert!(loader.provides("file://config.json")); +} + +#[test] +fn test_error_display_messages() { + let err = prefer::Error::NoLoaderFound("redis://host".into()); + assert!(err.to_string().contains("redis://host")); + + let err = prefer::Error::NoFormatterFound("config.bson".into()); + assert!(err.to_string().contains("config.bson")); + + let err = prefer::Error::WatchNotSupported("scheme://x".into()); + assert!(err.to_string().contains("scheme://x")); +}