config-forge 0.1.1

A CLI tool for converting, inspecting, and validating configuration files.
Documentation
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),
    }
}