use std::fs;
use std::path::Path;
use std::str::FromStr;
use anyhow::{Context, Result, bail};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
pub const NAME: &str = "ConfigForge";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub fn describe() -> &'static str {
"A CLI tool for converting, inspecting, and validating configuration files."
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ConfigValue {
Null,
Bool(bool),
Integer(i64),
Float(f64),
String(String),
Array(Vec<ConfigValue>),
Object(IndexMap<String, ConfigValue>),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Format {
Json,
Toml,
Yaml,
}
impl Format {
pub fn detect_path(path: &Path) -> Result<Self> {
let extension = path
.extension()
.and_then(|value| value.to_str())
.map(str::to_ascii_lowercase)
.with_context(|| format!("cannot detect format for `{}`", path.display()))?;
match extension.as_str() {
"json" => Ok(Self::Json),
"toml" => Ok(Self::Toml),
"yaml" | "yml" => Ok(Self::Yaml),
_ => bail!("unsupported file extension `{extension}`"),
}
}
pub fn name(self) -> &'static str {
match self {
Self::Json => "json",
Self::Toml => "toml",
Self::Yaml => "yaml",
}
}
}
impl FromStr for Format {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self> {
match value.to_ascii_lowercase().as_str() {
"json" => Ok(Self::Json),
"toml" => Ok(Self::Toml),
"yaml" | "yml" => Ok(Self::Yaml),
_ => bail!("unsupported format `{value}`"),
}
}
}
#[derive(Debug)]
pub struct DocumentInfo {
pub format: Format,
pub root_kind: &'static str,
pub size_bytes: u64,
}
pub fn inspect_path(path: impl AsRef<Path>, format: Option<Format>) -> Result<DocumentInfo> {
let path = path.as_ref();
let format = match format {
Some(format) => format,
None => Format::detect_path(path)?,
};
let content = fs::read_to_string(path)
.with_context(|| format!("failed to read input file `{}`", path.display()))?;
let value = parse_str(&content, format)?;
let metadata = fs::metadata(path)
.with_context(|| format!("failed to read metadata for `{}`", path.display()))?;
Ok(DocumentInfo {
format,
root_kind: value.kind(),
size_bytes: metadata.len(),
})
}
pub fn convert_path(
input: impl AsRef<Path>,
output: Option<impl AsRef<Path>>,
from: Option<Format>,
to: Option<Format>,
overwrite: bool,
) -> Result<String> {
let input = input.as_ref();
let input_format = match from {
Some(format) => format,
None => Format::detect_path(input)?,
};
let output_format = match (to, output.as_ref().map(|path| path.as_ref())) {
(Some(format), _) => format,
(None, Some(path)) => Format::detect_path(path)?,
(None, None) => bail!("output format is required when writing to stdout"),
};
if let Some(output) = output.as_ref().map(|path| path.as_ref()) {
if output.exists() && !overwrite {
bail!(
"output file `{}` already exists; pass --overwrite to replace it",
output.display()
);
}
}
let content = fs::read_to_string(input)
.with_context(|| format!("failed to read input file `{}`", input.display()))?;
let value = parse_str(&content, input_format)?;
let rendered = render_string(&value, output_format)?;
if let Some(output) = output {
fs::write(output.as_ref(), &rendered).with_context(|| {
format!(
"failed to write output file `{}`",
output.as_ref().display()
)
})?;
}
Ok(rendered)
}
pub fn check_convert_path(
input: impl AsRef<Path>,
from: Option<Format>,
to: Option<Format>,
) -> Result<Format> {
let input = input.as_ref();
let input_format = match from {
Some(format) => format,
None => Format::detect_path(input)?,
};
let output_format = to.context("output format is required for --check")?;
let content = fs::read_to_string(input)
.with_context(|| format!("failed to read input file `{}`", input.display()))?;
let value = parse_str(&content, input_format)?;
render_string(&value, output_format)?;
Ok(output_format)
}
pub fn parse_str(content: &str, format: Format) -> Result<ConfigValue> {
match format {
Format::Json => {
let value: serde_json::Value =
serde_json::from_str(content).context("failed to parse JSON")?;
json_to_config(value)
}
Format::Toml => {
let value: toml::Value = toml::from_str(content).context("failed to parse TOML")?;
toml_to_config(value)
}
Format::Yaml => serde_yaml::from_str(content).context("failed to parse YAML"),
}
}
pub fn render_string(value: &ConfigValue, format: Format) -> Result<String> {
match format {
Format::Json => {
let mut rendered =
serde_json::to_string_pretty(value).context("failed to render JSON")?;
rendered.push('\n');
Ok(rendered)
}
Format::Toml => {
if !matches!(value, ConfigValue::Object(_)) {
bail!("TOML output requires an object at the document root");
}
let rendered = toml::to_string_pretty(value).context("failed to render TOML")?;
Ok(rendered)
}
Format::Yaml => serde_yaml::to_string(value).context("failed to render YAML"),
}
}
impl ConfigValue {
pub fn kind(&self) -> &'static str {
match self {
Self::Null => "null",
Self::Bool(_) => "bool",
Self::Integer(_) => "integer",
Self::Float(_) => "float",
Self::String(_) => "string",
Self::Array(_) => "array",
Self::Object(_) => "object",
}
}
}
fn json_to_config(value: serde_json::Value) -> Result<ConfigValue> {
match value {
serde_json::Value::Null => Ok(ConfigValue::Null),
serde_json::Value::Bool(value) => Ok(ConfigValue::Bool(value)),
serde_json::Value::Number(value) => {
if let Some(value) = value.as_i64() {
Ok(ConfigValue::Integer(value))
} else if value.as_u64().is_some() {
bail!("JSON unsigned integer exceeds the supported i64 range")
} else if let Some(value) = value.as_f64() {
Ok(ConfigValue::Float(value))
} else {
bail!("unsupported JSON number `{value}`")
}
}
serde_json::Value::String(value) => Ok(ConfigValue::String(value)),
serde_json::Value::Array(values) => values
.into_iter()
.map(json_to_config)
.collect::<Result<Vec<_>>>()
.map(ConfigValue::Array),
serde_json::Value::Object(values) => values
.into_iter()
.map(|(key, value)| json_to_config(value).map(|value| (key, value)))
.collect::<Result<IndexMap<_, _>>>()
.map(ConfigValue::Object),
}
}
fn toml_to_config(value: toml::Value) -> Result<ConfigValue> {
match value {
toml::Value::String(value) => Ok(ConfigValue::String(value)),
toml::Value::Integer(value) => Ok(ConfigValue::Integer(value)),
toml::Value::Float(value) => Ok(ConfigValue::Float(value)),
toml::Value::Boolean(value) => Ok(ConfigValue::Bool(value)),
toml::Value::Datetime(value) => Ok(ConfigValue::String(value.to_string())),
toml::Value::Array(values) => values
.into_iter()
.map(toml_to_config)
.collect::<Result<Vec<_>>>()
.map(ConfigValue::Array),
toml::Value::Table(values) => values
.into_iter()
.map(|(key, value)| toml_to_config(value).map(|value| (key, value)))
.collect::<Result<IndexMap<_, _>>>()
.map(ConfigValue::Object),
}
}