use amberfork_model::{FieldDiff, FieldDiffKind, Move, MoveKind, Payload, Step};
use serde_json::Value;
use std::collections::BTreeSet;
pub(crate) fn field_diffs(
reference: &[Step],
observed: &[Step],
alignment: &[Move],
) -> Vec<FieldDiff> {
let mut out = Vec::new();
for (index, mv) in alignment.iter().enumerate() {
if mv.kind != MoveKind::Sync {
continue;
}
let pair = mv
.a_idx
.and_then(|i| reference.get(i))
.zip(mv.b_idx.and_then(|i| observed.get(i)));
let Some((a, b)) = pair else {
continue;
};
slot_diffs(
index,
"inputs",
a.inputs.as_ref(),
b.inputs.as_ref(),
&mut out,
);
slot_diffs(
index,
"outputs",
a.outputs.as_ref(),
b.outputs.as_ref(),
&mut out,
);
}
out
}
fn slot_diffs(
step: usize,
slot: &str,
a: Option<&Payload>,
b: Option<&Payload>,
out: &mut Vec<FieldDiff>,
) {
match (a, b) {
(None, None) => {}
(None, Some(b)) => out.push(FieldDiff {
step,
path: slot.to_string(),
before: None,
after: Some(payload_value(b)),
kind: FieldDiffKind::Added,
}),
(Some(a), None) => out.push(FieldDiff {
step,
path: slot.to_string(),
before: Some(payload_value(a)),
after: None,
kind: FieldDiffKind::Removed,
}),
(Some(a), Some(b)) => {
value_diffs(step, slot, &payload_value(a), &payload_value(b), out);
}
}
}
fn value_diffs(step: usize, path: &str, before: &Value, after: &Value, out: &mut Vec<FieldDiff>) {
if before == after {
return;
}
if let (Value::Object(a), Value::Object(b)) = (before, after) {
let keys: BTreeSet<&String> = a.keys().chain(b.keys()).collect();
for key in keys {
let child_path = format!("{path}.{key}");
match (a.get(key), b.get(key)) {
(Some(before), Some(after)) => value_diffs(step, &child_path, before, after, out),
(Some(before), None) => out.push(FieldDiff {
step,
path: child_path,
before: Some(before.clone()),
after: None,
kind: FieldDiffKind::Removed,
}),
(None, Some(after)) => out.push(FieldDiff {
step,
path: child_path,
before: None,
after: Some(after.clone()),
kind: FieldDiffKind::Added,
}),
(None, None) => unreachable!("key came from the union of both maps"),
}
}
return;
}
out.push(FieldDiff {
step,
path: path.to_string(),
before: Some(before.clone()),
after: Some(after.clone()),
kind: FieldDiffKind::Changed,
});
}
fn payload_value(payload: &Payload) -> Value {
match payload {
Payload::Text(text) => Value::String(text.clone()),
Payload::Object(map) => Value::Object(map.clone()),
Payload::Other(value) => value.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use amberfork_model::test_support;
use serde_json::json;
fn step(idx: usize, inputs: Option<Payload>, outputs: Option<Payload>) -> Step {
test_support::step(idx, "tool")
.inputs(inputs)
.outputs(outputs)
.build()
}
fn text(s: &str) -> Option<Payload> {
Some(Payload::Text(s.to_string()))
}
fn object(value: serde_json::Value) -> Option<Payload> {
match value {
Value::Object(map) => Some(Payload::Object(map)),
other => panic!("test fixture must be a JSON object, got {other}"),
}
}
#[test]
fn identical_payloads_yield_no_diffs() {
let a = [step(0, text("query"), text("9 results"))];
let b = [step(0, text("query"), text("9 results"))];
let alignment = [Move::sync(0, 0, 0.0, 1.0)];
assert_eq!(field_diffs(&a, &b, &alignment), Vec::new());
}
#[test]
fn changed_text_body_diffs_at_the_slot_path_with_the_alignment_index() {
let a = [step(0, None, text("census.gov page"))];
let b = [
step(0, None, text("stray retry")),
step(1, None, text("blogspot page")),
];
let alignment = [Move::log(0, 0.6, 0.9), Move::sync(0, 1, 0.8, 0.2)];
assert_eq!(
field_diffs(&a, &b, &alignment),
vec![FieldDiff {
step: 1,
path: "outputs".to_string(),
before: Some(json!("census.gov page")),
after: Some(json!("blogspot page")),
kind: FieldDiffKind::Changed,
}]
);
}
#[test]
fn object_payloads_diff_per_key_in_sorted_order() {
let a = [step(
0,
None,
object(json!({"gone": true, "same": 1, "status": "ok"})),
)];
let b = [step(
0,
None,
object(json!({"new": 2, "same": 1, "status": "error"})),
)];
let alignment = [Move::sync(0, 0, 0.5, 0.5)];
assert_eq!(
field_diffs(&a, &b, &alignment),
vec![
FieldDiff {
step: 0,
path: "outputs.gone".to_string(),
before: Some(json!(true)),
after: None,
kind: FieldDiffKind::Removed,
},
FieldDiff {
step: 0,
path: "outputs.new".to_string(),
before: None,
after: Some(json!(2)),
kind: FieldDiffKind::Added,
},
FieldDiff {
step: 0,
path: "outputs.status".to_string(),
before: Some(json!("ok")),
after: Some(json!("error")),
kind: FieldDiffKind::Changed,
},
]
);
}
#[test]
fn nested_objects_recurse_to_the_changed_leaf() {
let a = [step(
0,
None,
object(json!({"response": {"body": "x", "meta": {"tokens": 10}}})),
)];
let b = [step(
0,
None,
object(json!({"response": {"body": "x", "meta": {"tokens": 12}}})),
)];
let alignment = [Move::sync(0, 0, 0.1, 0.9)];
assert_eq!(
field_diffs(&a, &b, &alignment),
vec![FieldDiff {
step: 0,
path: "outputs.response.meta.tokens".to_string(),
before: Some(json!(10)),
after: Some(json!(12)),
kind: FieldDiffKind::Changed,
}]
);
}
#[test]
fn one_sided_slots_are_added_or_removed_inputs_before_outputs() {
let a = [step(0, object(json!({"q": "census"})), None)];
let b = [step(0, None, text("blog page"))];
let alignment = [Move::sync(0, 0, 0.9, 0.1)];
assert_eq!(
field_diffs(&a, &b, &alignment),
vec![
FieldDiff {
step: 0,
path: "inputs".to_string(),
before: Some(json!({"q": "census"})),
after: None,
kind: FieldDiffKind::Removed,
},
FieldDiff {
step: 0,
path: "outputs".to_string(),
before: None,
after: Some(json!("blog page")),
kind: FieldDiffKind::Added,
},
]
);
}
#[test]
fn non_sync_moves_carry_no_field_diffs() {
let a = [step(0, None, text("only in reference"))];
let b = [step(0, None, text("only in observed"))];
let alignment = [Move::model(0, 0.7, 0.8), Move::log(0, 0.7, 0.8)];
assert_eq!(field_diffs(&a, &b, &alignment), Vec::new());
}
#[test]
fn payload_variants_compare_by_wire_value() {
let a = [step(0, None, text("same"))];
let b = [step(0, None, Some(Payload::Other(json!("same"))))];
let alignment = [Move::sync(0, 0, 0.0, 1.0)];
assert_eq!(field_diffs(&a, &b, &alignment), Vec::new());
}
}