LeVCS/crates/levcs-merge/src/format.rs

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"),
}
}
}