use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct DiffChange {
pub change_type: String,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub from: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub to: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DiffResult {
pub base_endpoint: String,
pub target_endpoint: String,
pub changes: Vec<DiffChange>,
pub additions: usize,
pub removals: usize,
pub type_changes: usize,
pub value_changes: usize,
pub base_response: serde_json::Value,
pub target_response: serde_json::Value,
}
pub fn compare_json(
base: &serde_json::Value,
target: &serde_json::Value,
path: &str,
) -> Vec<DiffChange> {
let mut changes = Vec::new();
match (base, target) {
(serde_json::Value::Object(base_map), serde_json::Value::Object(target_map)) => {
for key in base_map.keys() {
if !target_map.contains_key(key) {
let field_path = if path.is_empty() {
key.clone()
} else {
format!("{}.{}", path, key)
};
changes.push(DiffChange {
change_type: "removed".to_string(),
path: field_path,
from: Some(base_map[key].clone()),
to: None,
});
}
}
for key in target_map.keys() {
let field_path = if path.is_empty() {
key.clone()
} else {
format!("{}.{}", path, key)
};
if !base_map.contains_key(key) {
changes.push(DiffChange {
change_type: "added".to_string(),
path: field_path,
from: None,
to: Some(target_map[key].clone()),
});
} else {
changes.extend(compare_json(&base_map[key], &target_map[key], &field_path));
}
}
}
(serde_json::Value::Array(base_arr), serde_json::Value::Array(target_arr)) => {
let max_len = base_arr.len().max(target_arr.len());
for i in 0..max_len {
let idx_path = if path.is_empty() {
format!("[{}]", i)
} else {
format!("{}[{}]", path, i)
};
match (base_arr.get(i), target_arr.get(i)) {
(Some(b), Some(t)) => {
changes.extend(compare_json(b, t, &idx_path));
}
(Some(b), None) => {
changes.push(DiffChange {
change_type: "removed".to_string(),
path: idx_path,
from: Some(b.clone()),
to: None,
});
}
(None, Some(t)) => {
changes.push(DiffChange {
change_type: "added".to_string(),
path: idx_path,
from: None,
to: Some(t.clone()),
});
}
(None, None) => unreachable!(),
}
}
}
_ => {
if json_type_name(base) != json_type_name(target) {
changes.push(DiffChange {
change_type: "type_changed".to_string(),
path: path.to_string(),
from: Some(serde_json::Value::String(json_type_name(base).to_string())),
to: Some(serde_json::Value::String(json_type_name(target).to_string())),
});
} else if base != target {
changes.push(DiffChange {
change_type: "value_changed".to_string(),
path: path.to_string(),
from: Some(base.clone()),
to: Some(target.clone()),
});
}
}
}
changes
}
fn json_type_name(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
pub fn diff_result_to_json(result: &DiffResult) -> serde_json::Value {
serde_json::to_value(result).unwrap_or(serde_json::Value::Null)
}
pub fn format_diff_tty(result: &DiffResult) -> String {
let mut out = String::new();
out.push_str(&format!(
"\n\x1b[1m--- {}\n+++ {}\x1b[0m\n\n",
result.base_endpoint, result.target_endpoint
));
if result.changes.is_empty() {
out.push_str(" \x1b[32mNo structural differences\x1b[0m\n");
return out;
}
for change in &result.changes {
match change.change_type.as_str() {
"added" => {
out.push_str(&format!(
" \x1b[32m+ {}: {}\x1b[0m\n",
change.path,
change.to.as_ref().map_or("".to_string(), |v| format_value(v))
));
}
"removed" => {
out.push_str(&format!(
" \x1b[31m- {}: {}\x1b[0m\n",
change.path,
change.from.as_ref().map_or("".to_string(), |v| format_value(v))
));
}
"type_changed" => {
out.push_str(&format!(
" \x1b[33m~ {} : {} → {}\x1b[0m\n",
change.path,
change.from.as_ref().map_or("?".to_string(), |v| v.as_str().unwrap_or("?").to_string()),
change.to.as_ref().map_or("?".to_string(), |v| v.as_str().unwrap_or("?").to_string()),
));
}
"value_changed" => {
out.push_str(&format!(
" \x1b[33m~ {}: {} → {}\x1b[0m\n",
change.path,
change.from.as_ref().map_or("".to_string(), |v| format_value(v)),
change.to.as_ref().map_or("".to_string(), |v| format_value(v)),
));
}
_ => {}
}
}
out.push_str(&format!(
"\n Summary: {} addition(s), {} removal(s), {} type change(s), {} value change(s)\n",
result.additions, result.removals, result.type_changes, result.value_changes
));
out
}
fn format_value(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => format!("\"{}\"", s),
other => other.to_string(),
}
}
impl DiffResult {
pub fn from_comparison(
base_endpoint: String,
target_endpoint: String,
base_response: serde_json::Value,
target_response: serde_json::Value,
) -> Self {
let changes = compare_json(&base_response, &target_response, "");
let additions = changes.iter().filter(|c| c.change_type == "added").count();
let removals = changes.iter().filter(|c| c.change_type == "removed").count();
let type_changes = changes.iter().filter(|c| c.change_type == "type_changed").count();
let value_changes = changes.iter().filter(|c| c.change_type == "value_changed").count();
Self {
base_endpoint,
target_endpoint,
changes,
additions,
removals,
type_changes,
value_changes,
base_response,
target_response,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compare_identical() {
let a = serde_json::json!({"name": "Alice", "age": 30});
let b = serde_json::json!({"name": "Alice", "age": 30});
let changes = compare_json(&a, &b, "");
assert!(changes.is_empty());
}
#[test]
fn test_compare_added_field() {
let base = serde_json::json!({"name": "Alice"});
let target = serde_json::json!({"name": "Alice", "email": "alice@example.com"});
let changes = compare_json(&base, &target, "");
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].change_type, "added");
assert_eq!(changes[0].path, "email");
}
#[test]
fn test_compare_removed_field() {
let base = serde_json::json!({"name": "Alice", "legacy_id": 42});
let target = serde_json::json!({"name": "Alice"});
let changes = compare_json(&base, &target, "");
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].change_type, "removed");
assert_eq!(changes[0].path, "legacy_id");
}
#[test]
fn test_compare_type_changed() {
let base = serde_json::json!({"count": "42"});
let target = serde_json::json!({"count": 42});
let changes = compare_json(&base, &target, "");
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].change_type, "type_changed");
assert_eq!(changes[0].path, "count");
}
#[test]
fn test_compare_value_changed() {
let base = serde_json::json!({"version": "1.0"});
let target = serde_json::json!({"version": "2.0"});
let changes = compare_json(&base, &target, "");
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].change_type, "value_changed");
assert_eq!(changes[0].path, "version");
}
#[test]
fn test_compare_nested() {
let base = serde_json::json!({"user": {"name": "Alice", "role": "user"}});
let target = serde_json::json!({"user": {"name": "Alice", "role": "admin", "level": 5}});
let changes = compare_json(&base, &target, "");
assert_eq!(changes.len(), 2);
let value_change = changes.iter().find(|c| c.path == "user.role").unwrap();
assert_eq!(value_change.change_type, "value_changed");
let added = changes.iter().find(|c| c.path == "user.level").unwrap();
assert_eq!(added.change_type, "added");
}
#[test]
fn test_compare_array_length_diff() {
let base = serde_json::json!({"items": [1, 2]});
let target = serde_json::json!({"items": [1, 2, 3]});
let changes = compare_json(&base, &target, "");
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].change_type, "added");
assert_eq!(changes[0].path, "items[2]");
}
#[test]
fn test_diff_result_from_comparison() {
let base = serde_json::json!({"status": "ok", "legacy": true});
let target = serde_json::json!({"status": "ok", "version": "2.0"});
let result = DiffResult::from_comparison(
"/v1/api".to_string(),
"/v2/api".to_string(),
base,
target,
);
assert_eq!(result.additions, 1);
assert_eq!(result.removals, 1);
assert_eq!(result.type_changes, 0);
assert_eq!(result.value_changes, 0);
}
#[test]
fn test_format_tty_no_changes() {
let result = DiffResult::from_comparison(
"/v1".to_string(),
"/v2".to_string(),
serde_json::json!({"a": 1}),
serde_json::json!({"a": 1}),
);
let output = format_diff_tty(&result);
assert!(output.contains("No structural differences"));
}
}