diff --git a/src/convert.rs b/src/convert.rs new file mode 100644 index 0000000..1bff384 --- /dev/null +++ b/src/convert.rs @@ -0,0 +1,391 @@ +use serde_json::Value; +use std::collections::HashMap; +use std::str::FromStr; + +use crate::error::{ConfigForgeError, Result}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigFormat { + Json, + Yaml, + Toml, + Env, + Ini, +} + +impl FromStr for ConfigFormat { + type Err = ConfigForgeError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "json" => Ok(ConfigFormat::Json), + "yaml" | "yml" => Ok(ConfigFormat::Yaml), + "toml" => Ok(ConfigFormat::Toml), + "env" | "dotenv" => Ok(ConfigFormat::Env), + "ini" => Ok(ConfigFormat::Ini), + _ => Err(ConfigForgeError::UnsupportedFormat { + format: s.to_string(), + }), + } + } +} + +impl std::fmt::Display for ConfigFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfigFormat::Json => write!(f, "json"), + ConfigFormat::Yaml => write!(f, "yaml"), + ConfigFormat::Toml => write!(f, "toml"), + ConfigFormat::Env => write!(f, "env"), + ConfigFormat::Ini => write!(f, "ini"), + } + } +} + +pub fn detect_format(path: &str) -> ConfigFormat { + let ext = path.split('.').last().unwrap_or("").to_lowercase(); + match ext.as_str() { + "json" => ConfigFormat::Json, + "yaml" | "yml" => ConfigFormat::Yaml, + "toml" => ConfigFormat::Toml, + "env" | "dotenv" => ConfigFormat::Env, + "ini" | "cfg" | "conf" => ConfigFormat::Ini, + _ => ConfigFormat::Json, + } +} + +pub fn detect_format_from_content(content: &str) -> Result { + let trimmed = content.trim_start(); + if trimmed.starts_with('{') || trimmed.starts_with('[') { + return Ok(ConfigFormat::Json); + } + if trimmed.starts_with('#') || trimmed.starts_with("---") || trimmed.contains(": ") && !trimmed.contains("= ") { + return Ok(ConfigFormat::Yaml); + } + if trimmed.contains('=') && !trimmed.contains(" = ") && !trimmed.starts_with('[') { + return Ok(ConfigFormat::Env); + } + if trimmed.starts_with('[') { + return Ok(ConfigFormat::Ini); + } + if trimmed.contains(" = ") { + return Ok(ConfigFormat::Toml); + } + Ok(ConfigFormat::Json) +} + +pub fn parse_content(content: &str, format: ConfigFormat) -> Result { + match format { + ConfigFormat::Json => parse_json(content), + ConfigFormat::Yaml => parse_yaml(content), + ConfigFormat::Toml => parse_toml(content), + ConfigFormat::Env => parse_env(content), + ConfigFormat::Ini => parse_ini(content), + } +} + +fn parse_json(content: &str) -> Result { + serde_json::from_str(content).map_err(|e| ConfigForgeError::ParseError { + format: "JSON".to_string(), + details: e.to_string(), + }) +} + +fn parse_yaml(content: &str) -> Result { + serde_yaml::from_str(content).map_err(|e| ConfigForgeError::ParseError { + format: "YAML".to_string(), + details: e.to_string(), + }) +} + +fn parse_toml(content: &str) -> Result { + let value: toml::Value = toml::from_str(content).map_err(|e| ConfigForgeError::ParseError { + format: "TOML".to_string(), + details: e.to_string(), + })?; + Ok(toml_to_value(value)) +} + +fn toml_to_value(value: toml::Value) -> Value { + match value { + toml::Value::String(s) => Value::String(s), + toml::Value::Integer(i) => Value::Number(serde_json::Number::from(i)), + toml::Value::Float(f) => { + if let Some(n) = serde_json::Number::from_f64(f) { + Value::Number(n) + } else { + Value::Null + } + } + toml::Value::Boolean(b) => Value::Bool(b), + toml::Value::Datetime(dt) => Value::String(dt.to_string()), + toml::Value::Array(arr) => Value::Array(arr.into_iter().map(toml_to_value).collect()), + toml::Value::Table(table) => { + let mut map = serde_json::Map::new(); + for (k, v) in table { + map.insert(k, toml_to_value(v)); + } + Value::Object(map) + } + } +} + +fn parse_env(content: &str) -> Result { + let mut map = serde_json::Map::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some(eq_idx) = line.find('=') { + let key = line[..eq_idx].trim().to_string(); + let value = line[eq_idx + 1..].trim().to_string(); + let value = value.trim_matches('"').trim_matches('\'').to_string(); + map.insert(key, Value::String(value)); + } + } + Ok(Value::Object(map)) +} + +fn parse_ini(content: &str) -> Result { + let ini = ini::Ini::load_from_str(content).map_err(|e| ConfigForgeError::ParseError { + format: "INI".to_string(), + details: e.to_string(), + })?; + let mut map = serde_json::Map::new(); + for (section, prop) in ini.iter() { + let mut section_map = serde_json::Map::new(); + if let Some(section_name) = section { + for (k, v) in prop.iter() { + section_map.insert(k.clone(), Value::String(v.clone().unwrap_or_default())); + } + map.insert(section_name.clone(), Value::Object(section_map)); + } else { + for (k, v) in prop.iter() { + map.insert(k.clone(), Value::String(v.clone().unwrap_or_default())); + } + } + } + Ok(Value::Object(map)) +} + +pub fn convert(value: Value, to_format: ConfigFormat) -> Result { + match to_format { + ConfigFormat::Json => to_json(&value), + ConfigFormat::Yaml => to_yaml(&value), + ConfigFormat::Toml => to_toml(&value), + ConfigFormat::Env => to_env(&value), + ConfigFormat::Ini => to_ini(&value), + } +} + +fn to_json(value: &Value) -> Result { + serde_json::to_string_pretty(value).map_err(|e| ConfigForgeError::ConversionError { + details: e.to_string(), + }) +} + +fn to_yaml(value: &Value) -> Result { + serde_yaml::to_string(value).map_err(|e| ConfigForgeError::ConversionError { + details: e.to_string(), + }) +} + +fn to_toml(value: &Value) -> Result { + let toml_value = value_to_toml(value)?; + toml::to_string_pretty(&toml_value).map_err(|e| ConfigForgeError::ConversionError { + details: e.to_string(), + }) +} + +fn value_to_toml(value: &Value) -> Result { + match value { + Value::String(s) => Ok(toml::Value::String(s.clone())), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(toml::Value::Integer(i)) + } else if let Some(f) = n.as_f64() { + Ok(toml::Value::Float(f)) + } else { + Ok(toml::Value::String(n.to_string())) + } + } + Value::Bool(b) => Ok(toml::Value::Boolean(*b)), + Value::Null => Ok(toml::Value::String(String::new())), + Value::Array(arr) => { + let converted: Result> = arr.iter().map(value_to_toml).collect(); + Ok(toml::Value::Array(converted?)) + } + Value::Object(map) => { + let mut table = toml::Map::new(); + for (k, v) in map { + table.insert(k.clone(), value_to_toml(v)?); + } + Ok(toml::Value::Table(table)) + } + } +} + +fn to_env(value: &Value) -> Result { + match value { + Value::Object(map) => { + let mut lines = Vec::new(); + for (k, v) in map { + let env_value = match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + _ => serde_json::to_string(v).unwrap_or_default(), + }; + lines.push(format!("{}={}", k.to_uppercase(), env_value)); + } + Ok(lines.join("\n")) + } + _ => Err(ConfigForgeError::ConversionError { + details: "ENV format requires an object at root".to_string(), + }) + } +} + +fn to_ini(value: &Value) -> Result { + let mut output = String::new(); + match value { + Value::Object(map) => { + let mut has_global = false; + for (k, v) in map { + if let Value::Object(section) = v { + output.push('['); + output.push_str(k); + output.push_str("]\n"); + for (key, val) in section { + output.push_str(key); + output.push_str(" = "); + if let Value::String(s) = val { + output.push_str(s); + } else { + output.push_str(&val.to_string()); + } + output.push('\n'); + } + output.push('\n'); + } else { + has_global = true; + output.push_str(k); + output.push_str(" = "); + if let Value::String(s) = v { + output.push_str(s); + } else { + output.push_str(&v.to_string()); + } + output.push('\n'); + } + } + if has_global { + output.push('\n'); + } + Ok(output) + } + _ => Err(ConfigForgeError::ConversionError { + details: "INI format requires an object at root".to_string(), + }) + } +} + +pub fn read_file(path: &std::path::Path) -> Result { + std::fs::read_to_string(path).map_err(|_| ConfigForgeError::FileNotFound { + path: path.to_string_lossy().to_string(), + }) +} + +pub fn write_file(path: &std::path::Path, content: &str) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, content)?; + Ok(()) +} + +pub fn infer_schema(value: &Value) -> Value { + match value { + Value::Null => json!({"type": "null"}), + Value::Bool(_) => json!({"type": "boolean"}), + Value::Number(n) => { + if n.is_i64() { + json!({"type": "integer"}) + } else if n.is_f64() { + json!({"type": "number"}) + } else { + json!({"type": "number"}) + } + } + Value::String(s) => { + if s.starts_with('/') && s.ends_with('/') { + json!({"type": "string", "format": "regex"}) + } else if s.contains('@') { + json!({"type": "string", "format": "email"}) + } else { + json!({"type": "string"}) + } + } + Value::Array(arr) => { + if arr.is_empty() { + json!({"type": "array", "items": {}}) + } else { + let mut types = std::collections::HashSet::new(); + let mut items = serde_json::Map::new(); + for item in arr { + let item_schema = infer_schema(item); + if let Some(type_) = item_schema.get("type") { + types.insert(type_.clone()); + } + for (k, v) in item_schema.as_object().unwrap_or(&serde_json::Map::new()) { + if let Some(existing) = items.get(k) { + if existing != v { + items.remove(k); + } + } else { + items.insert(k.clone(), v.clone()); + } + } + } + let mut result = serde_json::Map::new(); + result.insert("type".to_string(), json!("array")); + if !types.is_empty() && types.len() == 1 { + result.insert("items".to_string(), json!({"type": types.iter().next().unwrap()})); + } else { + let mut any_of = Vec::new(); + for type_ in types { + any_of.push(json!({"type": type_})); + } + if !any_of.is_empty() { + result.insert("items".to_string(), json!({"anyOf": any_of})); + } else { + result.insert("items".to_string(), json!({})); + } + } + Value::Object(result) + } + } + Value::Object(map) => { + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); + + for (k, v) in map { + properties.insert(k.clone(), infer_schema(v)); + if k.starts_with('_') || k.ends_with('?') || k.starts_with("optional_") { + } else { + required.push(k.clone()); + } + } + + let mut result = serde_json::Map::new(); + result.insert("type".to_string(), json!("object")); + result.insert("properties".to_string(), Value::Object(properties)); + if !required.is_empty() { + result.insert("required".to_string(), json!(required)); + } + Value::Object(result) + } + } +}