#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JsonDiffLine {
Context(String),
Added(String),
Removed(String),
Ellipsis,
}
pub fn compute_json_diff(
before: &str,
after: &str,
context_lines: usize,
) -> Option<Vec<JsonDiffLine>> {
let before_val: serde_json::Value = serde_json::from_str(before).ok()?;
let after_val: serde_json::Value = serde_json::from_str(after).ok()?;
let before_pretty = serde_json::to_string_pretty(&before_val).ok()?;
let after_pretty = serde_json::to_string_pretty(&after_val).ok()?;
if before_pretty == after_pretty {
return None;
}
let before_lines: Vec<&str> = before_pretty.lines().collect();
let after_lines: Vec<&str> = after_pretty.lines().collect();
const MAX_LCS_CELLS: usize = (500 + 1) * (500 + 1);
let lcs_cells = before_lines
.len()
.saturating_add(1)
.saturating_mul(after_lines.len().saturating_add(1));
if lcs_cells > MAX_LCS_CELLS {
return None;
}
let tagged = lcs_diff(&before_lines, &after_lines);
Some(collapse_context(&tagged, context_lines))
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum DiffTag {
Equal(String),
Insert(String),
Delete(String),
}
fn lcs_diff(old: &[&str], new: &[&str]) -> Vec<DiffTag> {
let n = old.len();
let m = new.len();
let mut table = vec![vec![0u32; m + 1]; n + 1];
for i in 1..=n {
for j in 1..=m {
table[i][j] = if old[i - 1] == new[j - 1] {
table[i - 1][j - 1] + 1
} else {
table[i - 1][j].max(table[i][j - 1])
};
}
}
let mut result = Vec::new();
let (mut i, mut j) = (n, m);
while i > 0 || j > 0 {
if i > 0 && j > 0 && old[i - 1] == new[j - 1] {
result.push(DiffTag::Equal(old[i - 1].to_string()));
i -= 1;
j -= 1;
} else if j > 0 && (i == 0 || table[i][j - 1] >= table[i - 1][j]) {
result.push(DiffTag::Insert(new[j - 1].to_string()));
j -= 1;
} else {
result.push(DiffTag::Delete(old[i - 1].to_string()));
i -= 1;
}
}
result.reverse();
result
}
fn collapse_context(tags: &[DiffTag], context_lines: usize) -> Vec<JsonDiffLine> {
let len = tags.len();
let mut keep = vec![false; len];
let mut last_change: Option<usize> = None;
for (i, tag) in tags.iter().enumerate() {
match tag {
DiffTag::Insert(_) | DiffTag::Delete(_) => {
keep[i] = true;
for k in (0..i).rev().take(context_lines) {
if matches!(tags[k], DiffTag::Equal(_)) {
keep[k] = true;
} else {
break;
}
}
last_change = Some(i);
}
DiffTag::Equal(_) => {
if let Some(lc) = last_change
&& i - lc <= context_lines
{
keep[i] = true;
}
}
}
}
let mut output = Vec::new();
let mut in_ellipsis = false;
for (i, tag) in tags.iter().enumerate() {
if keep[i] {
in_ellipsis = false;
match tag {
DiffTag::Equal(s) => output.push(JsonDiffLine::Context(s.clone())),
DiffTag::Insert(s) => output.push(JsonDiffLine::Added(s.clone())),
DiffTag::Delete(s) => output.push(JsonDiffLine::Removed(s.clone())),
}
} else if !in_ellipsis {
output.push(JsonDiffLine::Ellipsis);
in_ellipsis = true;
}
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_json_returns_none() {
let result = compute_json_diff("not json", "also not json", 1);
assert_eq!(result, None);
}
#[test]
fn identical_json_returns_none() {
let before = r#"{"b": 2, "a": 1}"#;
let after = r#"{"a": 1, "b": 2}"#;
let result = compute_json_diff(before, after, 1);
assert_eq!(result, None);
}
#[test]
fn single_value_change_returns_diff_with_context() {
let before = r#"{"name": "Alice", "age": 30}"#;
let after = r#"{"name": "Alice", "age": 31}"#;
let result = compute_json_diff(before, after, 1).unwrap();
assert!(
result
.iter()
.any(|l| matches!(l, JsonDiffLine::Removed(s) if s.contains("30")))
);
assert!(
result
.iter()
.any(|l| matches!(l, JsonDiffLine::Added(s) if s.contains("31")))
);
assert!(
result
.iter()
.any(|l| matches!(l, JsonDiffLine::Context(s) if s.contains("\"name\"")))
);
}
#[test]
fn nested_change_collapses_unchanged() {
let before = r#"{
"a": 1, "b": 2, "c": 3, "d": 4,
"nested": {"x": 10, "y": 20, "z": 30}
}"#;
let after = r#"{
"a": 1, "b": 2, "c": 3, "d": 4,
"nested": {"x": 10, "y": 99, "z": 30}
}"#;
let result = compute_json_diff(before, after, 1).unwrap();
assert!(
result.contains(&JsonDiffLine::Ellipsis),
"should collapse unchanged top-level keys"
);
assert!(
result.contains(&JsonDiffLine::Removed(" \"y\": 20,".to_string()))
|| result.contains(&JsonDiffLine::Removed(" \"y\": 20".to_string()))
);
}
#[test]
fn array_element_added_returns_added_lines() {
let before = r#"{"items": ["a", "b"]}"#;
let after = r#"{"items": ["a", "b", "c"]}"#;
let result = compute_json_diff(before, after, 1).unwrap();
assert!(
result
.iter()
.any(|l| matches!(l, JsonDiffLine::Added(s) if s.contains("\"c\"")))
);
}
#[test]
fn array_element_removed_returns_removed_lines() {
let before = r#"{"items": ["a", "b", "c"]}"#;
let after = r#"{"items": ["a", "b"]}"#;
let result = compute_json_diff(before, after, 1).unwrap();
assert!(
result
.iter()
.any(|l| matches!(l, JsonDiffLine::Removed(s) if s.contains("\"c\"")))
);
}
#[test]
fn context_zero_returns_only_changes_and_ellipsis() {
let before = r#"{"a": 1, "b": 2, "c": 3}"#;
let after = r#"{"a": 1, "b": 99, "c": 3}"#;
let result = compute_json_diff(before, after, 0).unwrap();
for line in &result {
assert!(
!matches!(line, JsonDiffLine::Context(_)),
"context=0 should have no Context lines, found: {line:?}"
);
}
assert!(result.iter().any(|l| matches!(l, JsonDiffLine::Removed(_))));
assert!(result.iter().any(|l| matches!(l, JsonDiffLine::Added(_))));
}
#[test]
fn multiple_hunks_merge_when_close() {
let before = r#"{"a": 1, "b": 2, "c": 3}"#;
let after = r#"{"a": 10, "b": 2, "c": 30}"#;
let result = compute_json_diff(before, after, 1).unwrap();
let change_count = result
.iter()
.filter(|l| matches!(l, JsonDiffLine::Added(_) | JsonDiffLine::Removed(_)))
.count();
assert!(change_count >= 4, "should have changes for a and c");
let between_ellipsis = result.windows(3).any(|w| {
matches!(w[0], JsonDiffLine::Added(_) | JsonDiffLine::Removed(_))
&& matches!(w[1], JsonDiffLine::Ellipsis)
&& matches!(w[2], JsonDiffLine::Added(_) | JsonDiffLine::Removed(_))
});
assert!(
!between_ellipsis,
"close hunks should merge without Ellipsis between them"
);
}
#[test]
fn change_at_start_returns_no_leading_ellipsis() {
let before = r#"{"a": 1, "b": 2}"#;
let after = r#"{"a": 99, "b": 2}"#;
let result = compute_json_diff(before, after, 1).unwrap();
assert!(
!matches!(result.first(), Some(JsonDiffLine::Ellipsis)),
"first line should not be Ellipsis when change is at start"
);
}
#[test]
fn change_at_end_returns_no_trailing_ellipsis() {
let before = r#"{"a": 1, "b": 2}"#;
let after = r#"{"a": 1, "b": 99}"#;
let result = compute_json_diff(before, after, 1).unwrap();
assert!(
!matches!(result.last(), Some(JsonDiffLine::Ellipsis)),
"last line should not be Ellipsis when change is at end"
);
}
#[test]
fn asymmetric_large_diff_still_returns_structured_diff() {
let after_items = (0..600)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
let before = r#"{"items":[0]}"#;
let after = format!(r#"{{"items":[{after_items}]}}"#);
let result = compute_json_diff(before, &after, 1);
assert!(
result.is_some(),
"asymmetric JSON sizes should still use structured diff when LCS table stays small"
);
}
#[test]
fn oversized_lcs_table_returns_none() {
let before_items = (0..550)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
let after_items = (550..1100)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",");
let before = format!(r#"{{"items":[{before_items}]}}"#);
let after = format!(r#"{{"items":[{after_items}]}}"#);
let result = compute_json_diff(&before, &after, 1);
assert_eq!(result, None, "oversized LCS inputs should fall back safely");
}
}