Skip to main content

backhopper_core/
schema_diff.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! Structured diff between two schema versions.
6//!
7//! The diff walks two `serde_json::Value` trees in parallel, emitting
8//! JSON-Pointer-keyed differences. Suitable for agents migrating
9//! between binary releases.
10
11use std::collections::BTreeSet;
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
18pub struct SchemaDiff {
19    pub from: u32,
20    pub to: u32,
21    pub added_paths: Vec<String>,
22    pub removed_paths: Vec<String>,
23    pub changed_types: Vec<TypeChange>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
28pub struct TypeChange {
29    pub path: String,
30    pub old_type: String,
31    pub new_type: String,
32}
33
34/// Walk `from` and `to` schema values and emit a structured diff.
35///
36/// `added_paths` are JSON Pointer paths present in `to` but not in
37/// `from`. `removed_paths` are the inverse. `changed_types` flags any
38/// terminal-value type changes (e.g., `"integer"` to `"string"` at
39/// the same path).
40#[must_use]
41pub fn diff(from_version: u32, to_version: u32, from: &Value, to: &Value) -> SchemaDiff {
42    let mut added = Vec::new();
43    let mut removed = Vec::new();
44    let mut changed = Vec::new();
45    walk(from, to, "", &mut added, &mut removed, &mut changed);
46    added.sort();
47    removed.sort();
48    changed.sort_by(|a, b| a.path.cmp(&b.path));
49    SchemaDiff {
50        from: from_version,
51        to: to_version,
52        added_paths: added,
53        removed_paths: removed,
54        changed_types: changed,
55    }
56}
57
58fn walk(
59    from: &Value,
60    to: &Value,
61    path: &str,
62    added: &mut Vec<String>,
63    removed: &mut Vec<String>,
64    changed: &mut Vec<TypeChange>,
65) {
66    match (from, to) {
67        (Value::Object(a), Value::Object(b)) => {
68            let keys: BTreeSet<&String> = a.keys().chain(b.keys()).collect();
69            for key in keys {
70                let child_path = format!("{path}/{}", escape_pointer_segment(key));
71                match (a.get(key), b.get(key)) {
72                    (None, Some(_)) => added.push(child_path),
73                    (Some(_), None) => removed.push(child_path),
74                    (Some(av), Some(bv)) => walk(av, bv, &child_path, added, removed, changed),
75                    (None, None) => unreachable!("key from union"),
76                }
77            }
78        }
79        (Value::Array(a), Value::Array(b)) => {
80            let max = a.len().max(b.len());
81            for idx in 0..max {
82                let child_path = format!("{path}/{idx}");
83                match (a.get(idx), b.get(idx)) {
84                    (None, Some(_)) => added.push(child_path),
85                    (Some(_), None) => removed.push(child_path),
86                    (Some(av), Some(bv)) => walk(av, bv, &child_path, added, removed, changed),
87                    (None, None) => unreachable!("index in range"),
88                }
89            }
90        }
91        (a, b) if json_type_name(a) != json_type_name(b) => {
92            changed.push(TypeChange {
93                path: path.to_owned(),
94                old_type: json_type_name(a).to_owned(),
95                new_type: json_type_name(b).to_owned(),
96            });
97        }
98        _ => {}
99    }
100}
101
102fn escape_pointer_segment(key: &str) -> String {
103    key.replace('~', "~0").replace('/', "~1")
104}
105
106fn json_type_name(v: &Value) -> &'static str {
107    match v {
108        Value::Null => "null",
109        Value::Bool(_) => "bool",
110        Value::Number(n) if n.is_i64() || n.is_u64() => "integer",
111        Value::Number(_) => "number",
112        Value::String(_) => "string",
113        Value::Array(_) => "array",
114        Value::Object(_) => "object",
115    }
116}