use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use colored::Colorize;
use serde_json::Value;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ConvertError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error),
#[error("YAML parse error: {0}")]
Yaml(#[from] serde_yml::Error),
#[error("TOML parse error: {0}")]
TomlParse(#[from] toml::de::Error),
#[error("TOML serialization error: {0}")]
TomlSerialize(#[from] toml::ser::Error),
#[error("Unsupported format: {0}")]
UnsupportedFormat(String),
#[error("{0}")]
Other(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigFormat {
Json,
Yaml,
Toml,
Env,
}
impl ConfigFormat {
pub fn from_extension(path: &Path) -> Option<Self> {
let ext = path.extension()?.to_str()?.to_lowercase();
match ext.as_str() {
"json" => Some(Self::Json),
"yaml" | "yml" => Some(Self::Yaml),
"toml" => Some(Self::Toml),
"env" => Some(Self::Env),
_ => None,
}
}
pub fn from_label(label: &str) -> Option<Self> {
match label.to_lowercase().as_str() {
"json" => Some(Self::Json),
"yaml" | "yml" => Some(Self::Yaml),
"toml" => Some(Self::Toml),
"env" | "dotenv" | ".env" => Some(Self::Env),
_ => None,
}
}
pub fn extension(self) -> &'static str {
match self {
Self::Json => "json",
Self::Yaml => "yaml",
Self::Toml => "toml",
Self::Env => "env",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Json => "JSON",
Self::Yaml => "YAML",
Self::Toml => "TOML",
Self::Env => ".env",
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ConvertResult {
pub input_path: String,
pub input_format: String,
pub output_format: String,
pub output: String,
pub output_path: Option<String>,
}
pub fn parse_input(content: &str, format: ConfigFormat) -> Result<Value, ConvertError> {
match format {
ConfigFormat::Json => {
let val: Value = serde_json::from_str(content)?;
Ok(val)
}
ConfigFormat::Yaml => {
let val: Value = serde_yml::from_str(content)?;
Ok(val)
}
ConfigFormat::Toml => {
let val: toml::Value = toml::from_str(content)?;
let val: Value =
serde_json::to_value(&val).map_err(|e| ConvertError::Other(e.to_string()))?;
Ok(val)
}
ConfigFormat::Env => {
let map = parse_dotenv(content);
Ok(unflatten_map(&map))
}
}
}
pub fn serialize_output(value: &Value, format: ConfigFormat) -> Result<String, ConvertError> {
match format {
ConfigFormat::Json => {
let out = serde_json::to_string_pretty(value)?;
Ok(out)
}
ConfigFormat::Yaml => {
let out = serde_yml::to_string(value)?;
Ok(out)
}
ConfigFormat::Toml => {
let out = toml::to_string_pretty(value).map_err(|e| {
ConvertError::Other(format!(
"Cannot convert to TOML: {e} (TOML does not support null or mixed-type arrays)"
))
})?;
Ok(out)
}
ConfigFormat::Env => {
let map = flatten_value(value, "");
let mut lines: Vec<String> = Vec::new();
for (k, v) in &map {
lines.push(format!("{k}={v}"));
}
Ok(lines.join("\n"))
}
}
}
pub fn convert_file(
input_path: &Path,
from_format: Option<ConfigFormat>,
to_format: ConfigFormat,
output_path: Option<&Path>,
) -> Result<ConvertResult, ConvertError> {
let input_format = from_format
.or_else(|| {
let name = input_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if name.starts_with(".env") {
return Some(ConfigFormat::Env);
}
ConfigFormat::from_extension(input_path)
})
.ok_or_else(|| {
ConvertError::UnsupportedFormat(format!(
"Cannot detect format of '{}'. Use --from to specify.",
input_path.display()
))
})?;
let content = fs::read_to_string(input_path)?;
let value = parse_input(&content, input_format)?;
let output = serialize_output(&value, to_format)?;
let out_path_str = if let Some(op) = output_path {
fs::write(op, &output)?;
Some(op.display().to_string())
} else {
None
};
Ok(ConvertResult {
input_path: input_path.display().to_string(),
input_format: input_format.label().to_string(),
output_format: to_format.label().to_string(),
output,
output_path: out_path_str,
})
}
fn parse_dotenv(content: &str) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some((key, val)) = trimmed.split_once('=') {
let key = key.trim().to_string();
let val = val.trim();
let val = if val.len() >= 2
&& ((val.starts_with('"') && val.ends_with('"'))
|| (val.starts_with('\'') && val.ends_with('\'')))
{
val[1..val.len() - 1].to_string()
} else {
val.to_string()
};
map.insert(key, val);
}
}
map
}
fn unflatten_map(map: &BTreeMap<String, String>) -> Value {
let mut root = serde_json::Map::new();
for (key, val) in map {
let parts: Vec<&str> = key.split('.').collect();
insert_nested(&mut root, &parts, Value::String(val.clone()));
}
Value::Object(root)
}
fn insert_nested(map: &mut serde_json::Map<String, Value>, path: &[&str], value: Value) {
if path.len() == 1 {
map.insert(path[0].to_string(), value);
return;
}
let entry = map
.entry(path[0].to_string())
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if let Value::Object(ref mut child) = entry {
insert_nested(child, &path[1..], value);
}
}
fn flatten_value(value: &Value, prefix: &str) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
match value {
Value::Object(obj) => {
for (k, v) in obj {
let new_prefix = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
map.extend(flatten_value(v, &new_prefix));
}
}
Value::Array(arr) => {
for (i, v) in arr.iter().enumerate() {
let new_prefix = format!("{prefix}.{i}");
map.extend(flatten_value(v, &new_prefix));
}
}
Value::String(s) => {
map.insert(prefix.to_string(), s.clone());
}
Value::Number(n) => {
map.insert(prefix.to_string(), n.to_string());
}
Value::Bool(b) => {
map.insert(prefix.to_string(), b.to_string());
}
Value::Null => {
map.insert(prefix.to_string(), String::new());
}
}
map
}
pub fn run(
input: &str,
to: &str,
from: Option<&str>,
output: Option<&str>,
json_output: bool,
) -> Result<(), ConvertError> {
let input_path = Path::new(input);
let to_format = ConfigFormat::from_label(to).ok_or_else(|| {
ConvertError::UnsupportedFormat(format!(
"Unknown target format: '{to}'. Use: json, yaml, toml, env"
))
})?;
let from_format = from
.map(|f| {
ConfigFormat::from_label(f).ok_or_else(|| {
ConvertError::UnsupportedFormat(format!(
"Unknown source format: '{f}'. Use: json, yaml, toml, env"
))
})
})
.transpose()?;
let output_path = output.map(Path::new);
let result = convert_file(input_path, from_format, to_format, output_path)?;
if json_output {
let json_str =
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string());
println!("{json_str}");
} else {
println!();
println!(
" {} {} {}",
"devpulse".bold(),
"──".dimmed(),
"Config Converter".bold()
);
println!();
println!(
" {} {} → {}",
"Format:".dimmed(),
result.input_format.cyan().bold(),
result.output_format.green().bold()
);
println!(
" {} {}",
"Input: ".dimmed(),
result.input_path.white()
);
if let Some(ref op) = result.output_path {
println!(" {} {}", "Output:".dimmed(), op.green());
} else {
let stem = Path::new(input)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "output".to_string());
let suggested = format!("{stem}.{}", to_format.extension());
println!(
" {} {} (use -o to write)",
"Hint: ".dimmed(),
suggested.dimmed()
);
}
println!();
println!(" {}", "── Converted Output ──".dimmed());
println!();
for line in result.output.lines() {
println!(" {line}");
}
println!();
}
Ok(())
}
pub fn convert_for_tui(
input: &str,
to: &str,
from: Option<&str>,
) -> Result<ConvertResult, ConvertError> {
let input_path = Path::new(input);
let to_format = ConfigFormat::from_label(to).ok_or_else(|| {
ConvertError::UnsupportedFormat(format!(
"Unknown target format: '{to}'. Use: json, yaml, toml, env"
))
})?;
let from_format = from
.map(|f| {
ConfigFormat::from_label(f).ok_or_else(|| {
ConvertError::UnsupportedFormat(format!(
"Unknown source format: '{f}'. Use: json, yaml, toml, env"
))
})
})
.transpose()?;
convert_file(input_path, from_format, to_format, None)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_json_to_yaml() {
let json = r#"{"name": "devpulse", "version": "0.1.0"}"#;
let val = parse_input(json, ConfigFormat::Json).unwrap();
let yaml = serialize_output(&val, ConfigFormat::Yaml).unwrap();
assert!(yaml.contains("name: devpulse"));
assert!(
yaml.contains("version: '0.1.0'")
|| yaml.contains("version: \"0.1.0\"")
|| yaml.contains("version: 0.1.0"),
"Expected version field in YAML, got: {yaml}"
);
}
#[test]
fn test_json_to_toml() {
let json = r#"{"name": "devpulse", "version": "0.1.0"}"#;
let val = parse_input(json, ConfigFormat::Json).unwrap();
let toml_out = serialize_output(&val, ConfigFormat::Toml).unwrap();
assert!(toml_out.contains("name = \"devpulse\""));
assert!(toml_out.contains("version = \"0.1.0\""));
}
#[test]
fn test_json_to_env() {
let json = r#"{"database": {"host": "localhost", "port": 5432}}"#;
let val = parse_input(json, ConfigFormat::Json).unwrap();
let env_out = serialize_output(&val, ConfigFormat::Env).unwrap();
assert!(env_out.contains("database.host=localhost"));
assert!(env_out.contains("database.port=5432"));
}
#[test]
fn test_env_to_json() {
let env_content = "DB_HOST=localhost\nDB_PORT=5432\n# comment\n\nAPP_NAME=test";
let val = parse_input(env_content, ConfigFormat::Env).unwrap();
let json = serialize_output(&val, ConfigFormat::Json).unwrap();
assert!(json.contains("DB_HOST"));
assert!(json.contains("localhost"));
assert!(json.contains("DB_PORT"));
}
#[test]
fn test_yaml_to_json() {
let yaml = "name: devpulse\nversion: 0.1.0\n";
let val = parse_input(yaml, ConfigFormat::Yaml).unwrap();
let json = serialize_output(&val, ConfigFormat::Json).unwrap();
assert!(json.contains("\"name\": \"devpulse\""));
}
#[test]
fn test_toml_to_json() {
let toml_input = "name = \"devpulse\"\nversion = \"0.1.0\"\n";
let val = parse_input(toml_input, ConfigFormat::Toml).unwrap();
let json = serialize_output(&val, ConfigFormat::Json).unwrap();
assert!(json.contains("\"name\": \"devpulse\""));
}
#[test]
fn test_dotenv_quote_stripping() {
let env_content = "KEY1=\"quoted value\"\nKEY2='single quoted'\nKEY3=plain";
let val = parse_input(env_content, ConfigFormat::Env).unwrap();
let json = serialize_output(&val, ConfigFormat::Json).unwrap();
assert!(json.contains("quoted value"));
assert!(json.contains("single quoted"));
assert!(json.contains("plain"));
}
#[test]
fn test_nested_env_unflatten() {
let env_content = "db.host=localhost\ndb.port=5432\napp.name=test";
let val = parse_input(env_content, ConfigFormat::Env).unwrap();
let obj = val.as_object().unwrap();
assert!(obj.contains_key("db"));
assert!(obj.contains_key("app"));
let db = obj["db"].as_object().unwrap();
assert_eq!(db["host"].as_str().unwrap(), "localhost");
}
#[test]
fn test_format_from_extension() {
assert_eq!(ConfigFormat::from_extension(Path::new("config.json")), Some(ConfigFormat::Json));
assert_eq!(ConfigFormat::from_extension(Path::new("config.yaml")), Some(ConfigFormat::Yaml));
assert_eq!(ConfigFormat::from_extension(Path::new("config.yml")), Some(ConfigFormat::Yaml));
assert_eq!(ConfigFormat::from_extension(Path::new("config.toml")), Some(ConfigFormat::Toml));
assert_eq!(ConfigFormat::from_extension(Path::new("config.env")), Some(ConfigFormat::Env));
assert_eq!(ConfigFormat::from_extension(Path::new("config.txt")), None);
}
#[test]
fn test_format_extension_and_label() {
assert_eq!(ConfigFormat::Json.extension(), "json");
assert_eq!(ConfigFormat::Yaml.extension(), "yaml");
assert_eq!(ConfigFormat::Toml.extension(), "toml");
assert_eq!(ConfigFormat::Env.extension(), "env");
assert_eq!(ConfigFormat::Json.label(), "JSON");
assert_eq!(ConfigFormat::Yaml.label(), "YAML");
assert_eq!(ConfigFormat::Toml.label(), "TOML");
assert_eq!(ConfigFormat::Env.label(), ".env");
}
#[test]
fn test_format_from_label() {
assert_eq!(ConfigFormat::from_label("json"), Some(ConfigFormat::Json));
assert_eq!(ConfigFormat::from_label("YAML"), Some(ConfigFormat::Yaml));
assert_eq!(ConfigFormat::from_label("yml"), Some(ConfigFormat::Yaml));
assert_eq!(ConfigFormat::from_label("toml"), Some(ConfigFormat::Toml));
assert_eq!(ConfigFormat::from_label("env"), Some(ConfigFormat::Env));
assert_eq!(ConfigFormat::from_label("dotenv"), Some(ConfigFormat::Env));
assert_eq!(ConfigFormat::from_label("xml"), None);
}
#[test]
fn test_convert_file_json_to_yaml() {
let mut tmp = tempfile::Builder::new().suffix(".json").tempfile().unwrap();
writeln!(tmp, r#"{{"server": "localhost", "port": 8080}}"#).unwrap();
let result = convert_file(tmp.path(), None, ConfigFormat::Yaml, None).unwrap();
assert_eq!(result.input_format, "JSON");
assert_eq!(result.output_format, "YAML");
assert!(result.output.contains("server: localhost"));
}
#[test]
fn test_convert_result_serialization() {
let result = ConvertResult {
input_path: "test.json".into(),
input_format: "JSON".into(),
output_format: "YAML".into(),
output: "key: value".into(),
output_path: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("input_path"));
assert!(json.contains("YAML"));
}
#[test]
fn test_roundtrip_json_yaml_json() {
let original = r#"{"alpha": "one", "beta": {"nested": true}}"#;
let val = parse_input(original, ConfigFormat::Json).unwrap();
let yaml = serialize_output(&val, ConfigFormat::Yaml).unwrap();
let val2 = parse_input(&yaml, ConfigFormat::Yaml).unwrap();
let json2 = serialize_output(&val2, ConfigFormat::Json).unwrap();
let val3 = parse_input(&json2, ConfigFormat::Json).unwrap();
assert_eq!(val, val3);
}
#[test]
fn test_roundtrip_json_toml_json() {
let original = r#"{"name": "test", "count": 42}"#;
let val = parse_input(original, ConfigFormat::Json).unwrap();
let toml_out = serialize_output(&val, ConfigFormat::Toml).unwrap();
let val2 = parse_input(&toml_out, ConfigFormat::Toml).unwrap();
let json2 = serialize_output(&val2, ConfigFormat::Json).unwrap();
let val3 = parse_input(&json2, ConfigFormat::Json).unwrap();
assert_eq!(val, val3);
}
}