303 lines
11 KiB
Rust
303 lines
11 KiB
Rust
//! Format-aware handlers: JSON, TOML, YAML.
|
|
//!
|
|
//! Each handler parses base/ours/theirs as the named structured format and
|
|
//! performs a three-way merge over the parsed value. JSON/TOML/YAML are
|
|
//! merged via the same recursive `merge_value` logic on `serde_json::Value`,
|
|
//! since that type can losslessly represent all three for our purposes.
|
|
|
|
use std::path::Path;
|
|
|
|
use serde_json::Value;
|
|
|
|
use crate::handler::{ConflictRegion, MergeHandler, MergeNote, MergeResult, MergeStatus};
|
|
|
|
pub struct JsonHandler;
|
|
pub struct TomlHandler;
|
|
|
|
impl MergeHandler for JsonHandler {
|
|
fn name(&self) -> &str {
|
|
"json"
|
|
}
|
|
fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool {
|
|
path.extension().and_then(|e| e.to_str()) == Some("json")
|
|
}
|
|
fn merge(&self, _path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
|
|
let try_parse = |b: &[u8]| -> Option<Value> { serde_json::from_slice(b).ok() };
|
|
let (b, o, t) = match (try_parse(base), try_parse(ours), try_parse(theirs)) {
|
|
(Some(b), Some(o), Some(t)) => (b, o, t),
|
|
_ => {
|
|
return MergeResult {
|
|
handler: self.name().into(),
|
|
status: MergeStatus::NotApplicable,
|
|
};
|
|
}
|
|
};
|
|
let (merged, conflicts) = merge_value(&b, &o, &t, "");
|
|
let bytes = serde_json::to_vec_pretty(&merged).unwrap_or_default();
|
|
let status = if conflicts.is_empty() {
|
|
MergeStatus::Merged {
|
|
content: bytes,
|
|
notes: vec![MergeNote {
|
|
message: "structural JSON three-way merge".into(),
|
|
}],
|
|
}
|
|
} else {
|
|
MergeStatus::Conflict {
|
|
regions: conflicts,
|
|
partial: bytes,
|
|
}
|
|
};
|
|
MergeResult {
|
|
handler: self.name().into(),
|
|
status,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MergeHandler for TomlHandler {
|
|
fn name(&self) -> &str {
|
|
"toml"
|
|
}
|
|
fn applicable(&self, path: &Path, _b: &[u8], _o: &[u8], _t: &[u8]) -> bool {
|
|
path.extension().and_then(|e| e.to_str()) == Some("toml")
|
|
}
|
|
fn merge(&self, _path: &Path, base: &[u8], ours: &[u8], theirs: &[u8]) -> MergeResult {
|
|
let try_parse = |b: &[u8]| -> Option<Value> {
|
|
let s = std::str::from_utf8(b).ok()?;
|
|
let t: toml::Value = toml::from_str(s).ok()?;
|
|
toml_to_json(&t).into()
|
|
};
|
|
let (b, o, t) = match (try_parse(base), try_parse(ours), try_parse(theirs)) {
|
|
(Some(b), Some(o), Some(t)) => (b, o, t),
|
|
_ => {
|
|
return MergeResult {
|
|
handler: self.name().into(),
|
|
status: MergeStatus::NotApplicable,
|
|
};
|
|
}
|
|
};
|
|
let (merged, conflicts) = merge_value(&b, &o, &t, "");
|
|
let toml_value = json_to_toml(&merged);
|
|
let s = toml::to_string_pretty(&toml_value).unwrap_or_default();
|
|
let bytes = s.into_bytes();
|
|
let status = if conflicts.is_empty() {
|
|
MergeStatus::Merged {
|
|
content: bytes,
|
|
notes: vec![MergeNote {
|
|
message: "structural TOML three-way merge".into(),
|
|
}],
|
|
}
|
|
} else {
|
|
MergeStatus::Conflict {
|
|
regions: conflicts,
|
|
partial: bytes,
|
|
}
|
|
};
|
|
MergeResult {
|
|
handler: self.name().into(),
|
|
status,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn toml_to_json(v: &toml::Value) -> Value {
|
|
match v {
|
|
toml::Value::String(s) => Value::String(s.clone()),
|
|
toml::Value::Integer(i) => Value::from(*i),
|
|
toml::Value::Float(f) => Value::from(*f),
|
|
toml::Value::Boolean(b) => Value::from(*b),
|
|
toml::Value::Datetime(d) => Value::String(d.to_string()),
|
|
toml::Value::Array(arr) => Value::Array(arr.iter().map(toml_to_json).collect()),
|
|
toml::Value::Table(t) => {
|
|
let mut m = serde_json::Map::new();
|
|
for (k, v) in t {
|
|
m.insert(k.clone(), toml_to_json(v));
|
|
}
|
|
Value::Object(m)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn json_to_toml(v: &Value) -> toml::Value {
|
|
match v {
|
|
Value::String(s) => toml::Value::String(s.clone()),
|
|
Value::Number(n) => {
|
|
if let Some(i) = n.as_i64() {
|
|
toml::Value::Integer(i)
|
|
} else if let Some(f) = n.as_f64() {
|
|
toml::Value::Float(f)
|
|
} else {
|
|
toml::Value::String(n.to_string())
|
|
}
|
|
}
|
|
Value::Bool(b) => toml::Value::Boolean(*b),
|
|
Value::Null => toml::Value::String(String::new()),
|
|
Value::Array(arr) => toml::Value::Array(arr.iter().map(json_to_toml).collect()),
|
|
Value::Object(o) => {
|
|
let mut t = toml::map::Map::new();
|
|
for (k, v) in o {
|
|
t.insert(k.clone(), json_to_toml(v));
|
|
}
|
|
toml::Value::Table(t)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Recursive structural merge over `serde_json::Value`. Reused by the YAML
|
|
/// handler (which converts via `serde_yaml::Value`).
|
|
pub fn merge_value(
|
|
base: &Value,
|
|
ours: &Value,
|
|
theirs: &Value,
|
|
path: &str,
|
|
) -> (Value, Vec<ConflictRegion>) {
|
|
if ours == theirs {
|
|
return (ours.clone(), Vec::new());
|
|
}
|
|
if base == ours {
|
|
return (theirs.clone(), Vec::new());
|
|
}
|
|
if base == theirs {
|
|
return (ours.clone(), Vec::new());
|
|
}
|
|
match (base, ours, theirs) {
|
|
(Value::Object(b), Value::Object(o), Value::Object(t)) => {
|
|
let mut merged = serde_json::Map::new();
|
|
let mut conflicts = Vec::new();
|
|
let mut keys: std::collections::BTreeSet<&String> = b.keys().collect();
|
|
keys.extend(o.keys());
|
|
keys.extend(t.keys());
|
|
for k in keys {
|
|
let bv = b.get(k);
|
|
let ov = o.get(k);
|
|
let tv = t.get(k);
|
|
let sub_path = if path.is_empty() {
|
|
k.clone()
|
|
} else {
|
|
format!("{path}.{k}")
|
|
};
|
|
match (bv, ov, tv) {
|
|
(None, None, None) => {}
|
|
(None, Some(o), None) => {
|
|
merged.insert(k.clone(), o.clone());
|
|
}
|
|
(None, None, Some(t)) => {
|
|
merged.insert(k.clone(), t.clone());
|
|
}
|
|
(Some(b), Some(o), None) => {
|
|
if b == o {
|
|
// theirs deleted; ours unchanged → delete.
|
|
} else {
|
|
// ours modified, theirs deleted → conflict (keep ours).
|
|
merged.insert(k.clone(), o.clone());
|
|
conflicts.push(ConflictRegion {
|
|
description: format!(
|
|
"{sub_path}: modified by ours, deleted by theirs"
|
|
),
|
|
base: 0..0,
|
|
ours: 0..0,
|
|
theirs: 0..0,
|
|
});
|
|
}
|
|
}
|
|
(Some(b), None, Some(t)) => {
|
|
if b == t {
|
|
// ours deleted; theirs unchanged → delete.
|
|
} else {
|
|
merged.insert(k.clone(), t.clone());
|
|
conflicts.push(ConflictRegion {
|
|
description: format!(
|
|
"{sub_path}: deleted by ours, modified by theirs"
|
|
),
|
|
base: 0..0,
|
|
ours: 0..0,
|
|
theirs: 0..0,
|
|
});
|
|
}
|
|
}
|
|
(Some(b), Some(o), Some(t)) => {
|
|
let (sub, c) = merge_value(b, o, t, &sub_path);
|
|
merged.insert(k.clone(), sub);
|
|
conflicts.extend(c);
|
|
}
|
|
(None, Some(o), Some(t)) => {
|
|
if o == t {
|
|
merged.insert(k.clone(), o.clone());
|
|
} else {
|
|
merged.insert(k.clone(), o.clone());
|
|
conflicts.push(ConflictRegion {
|
|
description: format!(
|
|
"{sub_path}: independently added with different values"
|
|
),
|
|
base: 0..0,
|
|
ours: 0..0,
|
|
theirs: 0..0,
|
|
});
|
|
}
|
|
}
|
|
(Some(_b), None, None) => { /* both deleted, drop */ }
|
|
}
|
|
}
|
|
(Value::Object(merged), conflicts)
|
|
}
|
|
(Value::Array(_b), Value::Array(o), Value::Array(t)) => {
|
|
// Concatenate ours then theirs additions, preserving the spec's
|
|
// "independent additions are merged" guidance for primitive
|
|
// arrays (with deduplication).
|
|
let mut merged: Vec<Value> = o.clone();
|
|
for v in t {
|
|
if !merged.contains(v) {
|
|
merged.push(v.clone());
|
|
}
|
|
}
|
|
(Value::Array(merged), Vec::new())
|
|
}
|
|
_ => {
|
|
// Scalar conflict.
|
|
let conflict = ConflictRegion {
|
|
description: format!("{path}: divergent scalar modifications"),
|
|
base: 0..0,
|
|
ours: 0..0,
|
|
theirs: 0..0,
|
|
};
|
|
(ours.clone(), vec![conflict])
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn json_object_merge_disjoint() {
|
|
let h = JsonHandler;
|
|
let base = br#"{"a":1}"#;
|
|
let ours = br#"{"a":1,"b":2}"#;
|
|
let theirs = br#"{"a":1,"c":3}"#;
|
|
let r = h.merge(Path::new("x.json"), base, ours, theirs);
|
|
match r.status {
|
|
MergeStatus::Merged { content, .. } => {
|
|
let v: Value = serde_json::from_slice(&content).unwrap();
|
|
assert_eq!(v["a"], 1);
|
|
assert_eq!(v["b"], 2);
|
|
assert_eq!(v["c"], 3);
|
|
}
|
|
other => panic!("expected merged, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn json_scalar_conflict() {
|
|
let h = JsonHandler;
|
|
let base = br#"{"a":1}"#;
|
|
let ours = br#"{"a":2}"#;
|
|
let theirs = br#"{"a":3}"#;
|
|
let r = h.merge(Path::new("x.json"), base, ours, theirs);
|
|
match r.status {
|
|
MergeStatus::Conflict { regions, .. } => assert_eq!(regions.len(), 1),
|
|
_ => panic!("expected conflict"),
|
|
}
|
|
}
|
|
}
|