jsonkeycompare 0.1.1

A very simple program that compares two or more JSON files and reports any keys that are present in the first file but not in any of the other files.
use std::{env, fs, process};
use std::collections::HashSet;
use std::path::PathBuf;

use colored::Colorize;
use serde_json::Value;

mod tests;

pub fn extract_nested_keys(value: &Value, parent_key: &str, keys: &mut HashSet<String>) {
    match value {
        Value::Object(map) => {
            for (key, val) in map {
                let full_key = if parent_key.is_empty() {
                    key.clone()
                } else {
                    format!("{}.{}", parent_key, key)
                };

                keys.insert(full_key.clone());
                extract_nested_keys(val, &full_key, keys);
            }
        }
        Value::Array(arr) => {
            for (index, item) in arr.iter().enumerate() {
                let full_key = format!("{}[{}]", parent_key, index);
                extract_nested_keys(item, &full_key, keys);
                keys.insert(full_key.clone());
            }
        }
        _ => {}
    }
}

pub fn compare_json_keys(main_json: &Value, other_json: &Value) -> HashSet<String> {
    let mut main_keys = HashSet::new();
    let mut other_keys = HashSet::new();

    extract_nested_keys(main_json, "", &mut main_keys);
    extract_nested_keys(other_json, "", &mut other_keys);

    main_keys
        .difference(&other_keys)
        .cloned()
        .collect()
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args: Vec<String> = env::args().collect();

    let exit_on_fail = &args.iter().any(|arg| arg == "--fail");
    let sort_lines = &args.iter().any(|arg| arg == "--sort");
    let help = &args.iter().any(|arg| arg == "--help");

    if args.len() < 3 || *help {
        eprintln!("Usage: {} <main_file> <other_file_1> <other_file_2> ... [--fail]", args[0]);
        eprintln!("  --fail: Exit with non-zero status if any files are missing keys.");
        eprintln!("  --sort: Sort the output keys alphabetically.");
        process::exit(if *help { 0 } else { 1 });
    }

    let main_file = PathBuf::from(&args[1]);
    let other_file_paths = args[2..]
        .iter()
        .filter(|x| !x.is_empty() && !x.starts_with("--") && x.to_string() != args[1])
        .map(PathBuf::from)
        .collect::<Vec<_>>()
        .into_iter();

    if other_file_paths.len() == 0 {
        eprintln!("{}", "Error: No other files provided.".red());
        process::exit(1);
    }

    let main_content = fs::read_to_string(&main_file)?;
    let main_json: Value = serde_json::from_str(&main_content)?;

    let mut all_files_valid = true;

    for other_file in other_file_paths.clone() {
        println!("\n{}", format!("Comparing '{}' to '{}'", other_file.display().to_string().blue(), main_file.display().to_string().blue()).green());

        let other_content = fs::read_to_string(other_file.clone())?;
        let other_json: Value = serde_json::from_str(&other_content)?;

        let mut differences: Vec<String> = compare_json_keys(&main_json, &other_json).iter().map(|key| key.to_string()).collect();

        if *sort_lines {
            differences.sort();
        }

        for difference in &differences {
            eprintln!(" - Key '{}' is present in the main file but not in the compared file '{}'", difference.red(), other_file.display().to_string().yellow());
        }

        if !differences.is_empty() {
            all_files_valid = false;
        } else {
            println!("{}", format!("All keys are present in '{}'.", other_file.display().to_string().blue()).green());
        }
    }

    if all_files_valid {
        println!("\n{}", format!("All {} files are valid.", other_file_paths.len()).green());
        process::exit(0);
    }

    if *exit_on_fail {
        process::exit(1);
    }

    Ok(())
}