use serde_yaml_ng::{Mapping, Value};
use crate::error::JoyError;
pub fn is_joycrypt_blob(bytes: &[u8]) -> bool {
bytes.starts_with(b"JOYCRYPT")
}
pub fn merge_yaml_doc(base: &str, ours: &str, theirs: &str) -> Result<String, JoyError> {
let base_v = parse(base)?;
let ours_v = parse(ours)?;
let theirs_v = parse(theirs)?;
let tiebreaker = pick_tiebreaker(&ours_v, &theirs_v);
let merged = merge_value(&base_v, &ours_v, &theirs_v, tiebreaker, "");
serde_yaml_ng::to_string(&merged).map_err(JoyError::from)
}
fn parse(s: &str) -> Result<Value, JoyError> {
if s.trim().is_empty() {
Ok(Value::Null)
} else {
serde_yaml_ng::from_str(s).map_err(JoyError::from)
}
}
#[derive(Copy, Clone)]
enum Side {
Ours,
Theirs,
}
fn pick_tiebreaker(ours: &Value, theirs: &Value) -> Side {
match (top_level_updated(ours), top_level_updated(theirs)) {
(Some(o), Some(t)) => {
if o >= t {
Side::Ours
} else {
Side::Theirs
}
}
(Some(_), None) => Side::Ours,
(None, Some(_)) => Side::Theirs,
(None, None) => Side::Theirs,
}
}
fn top_level_updated(v: &Value) -> Option<&str> {
if let Value::Mapping(m) = v {
if let Some(Value::String(s)) = m.get(string_key("updated")) {
return Some(s.as_str());
}
}
None
}
fn merge_value(base: &Value, ours: &Value, theirs: &Value, tb: Side, path: &str) -> Value {
if ours == theirs {
return ours.clone();
}
if ours == base {
return theirs.clone();
}
if theirs == base {
return ours.clone();
}
match (ours, theirs) {
(Value::Mapping(o), Value::Mapping(t)) => {
let b = if let Value::Mapping(m) = base {
Some(m)
} else {
None
};
Value::Mapping(merge_mapping(b, o, t, tb, path))
}
(Value::Sequence(o), Value::Sequence(t)) => {
let b = if let Value::Sequence(s) = base {
Some(s.as_slice())
} else {
None
};
Value::Sequence(merge_sequence(b, o, t, path))
}
_ => match tb {
Side::Ours => ours.clone(),
Side::Theirs => theirs.clone(),
},
}
}
fn merge_mapping(
base: Option<&Mapping>,
ours: &Mapping,
theirs: &Mapping,
tb: Side,
path: &str,
) -> Mapping {
let mut result = Mapping::new();
for (k, ov) in ours {
let bv = base.and_then(|b| b.get(k));
let tv = theirs.get(k);
let child_path = sub_path(path, k);
match (bv, tv) {
(_, Some(tv)) => {
let bv_owned = bv.cloned().unwrap_or(Value::Null);
let merged = merge_value(&bv_owned, ov, tv, tb, &child_path);
result.insert(k.clone(), merged);
}
(Some(bv_), None) => {
if ov == bv_ {
} else {
if matches!(tb, Side::Ours) {
result.insert(k.clone(), ov.clone());
}
}
}
(None, None) => {
result.insert(k.clone(), ov.clone());
}
}
}
for (k, tv) in theirs {
if ours.contains_key(k) {
continue;
}
let bv = base.and_then(|b| b.get(k));
match bv {
None => {
result.insert(k.clone(), tv.clone());
}
Some(bv_) => {
if tv == bv_ {
} else if matches!(tb, Side::Theirs) {
result.insert(k.clone(), tv.clone());
}
}
}
}
result
}
fn merge_sequence(
base: Option<&[Value]>,
ours: &[Value],
theirs: &[Value],
path: &str,
) -> Vec<Value> {
let id = |v: &Value| list_identity(path, v);
let base_ids: Vec<Value> = base.map(|b| b.iter().map(id).collect()).unwrap_or_default();
let ours_ids: Vec<Value> = ours.iter().map(id).collect();
let theirs_ids: Vec<Value> = theirs.iter().map(id).collect();
let in_set = |set: &[Value], k: &Value| set.iter().any(|x| x == k);
let mut result = Vec::new();
let mut seen: Vec<Value> = Vec::new();
for item in ours {
let k = id(item);
if in_set(&seen, &k) {
continue;
}
if in_set(&base_ids, &k) && !in_set(&theirs_ids, &k) {
continue;
}
result.push(item.clone());
seen.push(k);
}
for item in theirs {
let k = id(item);
if in_set(&seen, &k) {
continue;
}
if in_set(&base_ids, &k) && !in_set(&ours_ids, &k) {
continue;
}
result.push(item.clone());
seen.push(k);
}
result
}
fn list_identity(path: &str, item: &Value) -> Value {
let last = path.rsplit('.').next().unwrap_or(path);
match (last, item) {
("comments", Value::Mapping(m)) => {
let author = m.get(string_key("author")).cloned().unwrap_or(Value::Null);
let date = m.get(string_key("date")).cloned().unwrap_or(Value::Null);
Value::Sequence(vec![author, date])
}
("assignees", Value::Mapping(m)) => {
m.get(string_key("member")).cloned().unwrap_or(Value::Null)
}
_ => item.clone(),
}
}
fn string_key(s: &str) -> Value {
Value::String(s.to_string())
}
fn sub_path(parent: &str, key: &Value) -> String {
let key_str = match key {
Value::String(s) => s.as_str(),
_ => return parent.to_string(),
};
if parent.is_empty() {
key_str.to_string()
} else {
format!("{}.{}", parent, key_str)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn merge(base: &str, ours: &str, theirs: &str) -> Value {
let s = merge_yaml_doc(base, ours, theirs).unwrap();
serde_yaml_ng::from_str(&s).unwrap()
}
fn yaml(s: &str) -> Value {
serde_yaml_ng::from_str(s).unwrap()
}
#[test]
fn duplicate_updated_field_is_resolved_to_max() {
let base = "id: A\nupdated: 2026-04-22T08:00:00Z\nstatus: open\n";
let ours = "id: A\nupdated: 2026-04-24T15:00:00Z\nstatus: open\n";
let theirs = "id: A\nupdated: 2026-04-28T14:00:00Z\nstatus: open\n";
let merged = merge(base, ours, theirs);
let m = merged.as_mapping().unwrap();
assert_eq!(
m.get(string_key("updated")).unwrap().as_str().unwrap(),
"2026-04-28T14:00:00Z"
);
}
#[test]
fn scalar_conflict_resolved_by_later_updated() {
let base = "id: A\nupdated: 2026-01-01T00:00:00Z\npriority: medium\n";
let ours = "id: A\nupdated: 2026-04-02T00:00:00Z\npriority: high\n";
let theirs = "id: A\nupdated: 2026-04-01T00:00:00Z\npriority: low\n";
let merged = merge(base, ours, theirs);
let m = merged.as_mapping().unwrap();
assert_eq!(
m.get(string_key("priority")).unwrap().as_str().unwrap(),
"high",
"ours has the later updated, so its priority must win"
);
}
#[test]
fn one_sided_scalar_change_is_kept() {
let base = "id: A\nupdated: 2026-01-01T00:00:00Z\nstatus: open\n";
let ours = "id: A\nupdated: 2026-04-01T00:00:00Z\nstatus: in-progress\n";
let theirs = "id: A\nupdated: 2026-01-01T00:00:00Z\nstatus: open\n";
let merged = merge(base, ours, theirs);
assert_eq!(
merged
.as_mapping()
.unwrap()
.get(string_key("status"))
.unwrap()
.as_str()
.unwrap(),
"in-progress"
);
}
#[test]
fn comments_added_on_both_sides_are_unioned() {
let base = "id: A\nupdated: 2026-01-01T00:00:00Z\ncomments:\n - author: alice\n date: 2026-04-01T10:00:00Z\n text: plan\n";
let ours = "id: A\nupdated: 2026-04-02T00:00:00Z\ncomments:\n - author: alice\n date: 2026-04-01T10:00:00Z\n text: plan\n - author: bob\n date: 2026-04-02T09:00:00Z\n text: review\n";
let theirs = "id: A\nupdated: 2026-04-03T00:00:00Z\ncomments:\n - author: alice\n date: 2026-04-01T10:00:00Z\n text: plan\n - author: carol\n date: 2026-04-03T08:00:00Z\n text: ship\n";
let merged = merge(base, ours, theirs);
let comments = merged
.as_mapping()
.unwrap()
.get(string_key("comments"))
.unwrap()
.as_sequence()
.unwrap();
assert_eq!(comments.len(), 3);
let authors: Vec<_> = comments
.iter()
.filter_map(|c| c.as_mapping())
.filter_map(|m| m.get(string_key("author")))
.filter_map(|v| v.as_str())
.collect();
assert_eq!(authors, vec!["alice", "bob", "carol"]);
}
#[test]
fn comment_dedup_uses_author_date_not_text() {
let base = "id: A\nupdated: 2026-01-01T00:00:00Z\ncomments: []\n";
let ours = "id: A\nupdated: 2026-04-02T00:00:00Z\ncomments:\n - author: alice\n date: 2026-04-01T10:00:00Z\n text: ours\n";
let theirs = "id: A\nupdated: 2026-04-03T00:00:00Z\ncomments:\n - author: alice\n date: 2026-04-01T10:00:00Z\n text: theirs\n";
let merged = merge(base, ours, theirs);
let comments = merged
.as_mapping()
.unwrap()
.get(string_key("comments"))
.unwrap()
.as_sequence()
.unwrap();
assert_eq!(comments.len(), 1, "same (author,date) -> single entry");
}
#[test]
fn deps_and_tags_unioned() {
let base = "id: A\nupdated: 2026-01-01T00:00:00Z\ndeps: [JOY-0001]\ntags: [a]\n";
let ours =
"id: A\nupdated: 2026-04-01T00:00:00Z\ndeps: [JOY-0001, JOY-0002]\ntags: [a, b]\n";
let theirs =
"id: A\nupdated: 2026-04-02T00:00:00Z\ndeps: [JOY-0001, JOY-0003]\ntags: [a, c]\n";
let merged = merge(base, ours, theirs);
let m = merged.as_mapping().unwrap();
let deps: Vec<_> = m
.get(string_key("deps"))
.unwrap()
.as_sequence()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(deps, vec!["JOY-0001", "JOY-0002", "JOY-0003"]);
let tags: Vec<_> = m
.get(string_key("tags"))
.unwrap()
.as_sequence()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(tags, vec!["a", "b", "c"]);
}
#[test]
fn assignees_dedupe_by_member() {
let base = "id: A\nupdated: 2026-01-01T00:00:00Z\nassignees: []\n";
let ours = "id: A\nupdated: 2026-04-01T00:00:00Z\nassignees:\n - member: alice\n as: [implement]\n";
let theirs = "id: A\nupdated: 2026-04-02T00:00:00Z\nassignees:\n - member: alice\n as: [implement]\n - member: bob\n";
let merged = merge(base, ours, theirs);
let assignees = merged
.as_mapping()
.unwrap()
.get(string_key("assignees"))
.unwrap()
.as_sequence()
.unwrap();
let members: Vec<_> = assignees
.iter()
.filter_map(|v| v.as_mapping())
.filter_map(|m| m.get(string_key("member")))
.filter_map(|v| v.as_str())
.collect();
assert_eq!(members, vec!["alice", "bob"]);
}
#[test]
fn item_added_on_one_side_and_removed_on_other_falls_through_3way() {
let base = "id: A\nupdated: 2026-01-01T00:00:00Z\ndeps: [X]\n";
let ours = "id: A\nupdated: 2026-04-01T00:00:00Z\ndeps: [X]\n";
let theirs = "id: A\nupdated: 2026-04-02T00:00:00Z\ndeps: []\n";
let merged = merge(base, ours, theirs);
let deps = merged
.as_mapping()
.unwrap()
.get(string_key("deps"))
.unwrap()
.as_sequence()
.unwrap();
assert!(
deps.is_empty(),
"removed by theirs, untouched by ours -> drop"
);
}
#[test]
fn missing_field_added_on_one_side_only() {
let base = "id: A\nupdated: 2026-01-01T00:00:00Z\n";
let ours = "id: A\nupdated: 2026-04-01T00:00:00Z\nmilestone: M1\n";
let theirs = "id: A\nupdated: 2026-04-02T00:00:00Z\n";
let merged = merge(base, ours, theirs);
assert_eq!(
merged
.as_mapping()
.unwrap()
.get(string_key("milestone"))
.unwrap()
.as_str()
.unwrap(),
"M1"
);
}
#[test]
fn nested_mapping_merges_recursively() {
let base = yaml("a:\n x: 1\n y: 2\nupdated: 2026-01-01T00:00:00Z\n");
let ours = yaml("a:\n x: 10\n y: 2\nupdated: 2026-04-01T00:00:00Z\n");
let theirs = yaml("a:\n x: 1\n y: 20\nupdated: 2026-04-02T00:00:00Z\n");
let s = merge_yaml_doc(
&serde_yaml_ng::to_string(&base).unwrap(),
&serde_yaml_ng::to_string(&ours).unwrap(),
&serde_yaml_ng::to_string(&theirs).unwrap(),
)
.unwrap();
let merged: Value = serde_yaml_ng::from_str(&s).unwrap();
let a = merged.as_mapping().unwrap().get(string_key("a")).unwrap();
let am = a.as_mapping().unwrap();
assert_eq!(am.get(string_key("x")).unwrap().as_i64().unwrap(), 10);
assert_eq!(am.get(string_key("y")).unwrap().as_i64().unwrap(), 20);
}
#[test]
fn detects_joycrypt_blob() {
assert!(is_joycrypt_blob(b"JOYCRYPT\x01\x07default..."));
assert!(!is_joycrypt_blob(b"id: A\nupdated: 2026-01-01T00:00:00Z\n"));
assert!(!is_joycrypt_blob(b"JOY"));
assert!(!is_joycrypt_blob(b""));
}
#[test]
fn no_updated_anywhere_falls_back_to_theirs() {
let base = "id: A\nstatus: open\n";
let ours = "id: A\nstatus: in-progress\n";
let theirs = "id: A\nstatus: review\n";
let merged = merge(base, ours, theirs);
assert_eq!(
merged
.as_mapping()
.unwrap()
.get(string_key("status"))
.unwrap()
.as_str()
.unwrap(),
"review"
);
}
}