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,
Env,
Ini,
Properties,
}
impl Format {
pub fn detect_path(path: &Path) -> Result<Self> {
if path
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case(".env"))
{
return Ok(Self::Env);
}
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),
"env" => Ok(Self::Env),
"ini" => Ok(Self::Ini),
"properties" => Ok(Self::Properties),
_ => bail!("unsupported file extension `{extension}`"),
}
}
pub fn name(self) -> &'static str {
match self {
Self::Json => "json",
Self::Toml => "toml",
Self::Yaml => "yaml",
Self::Env => "env",
Self::Ini => "ini",
Self::Properties => "properties",
}
}
}
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),
"env" | "dotenv" => Ok(Self::Env),
"ini" => Ok(Self::Ini),
"properties" | "props" => Ok(Self::Properties),
_ => bail!("unsupported format `{value}`"),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ValueFormat {
String,
Json,
}
impl FromStr for ValueFormat {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self> {
match value.to_ascii_lowercase().as_str() {
"string" => Ok(Self::String),
"json" => Ok(Self::Json),
_ => bail!("unsupported value format `{value}`"),
}
}
}
#[derive(Debug)]
pub struct DocumentInfo {
pub format: Format,
pub root_kind: &'static str,
pub size_bytes: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DiffKind {
Added,
Removed,
Changed,
}
impl DiffKind {
pub fn name(&self) -> &'static str {
match self {
Self::Added => "added",
Self::Removed => "removed",
Self::Changed => "changed",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiffEntry {
pub kind: DiffKind,
pub path: String,
}
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 validate_path(path: impl AsRef<Path>, format: Option<Format>) -> Result<Format> {
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()))?;
parse_str(&content, format)?;
Ok(format)
}
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()) {
ensure_output_can_be_written(output, overwrite)?;
}
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 set_path_value(
input: impl AsRef<Path>,
output: impl AsRef<Path>,
query: &str,
raw_value: &str,
value_format: ValueFormat,
from: Option<Format>,
to: Option<Format>,
overwrite: bool,
) -> Result<String> {
let input = input.as_ref();
let output = output.as_ref();
let input_format = match from {
Some(format) => format,
None => Format::detect_path(input)?,
};
let output_format = match to {
Some(format) => format,
None => Format::detect_path(output)?,
};
ensure_output_can_be_written(output, overwrite)?;
let content = fs::read_to_string(input)
.with_context(|| format!("failed to read input file `{}`", input.display()))?;
let mut value = parse_str(&content, input_format)?;
let replacement = parse_value_literal(raw_value, value_format)?;
set_query_value(&mut value, query, replacement)?;
let rendered = render_string(&value, output_format)?;
write_output(output, &rendered)?;
Ok(rendered)
}
pub fn delete_path_value(
input: impl AsRef<Path>,
output: impl AsRef<Path>,
query: &str,
from: Option<Format>,
to: Option<Format>,
overwrite: bool,
) -> Result<String> {
let input = input.as_ref();
let output = output.as_ref();
let input_format = match from {
Some(format) => format,
None => Format::detect_path(input)?,
};
let output_format = match to {
Some(format) => format,
None => Format::detect_path(output)?,
};
ensure_output_can_be_written(output, overwrite)?;
let content = fs::read_to_string(input)
.with_context(|| format!("failed to read input file `{}`", input.display()))?;
let mut value = parse_str(&content, input_format)?;
delete_query_value(&mut value, query)?;
let rendered = render_string(&value, output_format)?;
write_output(output, &rendered)?;
Ok(rendered)
}
pub fn merge_paths(
base: impl AsRef<Path>,
override_file: impl AsRef<Path>,
output: impl AsRef<Path>,
base_format: Option<Format>,
override_format: Option<Format>,
output_format: Option<Format>,
overwrite: bool,
) -> Result<String> {
let base = base.as_ref();
let override_file = override_file.as_ref();
let output = output.as_ref();
let base_format = match base_format {
Some(format) => format,
None => Format::detect_path(base)?,
};
let override_format = match override_format {
Some(format) => format,
None => Format::detect_path(override_file)?,
};
let output_format = match output_format {
Some(format) => format,
None => Format::detect_path(output)?,
};
ensure_output_can_be_written(output, overwrite)?;
let base_content = fs::read_to_string(base)
.with_context(|| format!("failed to read base file `{}`", base.display()))?;
let override_content = fs::read_to_string(override_file)
.with_context(|| format!("failed to read override file `{}`", override_file.display()))?;
let mut value = parse_str(&base_content, base_format)?;
let override_value = parse_str(&override_content, override_format)?;
merge_values(&mut value, override_value);
let rendered = render_string(&value, output_format)?;
write_output(output, &rendered)?;
Ok(rendered)
}
pub fn diff_paths(
old: impl AsRef<Path>,
new: impl AsRef<Path>,
old_format: Option<Format>,
new_format: Option<Format>,
) -> Result<Vec<DiffEntry>> {
let old = old.as_ref();
let new = new.as_ref();
let old_format = match old_format {
Some(format) => format,
None => Format::detect_path(old)?,
};
let new_format = match new_format {
Some(format) => format,
None => Format::detect_path(new)?,
};
let old_content = fs::read_to_string(old)
.with_context(|| format!("failed to read old file `{}`", old.display()))?;
let new_content = fs::read_to_string(new)
.with_context(|| format!("failed to read new file `{}`", new.display()))?;
let old_value = parse_str(&old_content, old_format)?;
let new_value = parse_str(&new_content, new_format)?;
let mut entries = Vec::new();
diff_values(&old_value, &new_value, "", &mut entries);
Ok(entries)
}
pub fn render_diff(entries: &[DiffEntry]) -> String {
if entries.is_empty() {
return "no changes\n".to_string();
}
let mut rendered = String::new();
for entry in entries {
rendered.push_str(entry.kind.name());
rendered.push(' ');
rendered.push_str(&entry.path);
rendered.push('\n');
}
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 get_path_value(
input: impl AsRef<Path>,
query: &str,
from: Option<Format>,
) -> Result<ConfigValue> {
let input = input.as_ref();
let input_format = match from {
Some(format) => format,
None => Format::detect_path(input)?,
};
let content = fs::read_to_string(input)
.with_context(|| format!("failed to read input file `{}`", input.display()))?;
let value = parse_str(&content, input_format)?;
query_value(&value, query).cloned()
}
pub fn render_query_value(value: &ConfigValue, format: Option<Format>) -> Result<String> {
match format {
Some(format) => render_string(value, format),
None => match value {
ConfigValue::Null => Ok("null\n".to_string()),
ConfigValue::Bool(value) => Ok(format!("{value}\n")),
ConfigValue::Integer(value) => Ok(format!("{value}\n")),
ConfigValue::Float(value) => Ok(format!("{value}\n")),
ConfigValue::String(value) => Ok(format!("{value}\n")),
ConfigValue::Array(_) | ConfigValue::Object(_) => render_string(value, Format::Json),
},
}
}
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"),
Format::Env => parse_env(content),
Format::Ini => parse_ini(content),
Format::Properties => parse_properties(content),
}
}
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"),
Format::Env => render_env(value),
Format::Ini => render_ini(value),
Format::Properties => render_properties(value),
}
}
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 query_value<'a>(value: &'a ConfigValue, query: &str) -> Result<&'a ConfigValue> {
let segments = path_segments(query)?;
let mut current = value;
let mut visited: Vec<&str> = Vec::new();
for segment in segments {
match current {
ConfigValue::Object(values) => {
visited.push(segment);
current = values
.get(segment)
.with_context(|| format!("path `{}` not found", visited.join(".")))?;
}
ConfigValue::Array(values) => {
let index = segment.parse::<usize>().with_context(|| {
format!("path segment `{segment}` cannot be applied to array")
})?;
visited.push(segment);
current = values
.get(index)
.with_context(|| format!("path `{}` index out of bounds", visited.join(".")))?;
}
scalar => {
bail!(
"path segment `{segment}` cannot be applied to {}",
scalar.kind()
);
}
}
}
Ok(current)
}
fn set_query_value(value: &mut ConfigValue, query: &str, replacement: ConfigValue) -> Result<()> {
let (parent, segment) = query_parent_mut(value, query)?;
match parent {
ConfigValue::Object(values) => {
let slot = values
.get_mut(segment)
.with_context(|| format!("path `{query}` not found"))?;
*slot = replacement;
Ok(())
}
ConfigValue::Array(values) => {
let index = segment
.parse::<usize>()
.with_context(|| format!("path segment `{segment}` cannot be applied to array"))?;
let slot = values
.get_mut(index)
.with_context(|| format!("path `{query}` index out of bounds"))?;
*slot = replacement;
Ok(())
}
scalar => {
bail!(
"path segment `{segment}` cannot be applied to {}",
scalar.kind()
);
}
}
}
fn delete_query_value(value: &mut ConfigValue, query: &str) -> Result<()> {
let (parent, segment) = query_parent_mut(value, query)?;
match parent {
ConfigValue::Object(values) => {
if values.shift_remove(segment).is_none() {
bail!("path `{query}` not found");
}
Ok(())
}
ConfigValue::Array(values) => {
let index = segment
.parse::<usize>()
.with_context(|| format!("path segment `{segment}` cannot be applied to array"))?;
if index >= values.len() {
bail!("path `{query}` index out of bounds");
}
values.remove(index);
Ok(())
}
scalar => {
bail!(
"path segment `{segment}` cannot be applied to {}",
scalar.kind()
);
}
}
}
fn query_parent_mut<'a, 'q>(
value: &'a mut ConfigValue,
query: &'q str,
) -> Result<(&'a mut ConfigValue, &'q str)> {
let segments = path_segments(query)?;
let (segment, parents) = segments
.split_last()
.expect("path_segments rejects empty paths");
let mut current = value;
let mut visited: Vec<&str> = Vec::new();
for parent_segment in parents {
match current {
ConfigValue::Object(values) => {
visited.push(parent_segment);
current = values
.get_mut(*parent_segment)
.with_context(|| format!("path `{}` not found", visited.join(".")))?;
}
ConfigValue::Array(values) => {
let index = parent_segment.parse::<usize>().with_context(|| {
format!("path segment `{parent_segment}` cannot be applied to array")
})?;
visited.push(parent_segment);
current = values
.get_mut(index)
.with_context(|| format!("path `{}` index out of bounds", visited.join(".")))?;
}
scalar => {
bail!(
"path segment `{parent_segment}` cannot be applied to {}",
scalar.kind()
);
}
}
}
Ok((current, *segment))
}
fn path_segments(query: &str) -> Result<Vec<&str>> {
if query.is_empty() {
bail!("empty path is not supported");
}
let segments = query.split('.').collect::<Vec<_>>();
if segments.iter().any(|segment| segment.is_empty()) {
bail!("empty path segment is not supported in `{query}`");
}
Ok(segments)
}
fn parse_env(content: &str) -> Result<ConfigValue> {
let mut values = IndexMap::new();
for (line_index, raw_line) in content.lines().enumerate() {
let line_number = line_index + 1;
let mut line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(rest) = line.strip_prefix("export ") {
line = rest.trim_start();
}
let (key, raw_value) = line
.split_once('=')
.with_context(|| format!("invalid env assignment on line {line_number}"))?;
let key = key.trim();
if key.is_empty() {
bail!("empty env key on line {line_number}");
}
values.insert(
key.to_string(),
ConfigValue::String(unquote_key_value(raw_value.trim()).to_string()),
);
}
Ok(ConfigValue::Object(values))
}
fn parse_ini(content: &str) -> Result<ConfigValue> {
let mut values = IndexMap::new();
let mut current_section: Option<String> = None;
for (line_index, raw_line) in content.lines().enumerate() {
let line_number = line_index + 1;
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
let section = line[1..line.len() - 1].trim();
if section.is_empty() {
bail!("empty INI section on line {line_number}");
}
values
.entry(section.to_string())
.or_insert_with(|| ConfigValue::Object(IndexMap::new()));
current_section = Some(section.to_string());
continue;
}
let (key, raw_value) = line
.split_once('=')
.with_context(|| format!("invalid INI assignment on line {line_number}"))?;
let key = key.trim();
if key.is_empty() {
bail!("empty INI key on line {line_number}");
}
let value = ConfigValue::String(unquote_key_value(raw_value.trim()).to_string());
match current_section.as_deref() {
Some(section) => {
let section_value = values
.entry(section.to_string())
.or_insert_with(|| ConfigValue::Object(IndexMap::new()));
match section_value {
ConfigValue::Object(section_values) => {
section_values.insert(key.to_string(), value);
}
existing => {
bail!(
"INI section `{section}` conflicts with existing {} value",
existing.kind()
);
}
}
}
None => {
values.insert(key.to_string(), value);
}
}
}
Ok(ConfigValue::Object(values))
}
fn parse_properties(content: &str) -> Result<ConfigValue> {
let mut values = IndexMap::new();
for (line_index, raw_line) in content.lines().enumerate() {
let line_number = line_index + 1;
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
continue;
}
let (key, raw_value) = split_properties_assignment(line)
.with_context(|| format!("invalid properties assignment on line {line_number}"))?;
let key = key.trim();
if key.is_empty() {
bail!("empty properties key on line {line_number}");
}
insert_dotted_value(
&mut values,
key,
ConfigValue::String(unquote_key_value(raw_value.trim()).to_string()),
)?;
}
Ok(ConfigValue::Object(values))
}
fn split_properties_assignment(line: &str) -> Option<(&str, &str)> {
line.find(['=', ':'])
.map(|index| (&line[..index], &line[index + 1..]))
}
fn insert_dotted_value(
values: &mut IndexMap<String, ConfigValue>,
key: &str,
value: ConfigValue,
) -> Result<()> {
let segments = path_segments(key)?;
insert_dotted_segments(values, key, &segments, value)
}
fn insert_dotted_segments(
values: &mut IndexMap<String, ConfigValue>,
key: &str,
segments: &[&str],
value: ConfigValue,
) -> Result<()> {
let (segment, remaining) = segments
.split_first()
.expect("path_segments rejects empty paths");
if remaining.is_empty() {
if matches!(values.get(*segment), Some(ConfigValue::Object(_))) {
bail!("properties path `{key}` conflicts with existing object value");
}
values.insert((*segment).to_string(), value);
return Ok(());
}
let child = values
.entry((*segment).to_string())
.or_insert_with(|| ConfigValue::Object(IndexMap::new()));
match child {
ConfigValue::Object(child_values) => {
insert_dotted_segments(child_values, key, remaining, value)
}
existing => bail!(
"properties path `{key}` conflicts with existing {} value",
existing.kind()
),
}
}
fn unquote_key_value(value: &str) -> &str {
if value.len() >= 2 {
let bytes = value.as_bytes();
if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'')
{
return &value[1..value.len() - 1];
}
}
value
}
fn render_env(value: &ConfigValue) -> Result<String> {
let values = object_root(value, "env")?;
let mut rendered = String::new();
for (key, value) in values {
match value {
ConfigValue::String(value) => {
rendered.push_str(key);
rendered.push('=');
rendered.push_str(value);
rendered.push('\n');
}
_ => bail!("env output only supports top-level string values"),
}
}
Ok(rendered)
}
fn render_ini(value: &ConfigValue) -> Result<String> {
let values = object_root(value, "INI")?;
let mut rendered = String::new();
for (key, value) in values {
match value {
ConfigValue::String(value) => push_key_value_line(&mut rendered, key, value),
ConfigValue::Object(_) => {}
_ => bail!("INI output only supports top-level string values or sections"),
}
}
for (section, value) in values {
let ConfigValue::Object(section_values) = value else {
continue;
};
if !rendered.is_empty() && !rendered.ends_with("\n\n") {
rendered.push('\n');
}
rendered.push('[');
rendered.push_str(section);
rendered.push_str("]\n");
for (key, value) in section_values {
match value {
ConfigValue::String(value) => push_key_value_line(&mut rendered, key, value),
_ => bail!("INI output only supports string values in sections"),
}
}
}
Ok(rendered)
}
fn render_properties(value: &ConfigValue) -> Result<String> {
object_root(value, "properties")?;
let mut rendered = String::new();
render_properties_value(value, "", &mut rendered)?;
Ok(rendered)
}
fn render_properties_value(value: &ConfigValue, path: &str, rendered: &mut String) -> Result<()> {
match value {
ConfigValue::Object(values) => {
for (key, value) in values {
let child_path = join_path(path, key);
render_properties_value(value, &child_path, rendered)?;
}
}
ConfigValue::String(value) => {
if path.is_empty() {
bail!("properties output requires object keys");
}
push_key_value_line(rendered, path, value);
}
_ => bail!("properties output only supports string leaf values"),
}
Ok(())
}
fn object_root<'a>(
value: &'a ConfigValue,
format_name: &str,
) -> Result<&'a IndexMap<String, ConfigValue>> {
match value {
ConfigValue::Object(values) => Ok(values),
_ => bail!("{format_name} output requires an object at the document root"),
}
}
fn push_key_value_line(rendered: &mut String, key: &str, value: &str) {
rendered.push_str(key);
rendered.push('=');
rendered.push_str(value);
rendered.push('\n');
}
fn parse_value_literal(raw_value: &str, value_format: ValueFormat) -> Result<ConfigValue> {
match value_format {
ValueFormat::String => Ok(ConfigValue::String(raw_value.to_string())),
ValueFormat::Json => {
let value: serde_json::Value =
serde_json::from_str(raw_value).context("failed to parse JSON value")?;
json_to_config(value)
}
}
}
fn ensure_output_can_be_written(output: &Path, overwrite: bool) -> Result<()> {
if output.exists() && !overwrite {
bail!(
"output file `{}` already exists; pass --overwrite to replace it",
output.display()
);
}
Ok(())
}
fn write_output(output: &Path, rendered: &str) -> Result<()> {
fs::write(output, rendered)
.with_context(|| format!("failed to write output file `{}`", output.display()))
}
fn merge_values(base: &mut ConfigValue, override_value: ConfigValue) {
match (base, override_value) {
(ConfigValue::Object(base_values), ConfigValue::Object(override_values)) => {
for (key, override_value) in override_values {
match base_values.get_mut(&key) {
Some(base_value) => merge_values(base_value, override_value),
None => {
base_values.insert(key, override_value);
}
}
}
}
(base_value, override_value) => {
*base_value = override_value;
}
}
}
fn diff_values(old: &ConfigValue, new: &ConfigValue, path: &str, entries: &mut Vec<DiffEntry>) {
match (old, new) {
(ConfigValue::Object(old_values), ConfigValue::Object(new_values)) => {
for (key, old_value) in old_values {
let child_path = join_path(path, key);
match new_values.get(key) {
Some(new_value) => diff_values(old_value, new_value, &child_path, entries),
None => entries.push(DiffEntry {
kind: DiffKind::Removed,
path: child_path,
}),
}
}
for key in new_values.keys() {
if !old_values.contains_key(key) {
entries.push(DiffEntry {
kind: DiffKind::Added,
path: join_path(path, key),
});
}
}
}
(ConfigValue::Array(old_values), ConfigValue::Array(new_values)) => {
let shared_len = old_values.len().min(new_values.len());
for index in 0..shared_len {
let child_path = join_path(path, &index.to_string());
diff_values(&old_values[index], &new_values[index], &child_path, entries);
}
for index in shared_len..old_values.len() {
entries.push(DiffEntry {
kind: DiffKind::Removed,
path: join_path(path, &index.to_string()),
});
}
for index in shared_len..new_values.len() {
entries.push(DiffEntry {
kind: DiffKind::Added,
path: join_path(path, &index.to_string()),
});
}
}
_ => {
if old != new {
entries.push(DiffEntry {
kind: DiffKind::Changed,
path: display_path(path),
});
}
}
}
}
fn join_path(path: &str, segment: &str) -> String {
if path.is_empty() {
segment.to_string()
} else {
format!("{path}.{segment}")
}
}
fn display_path(path: &str) -> String {
if path.is_empty() {
"<root>".to_string()
} else {
path.to_string()
}
}
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),
}
}