Add source files: main, lib, cli, error, convert, highlight, validate, typescript
Some checks failed
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled

This commit is contained in:
2026-01-29 15:15:42 +00:00
parent cf9b252bda
commit d19554bdea

391
src/convert.rs Normal file
View File

@@ -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<Self> {
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<ConfigFormat> {
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<Value> {
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<Value> {
serde_json::from_str(content).map_err(|e| ConfigForgeError::ParseError {
format: "JSON".to_string(),
details: e.to_string(),
})
}
fn parse_yaml(content: &str) -> Result<Value> {
serde_yaml::from_str(content).map_err(|e| ConfigForgeError::ParseError {
format: "YAML".to_string(),
details: e.to_string(),
})
}
fn parse_toml(content: &str) -> Result<Value> {
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<Value> {
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<Value> {
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<String> {
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<String> {
serde_json::to_string_pretty(value).map_err(|e| ConfigForgeError::ConversionError {
details: e.to_string(),
})
}
fn to_yaml(value: &Value) -> Result<String> {
serde_yaml::to_string(value).map_err(|e| ConfigForgeError::ConversionError {
details: e.to_string(),
})
}
fn to_toml(value: &Value) -> Result<String> {
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<toml::Value> {
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<Vec<toml::Value>> = 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<String> {
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<String> {
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<String> {
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)
}
}
}