config-forge 0.5.0

A CLI tool for converting, inspecting, and validating configuration files.
Documentation
use std::path::PathBuf;

use anyhow::{Result, bail};
use clap::{Parser, Subcommand};
use config_forge::{
    Format, ValueFormat, check_convert_path, convert_path, delete_path_value, diff_paths,
    get_path_value, inspect_path, merge_paths, render_diff, render_query_value, set_path_value,
    validate_path,
};

#[derive(Debug, Parser)]
#[command(name = "config-forge")]
#[command(version, about = config_forge::describe())]
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(Debug, Subcommand)]
enum Command {
    /// Convert between JSON, TOML, YAML, env, INI, and properties.
    Convert {
        /// Input configuration file.
        input: PathBuf,

        /// Output file. If omitted, converted content is printed to stdout.
        #[arg(short, long)]
        output: Option<PathBuf>,

        /// Input format. Defaults to detection from input extension.
        #[arg(long, value_parser = parse_format)]
        from: Option<Format>,

        /// Output format. Defaults to detection from output extension.
        #[arg(long, value_parser = parse_format)]
        to: Option<Format>,

        /// Validate that conversion would succeed without writing output.
        #[arg(long)]
        check: bool,

        /// Replace the output file if it already exists.
        #[arg(long)]
        overwrite: bool,
    },

    /// Print basic information about a configuration file.
    Inspect {
        /// Input configuration file.
        input: PathBuf,

        /// Input format. Defaults to detection from input extension.
        #[arg(long, value_parser = parse_format)]
        from: Option<Format>,
    },

    /// Validate that a configuration file can be parsed.
    Validate {
        /// Input configuration file.
        input: PathBuf,

        /// Input format. Defaults to detection from input extension.
        #[arg(long, value_parser = parse_format)]
        from: Option<Format>,
    },

    /// Read a value by dot path.
    Get {
        /// Input configuration file.
        input: PathBuf,

        /// Dot path to read, such as server.port or items.0.name.
        path: String,

        /// Input format. Defaults to detection from input extension.
        #[arg(long, value_parser = parse_format)]
        from: Option<Format>,

        /// Output format for the selected value.
        #[arg(long, value_parser = parse_format)]
        to: Option<Format>,
    },

    /// Update an existing value by dot path and write a new file.
    Set {
        /// Input configuration file.
        input: PathBuf,

        /// Dot path to update, such as server.port or items.0.name.
        path: String,

        /// Replacement value. Defaults to a string literal.
        value: String,

        /// Output file.
        #[arg(short, long)]
        output: PathBuf,

        /// Input format. Defaults to detection from input extension.
        #[arg(long, value_parser = parse_format)]
        from: Option<Format>,

        /// Output format. Defaults to detection from output extension.
        #[arg(long, value_parser = parse_format)]
        to: Option<Format>,

        /// How to parse the replacement value.
        #[arg(long, default_value = "string", value_parser = parse_value_format)]
        value_format: ValueFormat,

        /// Replace the output file if it already exists.
        #[arg(long)]
        overwrite: bool,
    },

    /// Delete an existing value by dot path and write a new file.
    Delete {
        /// Input configuration file.
        input: PathBuf,

        /// Dot path to delete, such as server.debug or items.0.
        path: String,

        /// Output file.
        #[arg(short, long)]
        output: PathBuf,

        /// Input format. Defaults to detection from input extension.
        #[arg(long, value_parser = parse_format)]
        from: Option<Format>,

        /// Output format. Defaults to detection from output extension.
        #[arg(long, value_parser = parse_format)]
        to: Option<Format>,

        /// Replace the output file if it already exists.
        #[arg(long)]
        overwrite: bool,
    },

    /// Recursively merge two configuration files and write a new file.
    Merge {
        /// Base configuration file.
        base: PathBuf,

        /// Override configuration file.
        override_file: PathBuf,

        /// Output file.
        #[arg(short, long)]
        output: PathBuf,

        /// Base input format. Defaults to detection from base extension.
        #[arg(long, value_parser = parse_format)]
        base_format: Option<Format>,

        /// Override input format. Defaults to detection from override extension.
        #[arg(long, value_parser = parse_format)]
        override_format: Option<Format>,

        /// Output format. Defaults to detection from output extension.
        #[arg(long, value_parser = parse_format)]
        to: Option<Format>,

        /// Replace the output file if it already exists.
        #[arg(long)]
        overwrite: bool,
    },

    /// Print a path-oriented diff between two configuration files.
    Diff {
        /// Old configuration file.
        old: PathBuf,

        /// New configuration file.
        new: PathBuf,

        /// Old input format. Defaults to detection from old extension.
        #[arg(long, value_parser = parse_format)]
        old_format: Option<Format>,

        /// New input format. Defaults to detection from new extension.
        #[arg(long, value_parser = parse_format)]
        new_format: Option<Format>,
    },
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Some(Command::Convert {
            input,
            output,
            from,
            to,
            check,
            overwrite,
        }) => convert(input, output, from, to, check, overwrite),
        Some(Command::Inspect { input, from }) => inspect(input, from),
        Some(Command::Validate { input, from }) => validate(input, from),
        Some(Command::Get {
            input,
            path,
            from,
            to,
        }) => get(input, &path, from, to),
        Some(Command::Set {
            input,
            path,
            value,
            output,
            from,
            to,
            value_format,
            overwrite,
        }) => set(
            input,
            &path,
            &value,
            output,
            from,
            to,
            value_format,
            overwrite,
        ),
        Some(Command::Delete {
            input,
            path,
            output,
            from,
            to,
            overwrite,
        }) => delete(input, &path, output, from, to, overwrite),
        Some(Command::Merge {
            base,
            override_file,
            output,
            base_format,
            override_format,
            to,
            overwrite,
        }) => merge(
            base,
            override_file,
            output,
            base_format,
            override_format,
            to,
            overwrite,
        ),
        Some(Command::Diff {
            old,
            new,
            old_format,
            new_format,
        }) => diff(old, new, old_format, new_format),
        None => {
            println!("{} {}", config_forge::NAME, config_forge::VERSION);
            println!("{}", config_forge::describe());
            Ok(())
        }
    }
}

fn convert(
    input: PathBuf,
    output: Option<PathBuf>,
    from: Option<Format>,
    to: Option<Format>,
    check: bool,
    overwrite: bool,
) -> Result<()> {
    if check {
        let output_format = check_convert_path(&input, from, to)?;
        println!("ok: conversion to {} is valid", output_format.name());
        return Ok(());
    }

    if output.is_none() && to.is_none() {
        bail!("--to is required when --output is not provided");
    }

    let rendered = convert_path(&input, output.as_ref(), from, to, overwrite)?;

    if output.is_none() {
        print!("{rendered}");
    }

    Ok(())
}

fn inspect(input: PathBuf, from: Option<Format>) -> Result<()> {
    let info = inspect_path(&input, from)?;

    println!("path: {}", input.display());
    println!("format: {}", info.format.name());
    println!("root: {}", info.root_kind);
    println!("size: {} bytes", info.size_bytes);

    Ok(())
}

fn validate(input: PathBuf, from: Option<Format>) -> Result<()> {
    let format = validate_path(&input, from)?;
    let file_name = input
        .file_name()
        .and_then(|value| value.to_str())
        .unwrap_or_else(|| input.to_str().unwrap_or("<input>"));

    println!("ok: {file_name} is valid {}", format.name());
    Ok(())
}

fn get(input: PathBuf, path: &str, from: Option<Format>, to: Option<Format>) -> Result<()> {
    let value = get_path_value(&input, path, from)?;
    let rendered = render_query_value(&value, to)?;
    print!("{rendered}");
    Ok(())
}

fn set(
    input: PathBuf,
    path: &str,
    value: &str,
    output: PathBuf,
    from: Option<Format>,
    to: Option<Format>,
    value_format: ValueFormat,
    overwrite: bool,
) -> Result<()> {
    set_path_value(
        &input,
        &output,
        path,
        value,
        value_format,
        from,
        to,
        overwrite,
    )?;
    Ok(())
}

fn delete(
    input: PathBuf,
    path: &str,
    output: PathBuf,
    from: Option<Format>,
    to: Option<Format>,
    overwrite: bool,
) -> Result<()> {
    delete_path_value(&input, &output, path, from, to, overwrite)?;
    Ok(())
}

fn merge(
    base: PathBuf,
    override_file: PathBuf,
    output: PathBuf,
    base_format: Option<Format>,
    override_format: Option<Format>,
    to: Option<Format>,
    overwrite: bool,
) -> Result<()> {
    merge_paths(
        &base,
        &override_file,
        &output,
        base_format,
        override_format,
        to,
        overwrite,
    )?;
    Ok(())
}

fn diff(
    old: PathBuf,
    new: PathBuf,
    old_format: Option<Format>,
    new_format: Option<Format>,
) -> Result<()> {
    let entries = diff_paths(&old, &new, old_format, new_format)?;
    print!("{}", render_diff(&entries));
    Ok(())
}

fn parse_format(value: &str) -> Result<Format> {
    value.parse()
}

fn parse_value_format(value: &str) -> Result<ValueFormat> {
    value.parse()
}