use crate::error::ConfigError;
use serde_json::Value;
use std::fs;
use std::path::Path;
const GREEN: &str = "\x1b[32m";
const RED: &str = "\x1b[31m";
const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
pub struct DiffCommand;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DiffFormat {
Unified,
Context,
Normal,
SideBySide,
Strict,
}
impl std::str::FromStr for DiffFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"unified" => Ok(DiffFormat::Unified),
"context" => Ok(DiffFormat::Context),
"normal" => Ok(DiffFormat::Normal),
"side-by-side" | "sidebyside" | "side" => Ok(DiffFormat::SideBySide),
"strict" => Ok(DiffFormat::Strict),
_ => Err(format!("Unknown diff format: {}. Supported formats: unified, context, normal, side-by-side, strict", s)),
}
}
}
#[derive(Debug)]
pub struct DiffOptions {
pub format: DiffFormat,
pub context_lines: usize,
pub show_line_numbers: bool,
pub ignore_whitespace: bool,
pub case_insensitive: bool,
pub strict: bool,
pub output: Option<String>,
}
impl Clone for DiffOptions {
fn clone(&self) -> Self {
DiffOptions {
format: self.format,
context_lines: self.context_lines,
show_line_numbers: self.show_line_numbers,
ignore_whitespace: self.ignore_whitespace,
case_insensitive: self.case_insensitive,
strict: self.strict,
output: self.output.clone(),
}
}
}
impl Default for DiffOptions {
fn default() -> Self {
DiffOptions {
format: DiffFormat::Unified,
context_lines: 3,
show_line_numbers: false,
ignore_whitespace: false,
case_insensitive: false,
strict: false,
output: None,
}
}
}
impl DiffCommand {
pub fn execute(file1: &str, file2: &str, options: DiffOptions) -> Result<(), ConfigError> {
let v1 = Self::load_config(file1)?;
let v2 = Self::load_config(file2)?;
if v1 == v2 {
if let Some(output_path) = options.output.as_deref() {
fs::write(output_path, "Configurations are identical.\n")
.map_err(|e| ConfigError::IoError(e.to_string()))?;
} else {
println!("Configurations are identical.");
}
return Ok(());
}
if let Some(output_path) = options.output.as_deref() {
let mut render_options = options.clone();
render_options.strict = true;
let lines = Self::render_diff(file1, file2, &v1, &v2, &render_options);
fs::write(output_path, lines.join("\n"))
.map_err(|e| ConfigError::IoError(e.to_string()))?;
} else {
match options.format {
DiffFormat::Unified => Self::print_unified_diff(file1, file2, &v1, &v2, options),
DiffFormat::Context => Self::print_context_diff(file1, file2, &v1, &v2, options),
DiffFormat::Normal => Self::print_normal_diff(file1, file2, &v1, &v2, options),
DiffFormat::SideBySide => {
Self::print_side_by_side_diff(file1, file2, &v1, &v2, options)
}
DiffFormat::Strict => Self::print_strict_diff(file1, file2, &v1, &v2, options),
}
}
Ok(())
}
fn render_diff(
file1: &str,
file2: &str,
v1: &Value,
v2: &Value,
options: &DiffOptions,
) -> Vec<String> {
match options.format {
DiffFormat::Unified => {
let header = format!("--- {}", file1);
let header2 = format!("+++ {}", file2);
let mut lines = Vec::new();
if options.strict {
lines.push(header);
lines.push(header2);
} else {
lines.push(format!("{}{}{}", BOLD, header, RESET));
lines.push(format!("{}{}{}", BOLD, header2, RESET));
}
lines.extend(Self::generate_unified_diff(v1, v2, "", options));
lines
}
DiffFormat::Context => {
let mut lines = Vec::new();
lines.push(format!("*** {}", file1));
lines.push(format!("--- {}", file2));
lines.extend(Self::generate_context_diff(v1, v2, "", options));
lines
}
DiffFormat::Normal => {
let mut lines = Vec::new();
if options.strict {
lines.push(file1.to_string());
lines.push(file2.to_string());
} else {
lines.push(format!("{} {} {}", BOLD, file1, RESET));
lines.push(format!("{} {} {}", BOLD, file2, RESET));
}
lines.extend(Self::generate_normal_diff(v1, v2, "", options));
lines
}
DiffFormat::SideBySide => {
let sep = if options.show_line_numbers {
" | "
} else {
" "
};
let left_header = if options.strict {
format!("Left: {}", file1)
} else {
format!("{}Left: {}{}", BOLD, file1, RESET)
};
let right_header = if options.strict {
format!("Right: {}", file2)
} else {
format!("{}Right: {}{}", BOLD, file2, RESET)
};
let mut lines = Vec::new();
if options.strict {
lines.push(format!("{}{}{}", left_header, sep, right_header));
} else {
lines.push(format!(
"{}{}{}{}{}",
left_header, sep, right_header, sep, RESET
));
}
lines.push("-".repeat(80));
lines.extend(Self::generate_side_by_side_diff(v1, v2, "", options));
lines
}
DiffFormat::Strict => {
let strict_opts = DiffOptions {
strict: true,
format: DiffFormat::Strict,
..options.clone()
};
let mut lines = Vec::new();
lines.push(format!("--- {}", file1));
lines.push(format!("+++ {}", file2));
lines.extend(Self::generate_standard_unified_diff(
v1,
v2,
"",
&strict_opts,
));
lines
}
}
}
fn colorize(s: &str, color: &str, options: &DiffOptions) -> String {
if options.strict {
s.to_string()
} else {
format!("{}{}{}", color, s, RESET)
}
}
fn print_normal_diff(file1: &str, file2: &str, v1: &Value, v2: &Value, options: DiffOptions) {
println!("{} {} {}", BOLD, file1, RESET);
println!("{} {} {}", BOLD, file2, RESET);
let diffs = Self::generate_normal_diff(v1, v2, "", &options);
for line in diffs {
println!("{}", line);
}
}
fn print_strict_diff(file1: &str, file2: &str, v1: &Value, v2: &Value, options: DiffOptions) {
let strict_opts = DiffOptions {
strict: true,
format: DiffFormat::Strict,
..options
};
println!("--- {}", file1);
println!("+++ {}", file2);
let diffs = Self::generate_standard_unified_diff(v1, v2, "", &strict_opts);
for line in diffs {
println!("{}", line);
}
}
fn print_unified_diff(file1: &str, file2: &str, v1: &Value, v2: &Value, options: DiffOptions) {
let header = format!("--- {}", file1);
let header2 = format!("+++ {}", file2);
println!("{}{}{}", BOLD, header, RESET);
println!("{}{}{}", BOLD, header2, RESET);
let diffs = Self::generate_unified_diff(v1, v2, "", &options);
for line in diffs {
println!("{}", line);
}
}
fn print_context_diff(file1: &str, file2: &str, v1: &Value, v2: &Value, options: DiffOptions) {
println!("*** {}", file1);
println!("--- {}", file2);
let diffs = Self::generate_context_diff(v1, v2, "", &options);
for line in diffs {
println!("{}", line);
}
}
fn print_side_by_side_diff(
file1: &str,
file2: &str,
v1: &Value,
v2: &Value,
options: DiffOptions,
) {
let sep = if options.show_line_numbers {
" | "
} else {
" "
};
let left_header = format!("{}Left: {}{}", BOLD, file1, RESET);
let right_header = format!("{}Right: {}{}", BOLD, file2, RESET);
println!("{}{}{}{}{}", left_header, sep, right_header, sep, RESET);
println!("{}", "-".repeat(80));
let diffs = Self::generate_side_by_side_diff(v1, v2, "", &options);
for line in diffs {
println!("{}", line);
}
}
fn generate_unified_diff(
v1: &Value,
v2: &Value,
path: &str,
options: &DiffOptions,
) -> Vec<String> {
let mut diffs = Vec::new();
let _prefix = if path.is_empty() { "" } else { path };
match (v1, v2) {
(Value::Object(m1), Value::Object(m2)) => {
let all_keys: std::collections::HashSet<_> = m1.keys().chain(m2.keys()).collect();
let mut sorted_keys: Vec<_> = all_keys.iter().collect();
sorted_keys.sort();
for k in sorted_keys {
let new_path = if path.is_empty() {
k.as_str().to_string()
} else {
format!("{}.{}", path, k)
};
match (m1.get(k.as_str()), m2.get(k.as_str())) {
(None, Some(v2_val)) => {
diffs.push(Self::colorize(&format!("+{}", k), GREEN, options));
let indented = Self::indent_value(v2_val, 1);
for line in indented {
diffs.push(Self::colorize(&format!("+{}", line), GREEN, options));
}
}
(Some(v1_val), None) => {
diffs.push(Self::colorize(&format!("-{}", k), RED, options));
let indented = Self::indent_value(v1_val, 1);
for line in indented {
diffs.push(Self::colorize(&format!("-{}", line), RED, options));
}
}
(Some(v1_val), Some(v2_val)) if v1_val != v2_val => {
let sub_diffs =
Self::generate_unified_diff(v1_val, v2_val, &new_path, options);
if sub_diffs.is_empty() {
diffs.push(Self::colorize(&format!("-{}", new_path), RED, options));
diffs.push(Self::colorize(
&format!("+{}", new_path),
GREEN,
options,
));
} else {
diffs.extend(sub_diffs);
}
}
(Some(_v1_val), Some(_v2_val)) => {
diffs.push(format!(" {}", new_path));
}
_ => {}
}
}
}
(Value::Array(a1), Value::Array(a2)) => {
if a1 != a2 {
let max_len = std::cmp::max(a1.len(), a2.len());
for i in 0..max_len {
let item_path = format!("{}[{}]", path, i);
match (a1.get(i), a2.get(i)) {
(None, Some(v2_val)) => {
diffs.push(Self::colorize(
&format!("+{}", item_path),
GREEN,
options,
));
let indented = Self::indent_value(v2_val, 1);
for line in indented {
diffs.push(Self::colorize(
&format!("+{}", line),
GREEN,
options,
));
}
}
(Some(v1_val), None) => {
diffs.push(Self::colorize(
&format!("-{}", item_path),
RED,
options,
));
let indented = Self::indent_value(v1_val, 1);
for line in indented {
diffs.push(Self::colorize(&format!("-{}", line), RED, options));
}
}
(Some(v1_val), Some(v2_val)) if v1_val != v2_val => {
let sub_diffs = Self::generate_unified_diff(
v1_val, v2_val, &item_path, options,
);
if sub_diffs.is_empty() {
diffs.push(Self::colorize(
&format!("-{}", item_path),
RED,
options,
));
diffs.push(Self::colorize(
&format!("+{}", item_path),
GREEN,
options,
));
} else {
diffs.extend(sub_diffs);
}
}
(Some(_v1_val), Some(_v2_val)) => {
diffs.push(format!(" {}", item_path));
}
_ => {}
}
}
}
}
_ if v1 != v2 => {
diffs.push(Self::colorize(&format!("-{}", v1), RED, options));
diffs.push(Self::colorize(&format!("+{}", v2), GREEN, options));
}
_ => {}
}
diffs
}
fn generate_context_diff(
v1: &Value,
v2: &Value,
path: &str,
_options: &DiffOptions,
) -> Vec<String> {
let mut diffs = Vec::new();
let _prefix = if path.is_empty() { "" } else { path };
match (v1, v2) {
(Value::Object(m1), Value::Object(m2)) => {
for k in m1
.keys()
.chain(m2.keys())
.collect::<std::collections::HashSet<_>>()
{
let new_path = if path.is_empty() {
k.clone()
} else {
format!("{}.{}", path, k)
};
match (m1.get(k), m2.get(k)) {
(None, Some(v2_val)) => {
diffs.push(format!("{}{}*{}*{}", GREEN, BOLD, k, RESET));
let indented = Self::indent_value(v2_val, 1);
diffs.extend(
indented
.iter()
.map(|s| format!("+{}", s))
.collect::<Vec<_>>(),
);
}
(Some(v1_val), None) => {
diffs.push(format!("{}{}*{}*{}", RED, BOLD, k, RESET));
let indented = Self::indent_value(v1_val, 1);
diffs.extend(
indented
.iter()
.map(|s| format!("-{}", s))
.collect::<Vec<_>>(),
);
}
(Some(v1_val), Some(v2_val)) if v1_val != v2_val => {
let sub_diffs =
Self::generate_context_diff(v1_val, v2_val, &new_path, _options);
if sub_diffs.is_empty() {
diffs.push(format!("{}{}{}- ", RED, new_path, RESET));
diffs.push(format!("{}{}{}+ ", GREEN, new_path, RESET));
} else {
diffs.extend(sub_diffs);
}
}
_ => {}
}
}
}
_ if v1 != v2 => {
diffs.push(format!("{}{}*{}*{}", RED, BOLD, path, RESET));
}
_ => {}
}
diffs
}
fn generate_side_by_side_diff(
v1: &Value,
v2: &Value,
path: &str,
options: &DiffOptions,
) -> Vec<String> {
let width = 35;
let separator = if options.show_line_numbers {
" | "
} else {
" "
};
let mut diffs = Vec::new();
match (v1, v2) {
(Value::Object(m1), Value::Object(m2)) => {
for k in m1
.keys()
.chain(m2.keys())
.collect::<std::collections::HashSet<_>>()
{
let new_path = if path.is_empty() {
k.clone()
} else {
format!("{}.{}", path, k)
};
match (m1.get(k), m2.get(k)) {
(None, Some(v2_val)) => {
let left = format!("{}{}{}", RED, " ".repeat(width), RESET);
let right = Self::truncate(&format!("{}", v2_val), width);
let right = format!("{}{}{}", GREEN, right, RESET);
diffs.push(format!("{} {} {}", left, separator, right));
}
(Some(v1_val), None) => {
let left = Self::truncate(&format!("{}", v1_val), width);
let left = format!("{}{}{}", RED, left, RESET);
let right = format!("{}{}{}", GREEN, " ".repeat(width), RESET);
diffs.push(format!("{} {} {}", left, separator, right));
}
(Some(v1_val), Some(v2_val)) if v1_val != v2_val => {
let sub_diffs = Self::generate_side_by_side_diff(
v1_val, v2_val, &new_path, options,
);
diffs.extend(sub_diffs);
}
(Some(v1_val), Some(v2_val)) => {
let left = Self::truncate(&format!("{}", v1_val), width);
let right = Self::truncate(&format!("{}", v2_val), width);
diffs.push(format!("{} {} {}", left, separator, right));
}
_ => {}
}
}
}
_ if v1 != v2 => {
let left = Self::truncate(&format!("{}", v1), width);
let left = format!("{}{}{}", RED, left, RESET);
let right = Self::truncate(&format!("{}", v2), width);
let right = format!("{}{}{}", GREEN, right, RESET);
diffs.push(format!("{} {} {}", left, separator, right));
}
_ => {
let left = Self::truncate(&format!("{}", v1), width);
let right = Self::truncate(&format!("{}", v2), width);
diffs.push(format!("{} {} {}", left, separator, right));
}
}
diffs
}
fn indent_value(v: &Value, indent: usize) -> Vec<String> {
let s = format!("{}", v);
let indent_str = " ".repeat(indent);
s.lines()
.map(|line| format!("{}{}", indent_str, line))
.collect()
}
fn truncate(s: &str, width: usize) -> String {
if s.len() <= width {
s.to_string()
} else {
s.chars().take(width.saturating_sub(3)).collect::<String>() + "..."
}
}
fn load_config(file: &str) -> Result<Value, ConfigError> {
let path = Path::new(file);
if !path.exists() {
return Err(ConfigError::FileNotFound {
path: path.to_path_buf(),
});
}
let content = fs::read_to_string(path)
.map_err(|e| ConfigError::IoError(format!("Failed to read config file: {}", e)))?;
let ext = path.extension().and_then(|e| e.to_str());
match ext {
Some("json") | Some("jsonc") => {
serde_json::from_str(&content).map_err(|e| ConfigError::ParseError(e.to_string()))
}
Some("yaml") | Some("yml") => {
serde_yaml::from_str(&content).map_err(|e| ConfigError::ParseError(e.to_string()))
}
Some("toml") => {
toml::from_str(&content).map_err(|e| ConfigError::ParseError(e.to_string()))
}
Some("ini") => {
serde_ini::from_str(&content).map_err(|e| ConfigError::ParseError(e.to_string()))
}
_ => Err(ConfigError::ParseError(format!(
"Unsupported config format: {:?}",
ext
))),
}
}
fn generate_normal_diff(
v1: &Value,
v2: &Value,
path: &str,
_options: &DiffOptions,
) -> Vec<String> {
let mut diffs = Vec::new();
match (v1, v2) {
(Value::Object(m1), Value::Object(m2)) => {
for k in m1
.keys()
.chain(m2.keys())
.collect::<std::collections::HashSet<_>>()
{
let new_path = if path.is_empty() {
k.clone()
} else {
format!("{}.{}", path, k)
};
match (m1.get(k), m2.get(k)) {
(None, Some(v2_val)) => {
diffs.push(format!("{}: {}", new_path, v2_val));
}
(Some(v1_val), None) => {
diffs.push(format!("{}: {}", new_path, v1_val));
}
(Some(v1_val), Some(v2_val)) if v1_val != v2_val => {
diffs.extend(Self::generate_normal_diff(
v1_val, v2_val, &new_path, _options,
));
}
_ => {}
}
}
}
(Value::Array(a1), Value::Array(a2)) => {
if a1 != a2 {
let max_len = std::cmp::max(a1.len(), a2.len());
for i in 0..max_len {
let item_path = format!("{}[{}]", path, i);
match (a1.get(i), a2.get(i)) {
(None, Some(v2_val)) => {
diffs.push(format!("{}: {}", item_path, v2_val));
}
(Some(v1_val), None) => {
diffs.push(format!("{}: {}", item_path, v1_val));
}
(Some(v1_val), Some(v2_val)) if v1_val != v2_val => {
diffs.extend(Self::generate_normal_diff(
v1_val, v2_val, &item_path, _options,
));
}
_ => {}
}
}
}
}
_ if v1 != v2 => {
diffs.push(format!("{}: {} => {}", path, v1, v2));
}
_ => {}
}
diffs
}
fn generate_standard_unified_diff(
v1: &Value,
v2: &Value,
path: &str,
_options: &DiffOptions,
) -> Vec<String> {
let mut diffs = Vec::new();
match (v1, v2) {
(Value::Object(m1), Value::Object(m2)) => {
let all_keys: std::collections::HashSet<_> = m1.keys().chain(m2.keys()).collect();
let mut sorted_keys: Vec<_> = all_keys.iter().collect();
sorted_keys.sort();
for k in sorted_keys {
let new_path = if path.is_empty() {
k.as_str().to_string()
} else {
format!("{}.{}", path, k)
};
match (m1.get(k.as_str()), m2.get(k.as_str())) {
(None, Some(v2_val)) => {
diffs.push(format!("+[{}]", new_path));
let indented = Self::indent_value(v2_val, 1);
for line in indented {
diffs.push(format!("+{}", line));
}
}
(Some(v1_val), None) => {
diffs.push(format!("-[{}]", new_path));
let indented = Self::indent_value(v1_val, 1);
for line in indented {
diffs.push(format!("-{}", line));
}
}
(Some(_v1_val), Some(_v2_val)) if _v1_val != _v2_val => {
diffs.extend(Self::generate_standard_unified_diff(
_v1_val, _v2_val, &new_path, _options,
));
}
(Some(_v1_val), Some(_v2_val)) => {
diffs.push(format!(" [{}]", new_path));
}
_ => {}
}
}
}
(Value::Array(a1), Value::Array(a2)) => {
if a1 != a2 {
let max_len = std::cmp::max(a1.len(), a2.len());
for i in 0..max_len {
let item_path = format!("{}[{}]", path, i);
match (a1.get(i), a2.get(i)) {
(None, Some(v2_val)) => {
diffs.push(format!("+{}", item_path));
let indented = Self::indent_value(v2_val, 1);
for line in indented {
diffs.push(format!("+{}", line));
}
}
(Some(v1_val), None) => {
diffs.push(format!("-{}", item_path));
let indented = Self::indent_value(v1_val, 1);
for line in indented {
diffs.push(format!("-{}", line));
}
}
(Some(v1_val), Some(v2_val)) if v1_val != v2_val => {
diffs.extend(Self::generate_standard_unified_diff(
v1_val, v2_val, &item_path, _options,
));
}
_ => {}
}
}
}
}
_ if v1 != v2 => {
diffs.push(format!("-{}", v1));
diffs.push(format!("+{}", v2));
}
_ => {}
}
diffs
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[allow(dead_code)]
fn create_test_config() -> (Value, Value) {
let config1 = json!({
"name": "test_app",
"version": "1.0.0",
"server": {
"host": "localhost",
"port": 8080
},
"database": {
"url": "postgres://localhost/db",
"pool_size": 5
}
});
let config2 = json!({
"name": "test_app",
"version": "1.1.0",
"server": {
"host": "0.0.0.0",
"port": 8080
},
"database": {
"url": "postgres://localhost/db",
"pool_size": 10
},
"new_feature": {
"enabled": true
}
});
(config1, config2)
}
#[test]
fn test_execute_with_identical_configs() {
let config_content = r#"{"name": "test", "value": "same"}"#;
let temp_dir = std::env::temp_dir();
let file1_path = temp_dir.join("test_config_1.json");
let file2_path = temp_dir.join("test_config_2.json");
std::fs::write(&file1_path, config_content).unwrap();
std::fs::write(&file2_path, config_content).unwrap();
let result = DiffCommand::execute(
file1_path.to_str().unwrap(),
file2_path.to_str().unwrap(),
DiffOptions::default(),
);
let _ = std::fs::remove_file(&file1_path);
let _ = std::fs::remove_file(&file2_path);
assert!(result.is_ok());
}
#[test]
fn test_diff_format_parsing() {
assert_eq!(
"unified".parse::<DiffFormat>().unwrap(),
DiffFormat::Unified
);
assert_eq!(
"side-by-side".parse::<DiffFormat>().unwrap(),
DiffFormat::SideBySide
);
assert_eq!("strict".parse::<DiffFormat>().unwrap(), DiffFormat::Strict);
assert!("invalid".parse::<DiffFormat>().is_err());
}
#[test]
fn test_diff_options_default() {
let options = DiffOptions::default();
assert_eq!(options.format, DiffFormat::Unified);
assert_eq!(options.context_lines, 3);
assert!(!options.show_line_numbers);
assert!(!options.ignore_whitespace);
assert!(!options.case_insensitive);
assert!(!options.strict);
assert!(options.output.is_none());
}
#[test]
fn test_colorize_strict_mode() {
let options = DiffOptions {
strict: true,
..Default::default()
};
let result = DiffCommand::colorize("test", GREEN, &options);
assert_eq!(result, "test");
}
#[test]
fn test_colorize_non_strict_mode() {
let options = DiffOptions {
strict: false,
..Default::default()
};
let result = DiffCommand::colorize("test", GREEN, &options);
assert!(result.contains("test"));
assert!(result.contains(GREEN));
assert!(result.contains(RESET));
}
}