Skip to main content

driftsync_core/
structural_merge.rs

1use serde::{Deserialize, Serialize};
2use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Item, Table, Value};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "kebab-case")]
6pub enum FileFormat {
7    Toml,
8    Json,
9    Yaml,
10    Text,
11}
12
13impl FileFormat {
14    pub fn from_path(path: &str) -> Self {
15        let lower = path.trim().to_ascii_lowercase();
16        if lower.ends_with(".toml") {
17            Self::Toml
18        } else if lower.ends_with(".json") {
19            Self::Json
20        } else if lower.ends_with(".yml") || lower.ends_with(".yaml") {
21            Self::Yaml
22        } else {
23            Self::Text
24        }
25    }
26}
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct StructuralConflict {
30    pub key_path: String,
31    pub base: String,
32    pub ours: String,
33    pub theirs: String,
34}
35
36fn format_conflicts(format_label: &str, conflicts: &[StructuralConflict]) -> String {
37    let mut out = format!("# Structural Merge Conflict ({format_label})\n");
38    for c in conflicts {
39        out.push_str(&format!(
40            "<<<<<<< ours: {}\n{}\n||||||| base: {}\n{}\n=======\n{}\n>>>>>>> theirs: {}\n",
41            c.key_path, c.ours, c.key_path, c.base, c.theirs, c.key_path
42        ));
43    }
44    out
45}
46
47pub fn three_way_merge_by_path(
48    target_path: &str,
49    base: &str,
50    ours: &str,
51    theirs: &str,
52) -> Result<String, String> {
53    match FileFormat::from_path(target_path) {
54        FileFormat::Toml => {
55            if let Some(res) = try_merge_toml_structural(base, ours, theirs) {
56                res
57            } else {
58                diffy::merge(base, ours, theirs)
59            }
60        }
61        FileFormat::Json => {
62            if let Some(res) = try_merge_json_structural(base, ours, theirs) {
63                res
64            } else {
65                diffy::merge(base, ours, theirs)
66            }
67        }
68        FileFormat::Yaml => {
69            if let Some(res) = try_merge_yaml_structural(base, ours, theirs) {
70                res
71            } else {
72                diffy::merge(base, ours, theirs)
73            }
74        }
75        FileFormat::Text => diffy::merge(base, ours, theirs),
76    }
77}
78
79pub fn merge_toml_structural(base: &str, ours: &str, theirs: &str) -> Result<String, String> {
80    match try_merge_toml_structural(base, ours, theirs) {
81        Some(result) => result,
82        None => Err("at least one input document is not valid TOML".to_string()),
83    }
84}
85
86fn try_merge_toml_structural(
87    base: &str,
88    ours: &str,
89    theirs: &str,
90) -> Option<Result<String, String>> {
91    let base_doc = base.parse::<DocumentMut>().ok()?;
92    let mut ours_doc = ours.parse::<DocumentMut>().ok()?;
93    let theirs_doc = theirs.parse::<DocumentMut>().ok()?;
94
95    let mut conflicts = Vec::new();
96    merge_toml_tables(
97        Some(base_doc.as_table()),
98        ours_doc.as_table_mut(),
99        theirs_doc.as_table(),
100        "",
101        &mut conflicts,
102    );
103
104    if conflicts.is_empty() {
105        Some(Ok(ours_doc.to_string()))
106    } else {
107        Some(Err(format_conflicts("TOML", &conflicts)))
108    }
109}
110
111fn collect_table_keys(base: Option<&Table>, ours: &Table, theirs: &Table) -> Vec<String> {
112    let mut keys = Vec::new();
113    if let Some(b) = base {
114        for (k, item) in b.iter() {
115            if !item.is_none() && !keys.iter().any(|existing| existing == k) {
116                keys.push(k.to_string());
117            }
118        }
119    }
120    for (k, item) in ours.iter() {
121        if !item.is_none() && !keys.iter().any(|existing| existing == k) {
122            keys.push(k.to_string());
123        }
124    }
125    for (k, item) in theirs.iter() {
126        if !item.is_none() && !keys.iter().any(|existing| existing == k) {
127            keys.push(k.to_string());
128        }
129    }
130    keys
131}
132
133fn item_as_table_clone(item: &Item) -> Option<Table> {
134    match item {
135        Item::Table(t) => Some(t.clone()),
136        Item::Value(Value::InlineTable(it)) => Some(it.clone().into_table()),
137        _ => None,
138    }
139}
140
141fn item_is_table_like(item: &Item) -> bool {
142    matches!(item, Item::Table(_) | Item::Value(Value::InlineTable(_)))
143}
144
145fn merge_toml_tables(
146    base: Option<&Table>,
147    ours: &mut Table,
148    theirs: &Table,
149    prefix: &str,
150    conflicts: &mut Vec<StructuralConflict>,
151) {
152    let keys = collect_table_keys(base, ours, theirs);
153
154    for k in keys {
155        let key_path = if prefix.is_empty() {
156            k.clone()
157        } else {
158            format!("{prefix}.{k}")
159        };
160
161        let b_item = base.and_then(|t| t.get(&k)).filter(|i| !i.is_none());
162        let o_item = ours.get(&k).filter(|i| !i.is_none()).cloned();
163        let t_item = theirs.get(&k).filter(|i| !i.is_none());
164
165        let both_table_like = match (o_item.as_ref(), t_item) {
166            (Some(o), Some(t)) => {
167                item_is_table_like(o)
168                    && item_is_table_like(t)
169                    && b_item.is_none_or(item_is_table_like)
170            }
171            _ => false,
172        };
173
174        if both_table_like {
175            match ours.get_mut(&k) {
176                Some(Item::Table(o_subtable)) => {
177                    let b_sub = b_item.and_then(item_as_table_clone);
178                    if let Some(t_sub) = t_item.and_then(item_as_table_clone) {
179                        merge_toml_tables(b_sub.as_ref(), o_subtable, &t_sub, &key_path, conflicts);
180                    }
181                    continue;
182                }
183                Some(Item::Value(Value::InlineTable(o_inline))) => {
184                    if let Some(Item::Value(Value::InlineTable(t_inline))) = t_item {
185                        let b_inline = match b_item {
186                            Some(Item::Value(Value::InlineTable(bi))) => Some(bi),
187                            _ => None,
188                        };
189                        merge_toml_inline_tables(
190                            b_inline, o_inline, t_inline, &key_path, conflicts,
191                        );
192                        continue;
193                    } else if let Some(t_sub) = t_item.and_then(item_as_table_clone) {
194                        let b_sub = b_item.and_then(item_as_table_clone);
195                        let mut promoted = o_inline.clone().into_table();
196                        merge_toml_tables(
197                            b_sub.as_ref(),
198                            &mut promoted,
199                            &t_sub,
200                            &key_path,
201                            conflicts,
202                        );
203                        ours.insert(&k, Item::Table(promoted));
204                        continue;
205                    }
206                }
207                _ => {}
208            }
209        }
210
211        let b_eq_t = opt_toml_items_equal(b_item, t_item);
212        let b_eq_o = opt_toml_items_equal(b_item, o_item.as_ref());
213        let o_eq_t = opt_toml_items_equal(o_item.as_ref(), t_item);
214
215        if b_eq_t || o_eq_t {
216            // Keep `ours`
217        } else if b_eq_o {
218            match t_item {
219                Some(t_val) => {
220                    apply_theirs_item_to_ours_table(ours, theirs, &k, t_val);
221                }
222                None => {
223                    ours.remove(&k);
224                }
225            }
226        } else {
227            conflicts.push(StructuralConflict {
228                key_path,
229                base: format_opt_toml_item(b_item),
230                ours: format_opt_toml_item(o_item.as_ref()),
231                theirs: format_opt_toml_item(t_item),
232            });
233        }
234    }
235}
236
237fn apply_theirs_item_to_ours_table(ours: &mut Table, theirs: &Table, key: &str, t_val: &Item) {
238    if let (Some(Item::Value(existing_val)), Item::Value(new_val)) = (ours.get_mut(key), t_val) {
239        let prefix = existing_val.decor().prefix().cloned();
240        let suffix = existing_val.decor().suffix().cloned();
241        let mut updated = new_val.clone();
242        if let Some(p) = prefix {
243            updated.decor_mut().set_prefix(p);
244        }
245        if let Some(s) = suffix {
246            updated.decor_mut().set_suffix(s);
247        }
248        *existing_val = updated;
249    } else if !ours.contains_key(key) {
250        if let Some((theirs_key, _)) = theirs.get_key_value(key) {
251            ours.insert_formatted(theirs_key, t_val.clone());
252        } else {
253            ours.insert(key, t_val.clone());
254        }
255    } else {
256        ours.insert(key, t_val.clone());
257    }
258}
259
260fn merge_toml_inline_tables(
261    base: Option<&InlineTable>,
262    ours: &mut InlineTable,
263    theirs: &InlineTable,
264    prefix: &str,
265    conflicts: &mut Vec<StructuralConflict>,
266) {
267    let mut keys = Vec::new();
268    if let Some(b) = base {
269        for (k, _) in b.iter() {
270            if !keys.iter().any(|existing| existing == k) {
271                keys.push(k.to_string());
272            }
273        }
274    }
275    for (k, _) in ours.iter() {
276        if !keys.iter().any(|existing| existing == k) {
277            keys.push(k.to_string());
278        }
279    }
280    for (k, _) in theirs.iter() {
281        if !keys.iter().any(|existing| existing == k) {
282            keys.push(k.to_string());
283        }
284    }
285
286    for k in keys {
287        let key_path = format!("{prefix}.{k}");
288        let b_val = base.and_then(|t| t.get(&k));
289        let o_val = ours.get(&k).cloned();
290        let t_val = theirs.get(&k);
291
292        if let (Some(Value::InlineTable(_)), Some(Value::InlineTable(t_sub))) =
293            (o_val.as_ref(), t_val)
294            && b_val.is_none_or(|v| matches!(v, Value::InlineTable(_)))
295            && let Some(Value::InlineTable(o_sub)) = ours.get_mut(&k)
296        {
297            let b_sub = match b_val {
298                Some(Value::InlineTable(bi)) => Some(bi),
299                _ => None,
300            };
301            merge_toml_inline_tables(b_sub, o_sub, t_sub, &key_path, conflicts);
302            continue;
303        }
304
305        let b_eq_t = opt_toml_values_equal(b_val, t_val);
306        let b_eq_o = opt_toml_values_equal(b_val, o_val.as_ref());
307        let o_eq_t = opt_toml_values_equal(o_val.as_ref(), t_val);
308
309        if b_eq_t || o_eq_t {
310            // Keep `ours`
311        } else if b_eq_o {
312            match t_val {
313                Some(tv) => {
314                    ours.insert(&k, tv.clone());
315                }
316                None => {
317                    ours.remove(&k);
318                }
319            }
320        } else {
321            conflicts.push(StructuralConflict {
322                key_path,
323                base: b_val
324                    .map(|v| v.to_string().trim().to_string())
325                    .unwrap_or_else(|| "<none>".to_string()),
326                ours: o_val
327                    .as_ref()
328                    .map(|v| v.to_string().trim().to_string())
329                    .unwrap_or_else(|| "<deleted>".to_string()),
330                theirs: t_val
331                    .map(|v| v.to_string().trim().to_string())
332                    .unwrap_or_else(|| "<deleted>".to_string()),
333            });
334        }
335    }
336}
337
338fn opt_toml_items_equal(a: Option<&Item>, b: Option<&Item>) -> bool {
339    match (a, b) {
340        (None, None) => true,
341        (Some(ia), Some(ib)) => toml_items_equal(ia, ib),
342        _ => false,
343    }
344}
345
346fn toml_items_equal(a: &Item, b: &Item) -> bool {
347    match (a, b) {
348        (Item::None, Item::None) => true,
349        (Item::Value(va), Item::Value(vb)) => toml_values_equal(va, vb),
350        (Item::Table(ta), Item::Table(tb)) => toml_tables_equal(ta, tb),
351        (Item::Table(ta), Item::Value(Value::InlineTable(ib)))
352        | (Item::Value(Value::InlineTable(ib)), Item::Table(ta)) => {
353            toml_tables_equal(ta, &ib.clone().into_table())
354        }
355        (Item::ArrayOfTables(aa), Item::ArrayOfTables(ab)) => toml_array_of_tables_equal(aa, ab),
356        _ => false,
357    }
358}
359
360fn opt_toml_values_equal(a: Option<&Value>, b: Option<&Value>) -> bool {
361    match (a, b) {
362        (None, None) => true,
363        (Some(va), Some(vb)) => toml_values_equal(va, vb),
364        _ => false,
365    }
366}
367
368fn toml_values_equal(a: &Value, b: &Value) -> bool {
369    match (a, b) {
370        (Value::String(sa), Value::String(sb)) => sa.value() == sb.value(),
371        (Value::Integer(ia), Value::Integer(ib)) => ia.value() == ib.value(),
372        (Value::Float(fa), Value::Float(fb)) => fa.value().to_bits() == fb.value().to_bits(),
373        (Value::Boolean(ba), Value::Boolean(bb)) => ba.value() == bb.value(),
374        (Value::Datetime(da), Value::Datetime(db)) => da.value() == db.value(),
375        (Value::Array(aa), Value::Array(ab)) => toml_arrays_equal(aa, ab),
376        (Value::InlineTable(ta), Value::InlineTable(tb)) => toml_inline_tables_equal(ta, tb),
377        _ => false,
378    }
379}
380
381fn toml_arrays_equal(a: &Array, b: &Array) -> bool {
382    a.len() == b.len()
383        && a.iter()
384            .zip(b.iter())
385            .all(|(va, vb)| toml_values_equal(va, vb))
386}
387
388fn toml_inline_tables_equal(a: &InlineTable, b: &InlineTable) -> bool {
389    if a.len() != b.len() {
390        return false;
391    }
392    a.iter()
393        .all(|(k, va)| b.get(k).is_some_and(|vb| toml_values_equal(va, vb)))
394}
395
396fn toml_tables_equal(a: &Table, b: &Table) -> bool {
397    let a_keys: Vec<_> = a.iter().filter(|(_, i)| !i.is_none()).collect();
398    let b_keys: Vec<_> = b.iter().filter(|(_, i)| !i.is_none()).collect();
399    if a_keys.len() != b_keys.len() {
400        return false;
401    }
402    a_keys.iter().all(|(k, ia)| {
403        b.get(k)
404            .filter(|ib| !ib.is_none())
405            .is_some_and(|ib| toml_items_equal(ia, ib))
406    })
407}
408
409fn toml_array_of_tables_equal(a: &ArrayOfTables, b: &ArrayOfTables) -> bool {
410    a.len() == b.len()
411        && a.iter()
412            .zip(b.iter())
413            .all(|(ta, tb)| toml_tables_equal(ta, tb))
414}
415
416fn format_opt_toml_item(item: Option<&Item>) -> String {
417    match item {
418        None | Some(Item::None) => "<none>".to_string(),
419        Some(Item::Value(v)) => v.to_string().trim().to_string(),
420        Some(Item::Table(t)) => t.to_string().trim().to_string(),
421        Some(Item::ArrayOfTables(a)) => format!("[({} array of tables)]", a.len()),
422    }
423}
424
425pub fn merge_json_structural(base: &str, ours: &str, theirs: &str) -> Result<String, String> {
426    match try_merge_json_structural(base, ours, theirs) {
427        Some(result) => result,
428        None => Err("at least one input document is not valid JSON".to_string()),
429    }
430}
431
432fn try_merge_json_structural(
433    base: &str,
434    ours: &str,
435    theirs: &str,
436) -> Option<Result<String, String>> {
437    let base_val: serde_json::Value = serde_json::from_str(base).ok()?;
438    let ours_val: serde_json::Value = serde_json::from_str(ours).ok()?;
439    let theirs_val: serde_json::Value = serde_json::from_str(theirs).ok()?;
440
441    let mut conflicts = Vec::new();
442    let merged_val = merge_json_values(Some(&base_val), &ours_val, &theirs_val, "", &mut conflicts);
443
444    if conflicts.is_empty() {
445        let mut pretty = serde_json::to_string_pretty(&merged_val).ok()?;
446        pretty.push('\n');
447        Some(Ok(pretty))
448    } else {
449        Some(Err(format_conflicts("JSON", &conflicts)))
450    }
451}
452
453fn merge_json_values(
454    base: Option<&serde_json::Value>,
455    ours: &serde_json::Value,
456    theirs: &serde_json::Value,
457    prefix: &str,
458    conflicts: &mut Vec<StructuralConflict>,
459) -> serde_json::Value {
460    if let (serde_json::Value::Object(o_map), serde_json::Value::Object(t_map)) = (ours, theirs)
461        && base.is_none_or(|b| b.is_object())
462    {
463        let b_map = base.and_then(|b| b.as_object());
464        let mut keys = Vec::new();
465        if let Some(bm) = b_map {
466            for k in bm.keys() {
467                if !keys.contains(k) {
468                    keys.push(k.clone());
469                }
470            }
471        }
472        for k in o_map.keys() {
473            if !keys.contains(k) {
474                keys.push(k.clone());
475            }
476        }
477        for k in t_map.keys() {
478            if !keys.contains(k) {
479                keys.push(k.clone());
480            }
481        }
482
483        let mut merged = serde_json::Map::new();
484        for k in keys {
485            let key_path = if prefix.is_empty() {
486                k.clone()
487            } else {
488                format!("{prefix}.{k}")
489            };
490
491            let b_v = b_map.and_then(|m| m.get(&k));
492            let o_v = o_map.get(&k);
493            let t_v = t_map.get(&k);
494
495            if let (Some(o_sub), Some(t_sub)) = (o_v, t_v)
496                && o_sub.is_object()
497                && t_sub.is_object()
498                && b_v.is_none_or(|bv| bv.is_object())
499            {
500                let sub = merge_json_values(b_v, o_sub, t_sub, &key_path, conflicts);
501                merged.insert(k, sub);
502                continue;
503            }
504
505            if b_v == t_v {
506                if let Some(ov) = o_v {
507                    merged.insert(k, ov.clone());
508                }
509            } else if b_v == o_v {
510                if let Some(tv) = t_v {
511                    merged.insert(k, tv.clone());
512                }
513            } else if o_v == t_v {
514                if let Some(ov) = o_v {
515                    merged.insert(k, ov.clone());
516                }
517            } else {
518                conflicts.push(StructuralConflict {
519                    key_path,
520                    base: format_opt_json(b_v),
521                    ours: format_opt_json(o_v),
522                    theirs: format_opt_json(t_v),
523                });
524            }
525        }
526
527        return serde_json::Value::Object(merged);
528    }
529
530    if base == Some(theirs) {
531        ours.clone()
532    } else if base == Some(ours) {
533        theirs.clone()
534    } else if ours == theirs {
535        ours.clone()
536    } else {
537        let key_path = if prefix.is_empty() {
538            "$".to_string()
539        } else {
540            prefix.to_string()
541        };
542        conflicts.push(StructuralConflict {
543            key_path,
544            base: format_opt_json(base),
545            ours: format_opt_json(Some(ours)),
546            theirs: format_opt_json(Some(theirs)),
547        });
548        ours.clone()
549    }
550}
551
552fn format_opt_json(val: Option<&serde_json::Value>) -> String {
553    match val {
554        None => "<none>".to_string(),
555        Some(v) => serde_json::to_string(v).unwrap_or_else(|_| v.to_string()),
556    }
557}
558
559pub fn merge_yaml_structural(base: &str, ours: &str, theirs: &str) -> Result<String, String> {
560    match try_merge_yaml_structural(base, ours, theirs) {
561        Some(result) => result,
562        None => Err("at least one input document is not valid block YAML".to_string()),
563    }
564}
565
566#[derive(Debug, Clone, PartialEq, Eq)]
567struct YamlMap {
568    entries: Vec<YamlEntry>,
569    trailing_lines: Vec<String>,
570}
571
572#[derive(Debug, Clone, PartialEq, Eq)]
573struct YamlEntry {
574    leading_trivia: Vec<String>,
575    key: String,
576    header_line: String,
577    body: YamlNode,
578}
579
580#[derive(Debug, Clone, PartialEq, Eq)]
581enum YamlNode {
582    Scalar(String),
583    Mapping(YamlMap),
584    Sequence(Vec<YamlSeqItem>),
585    RawBlock(Vec<String>),
586}
587
588#[derive(Debug, Clone, PartialEq, Eq)]
589struct YamlSeqItem {
590    leading_trivia: Vec<String>,
591    lines: Vec<String>,
592    canonical: String,
593}
594
595fn try_merge_yaml_structural(
596    base: &str,
597    ours: &str,
598    theirs: &str,
599) -> Option<Result<String, String>> {
600    let base_map = parse_yaml_map_root(base)?;
601    let ours_map = parse_yaml_map_root(ours)?;
602    let theirs_map = parse_yaml_map_root(theirs)?;
603
604    let mut conflicts = Vec::new();
605    let merged_map = merge_yaml_maps(Some(&base_map), &ours_map, &theirs_map, "", &mut conflicts);
606
607    if conflicts.is_empty() {
608        let mut rendered = String::new();
609        emit_yaml_map(&merged_map, &mut rendered);
610        if !rendered.ends_with('\n') && !rendered.is_empty() {
611            rendered.push('\n');
612        }
613        Some(Ok(rendered))
614    } else {
615        Some(Err(format_conflicts("YAML", &conflicts)))
616    }
617}
618
619fn parse_yaml_map_root(input: &str) -> Option<YamlMap> {
620    let lines: Vec<String> = input.lines().map(str::to_string).collect();
621    if lines.is_empty() {
622        return None;
623    }
624    parse_yaml_map_slice(&lines, 0)
625}
626
627fn line_indent(line: &str) -> usize {
628    line.bytes().take_while(|b| *b == b' ').count()
629}
630
631fn is_trivia_line(line: &str) -> bool {
632    let trimmed = line.trim();
633    trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "---"
634}
635
636fn split_yaml_key_value(trimmed: &str) -> Option<(&str, &str)> {
637    if trimmed.starts_with("- ") || trimmed == "-" || trimmed.starts_with('#') {
638        return None;
639    }
640    let colon_idx = trimmed.find(':')?;
641    let key = trimmed[..colon_idx].trim();
642    if key.is_empty() || key.contains(' ') || key.contains('\t') {
643        return None;
644    }
645    let after = &trimmed[colon_idx + 1..];
646    if !after.is_empty() && !after.starts_with(' ') && !after.starts_with('\t') {
647        return None;
648    }
649    let val_no_comment = strip_yaml_inline_comment(after.trim());
650    Some((key, val_no_comment))
651}
652
653fn strip_yaml_inline_comment(val: &str) -> &str {
654    let mut in_single = false;
655    let mut in_double = false;
656    let bytes = val.as_bytes();
657    for i in 0..bytes.len() {
658        match bytes[i] {
659            b'\'' if !in_double => in_single = !in_single,
660            b'"' if !in_single => in_double = !in_double,
661            b'#' if !in_single && !in_double && (i == 0 || bytes[i - 1] == b' ') => {
662                return val[..i].trim_end();
663            }
664            _ => {}
665        }
666    }
667    val.trim()
668}
669
670fn parse_yaml_map_slice(lines: &[String], expected_indent: usize) -> Option<YamlMap> {
671    let mut entries = Vec::new();
672    let mut pending_trivia = Vec::new();
673    let mut idx = 0;
674
675    while idx < lines.len() {
676        let line = &lines[idx];
677        if is_trivia_line(line) {
678            pending_trivia.push(line.clone());
679            idx += 1;
680            continue;
681        }
682        if line.starts_with('\t') {
683            return None;
684        }
685
686        let indent = line_indent(line);
687        if indent != expected_indent {
688            return None;
689        }
690
691        let trimmed = &line[indent..];
692        let (key, inline_val) = split_yaml_key_value(trimmed)?;
693        let header_line = line.clone();
694        let leading_trivia = std::mem::take(&mut pending_trivia);
695        idx += 1;
696
697        let child_start = idx;
698        while idx < lines.len() {
699            let next_line = &lines[idx];
700            if is_trivia_line(next_line) {
701                let mut lookahead = idx + 1;
702                let mut has_deeper_child = false;
703                while lookahead < lines.len() {
704                    if !is_trivia_line(&lines[lookahead]) {
705                        if line_indent(&lines[lookahead]) > expected_indent {
706                            has_deeper_child = true;
707                        }
708                        break;
709                    }
710                    lookahead += 1;
711                }
712                if has_deeper_child {
713                    idx += 1;
714                    continue;
715                } else {
716                    break;
717                }
718            }
719            if line_indent(next_line) > expected_indent {
720                idx += 1;
721            } else {
722                break;
723            }
724        }
725
726        let child_lines = &lines[child_start..idx];
727        let body = if child_lines.is_empty() {
728            YamlNode::Scalar(inline_val.to_string())
729        } else if inline_val == "|" || inline_val == ">" || inline_val == "|-" || inline_val == ">-"
730        {
731            YamlNode::RawBlock(child_lines.to_vec())
732        } else {
733            let first_non_trivia = child_lines.iter().find(|l| !is_trivia_line(l))?;
734            let child_indent = line_indent(first_non_trivia);
735            let child_trimmed = &first_non_trivia[child_indent..];
736            if child_trimmed.starts_with("- ") || child_trimmed == "-" {
737                let seq = parse_yaml_sequence_slice(child_lines, child_indent)?;
738                YamlNode::Sequence(seq)
739            } else if let Some(sub_map) = parse_yaml_map_slice(child_lines, child_indent) {
740                YamlNode::Mapping(sub_map)
741            } else {
742                YamlNode::RawBlock(child_lines.to_vec())
743            }
744        };
745
746        entries.push(YamlEntry {
747            leading_trivia,
748            key: key.to_string(),
749            header_line,
750            body,
751        });
752    }
753
754    if entries.is_empty() {
755        return None;
756    }
757
758    Some(YamlMap {
759        entries,
760        trailing_lines: pending_trivia,
761    })
762}
763
764fn parse_yaml_sequence_slice(lines: &[String], seq_indent: usize) -> Option<Vec<YamlSeqItem>> {
765    let mut items = Vec::new();
766    let mut pending_trivia = Vec::new();
767    let mut idx = 0;
768
769    while idx < lines.len() {
770        let line = &lines[idx];
771        if is_trivia_line(line) {
772            pending_trivia.push(line.clone());
773            idx += 1;
774            continue;
775        }
776        let indent = line_indent(line);
777        if indent != seq_indent {
778            return None;
779        }
780        let trimmed = &line[indent..];
781        if !trimmed.starts_with("- ") && trimmed != "-" {
782            return None;
783        }
784
785        let leading_trivia = std::mem::take(&mut pending_trivia);
786        let mut item_lines = vec![line.clone()];
787        idx += 1;
788
789        while idx < lines.len() {
790            let next_line = &lines[idx];
791            if is_trivia_line(next_line) {
792                let mut lookahead = idx + 1;
793                let mut continues_item = false;
794                while lookahead < lines.len() {
795                    if !is_trivia_line(&lines[lookahead]) {
796                        if line_indent(&lines[lookahead]) > seq_indent {
797                            continues_item = true;
798                        }
799                        break;
800                    }
801                    lookahead += 1;
802                }
803                if continues_item {
804                    item_lines.push(next_line.clone());
805                    idx += 1;
806                    continue;
807                } else {
808                    break;
809                }
810            }
811            if line_indent(next_line) > seq_indent {
812                item_lines.push(next_line.clone());
813                idx += 1;
814            } else {
815                break;
816            }
817        }
818
819        let canonical = item_lines
820            .iter()
821            .filter(|l| !is_trivia_line(l))
822            .map(|l| strip_yaml_inline_comment(l.trim()))
823            .collect::<Vec<_>>()
824            .join("\n");
825
826        items.push(YamlSeqItem {
827            leading_trivia,
828            lines: item_lines,
829            canonical,
830        });
831    }
832
833    if items.is_empty() { None } else { Some(items) }
834}
835
836fn yaml_entry_semantically_equal(a: &YamlEntry, b: &YamlEntry) -> bool {
837    if a.key != b.key {
838        return false;
839    }
840    yaml_node_semantically_equal(&a.body, &b.body)
841}
842
843fn yaml_node_semantically_equal(a: &YamlNode, b: &YamlNode) -> bool {
844    match (a, b) {
845        (YamlNode::Scalar(sa), YamlNode::Scalar(sb)) => sa == sb,
846        (YamlNode::Mapping(ma), YamlNode::Mapping(mb)) => yaml_maps_semantically_equal(ma, mb),
847        (YamlNode::Sequence(sa), YamlNode::Sequence(sb)) => {
848            sa.len() == sb.len()
849                && sa
850                    .iter()
851                    .zip(sb.iter())
852                    .all(|(ia, ib)| ia.canonical == ib.canonical)
853        }
854        (YamlNode::RawBlock(ra), YamlNode::RawBlock(rb)) => ra == rb,
855        _ => false,
856    }
857}
858
859fn yaml_maps_semantically_equal(a: &YamlMap, b: &YamlMap) -> bool {
860    if a.entries.len() != b.entries.len() {
861        return false;
862    }
863    a.entries.iter().all(|ea| {
864        b.entries
865            .iter()
866            .find(|eb| eb.key == ea.key)
867            .is_some_and(|eb| yaml_entry_semantically_equal(ea, eb))
868    })
869}
870
871fn merge_yaml_maps(
872    base: Option<&YamlMap>,
873    ours: &YamlMap,
874    theirs: &YamlMap,
875    prefix: &str,
876    conflicts: &mut Vec<StructuralConflict>,
877) -> YamlMap {
878    let mut keys = Vec::new();
879    if let Some(b) = base {
880        for e in &b.entries {
881            if !keys.contains(&e.key) {
882                keys.push(e.key.clone());
883            }
884        }
885    }
886    for e in &ours.entries {
887        if !keys.contains(&e.key) {
888            keys.push(e.key.clone());
889        }
890    }
891    for e in &theirs.entries {
892        if !keys.contains(&e.key) {
893            keys.push(e.key.clone());
894        }
895    }
896
897    let mut merged_entries = Vec::new();
898    for k in keys {
899        let key_path = if prefix.is_empty() {
900            k.clone()
901        } else {
902            format!("{prefix}.{k}")
903        };
904
905        let b_e = base.and_then(|m| m.entries.iter().find(|e| e.key == k));
906        let o_e = ours.entries.iter().find(|e| e.key == k);
907        let t_e = theirs.entries.iter().find(|e| e.key == k);
908
909        if let (Some(oe), Some(te)) = (o_e, t_e)
910            && let (YamlNode::Mapping(o_sub), YamlNode::Mapping(t_sub)) = (&oe.body, &te.body)
911            && b_e.is_none_or(|be| matches!(be.body, YamlNode::Mapping(_)))
912        {
913            let b_sub = match b_e.map(|be| &be.body) {
914                Some(YamlNode::Mapping(bm)) => Some(bm),
915                _ => None,
916            };
917            let merged_sub = merge_yaml_maps(b_sub, o_sub, t_sub, &key_path, conflicts);
918            merged_entries.push(YamlEntry {
919                leading_trivia: oe.leading_trivia.clone(),
920                key: k,
921                header_line: oe.header_line.clone(),
922                body: YamlNode::Mapping(merged_sub),
923            });
924            continue;
925        }
926
927        if let (Some(oe), Some(te)) = (o_e, t_e)
928            && let (YamlNode::Sequence(o_seq), YamlNode::Sequence(t_seq)) = (&oe.body, &te.body)
929            && b_e.is_none_or(|be| matches!(be.body, YamlNode::Sequence(_)))
930        {
931            let b_seq = match b_e.map(|be| &be.body) {
932                Some(YamlNode::Sequence(bs)) => Some(bs.as_slice()),
933                _ => None,
934            };
935            let merged_seq = merge_yaml_sequences(b_seq, o_seq, t_seq);
936            merged_entries.push(YamlEntry {
937                leading_trivia: oe.leading_trivia.clone(),
938                key: k,
939                header_line: oe.header_line.clone(),
940                body: YamlNode::Sequence(merged_seq),
941            });
942            continue;
943        }
944
945        let b_eq_t = opt_yaml_entries_equal(b_e, t_e);
946        let b_eq_o = opt_yaml_entries_equal(b_e, o_e);
947        let o_eq_t = opt_yaml_entries_equal(o_e, t_e);
948
949        if b_eq_t {
950            if let Some(oe) = o_e {
951                merged_entries.push(oe.clone());
952            }
953        } else if b_eq_o {
954            if let Some(te) = t_e {
955                let mut adopted = te.clone();
956                if let Some(oe) = o_e
957                    && !oe.leading_trivia.is_empty()
958                    && adopted.leading_trivia.is_empty()
959                {
960                    adopted.leading_trivia = oe.leading_trivia.clone();
961                }
962                merged_entries.push(adopted);
963            }
964        } else if o_eq_t {
965            if let Some(oe) = o_e {
966                merged_entries.push(oe.clone());
967            }
968        } else {
969            conflicts.push(StructuralConflict {
970                key_path,
971                base: format_opt_yaml_entry(b_e),
972                ours: format_opt_yaml_entry(o_e),
973                theirs: format_opt_yaml_entry(t_e),
974            });
975        }
976    }
977
978    YamlMap {
979        entries: merged_entries,
980        trailing_lines: ours.trailing_lines.clone(),
981    }
982}
983
984fn merge_yaml_sequences(
985    base: Option<&[YamlSeqItem]>,
986    ours: &[YamlSeqItem],
987    theirs: &[YamlSeqItem],
988) -> Vec<YamlSeqItem> {
989    let base_set: Vec<&str> = base
990        .unwrap_or(&[])
991        .iter()
992        .map(|i| i.canonical.as_str())
993        .collect();
994    let theirs_set: Vec<&str> = theirs.iter().map(|i| i.canonical.as_str()).collect();
995    let ours_set: Vec<&str> = ours.iter().map(|i| i.canonical.as_str()).collect();
996
997    let mut merged: Vec<YamlSeqItem> = Vec::new();
998    for t_item in theirs {
999        let was_in_base = base_set.contains(&t_item.canonical.as_str());
1000        let still_in_ours = ours_set.contains(&t_item.canonical.as_str());
1001        if was_in_base && !still_in_ours {
1002            continue;
1003        }
1004        if let Some(o_existing) = ours.iter().find(|o| o.canonical == t_item.canonical) {
1005            merged.push(o_existing.clone());
1006        } else {
1007            merged.push(t_item.clone());
1008        }
1009    }
1010
1011    for o_item in ours {
1012        let was_in_base = base_set.contains(&o_item.canonical.as_str());
1013        let in_theirs = theirs_set.contains(&o_item.canonical.as_str());
1014        if !was_in_base && !in_theirs {
1015            merged.push(o_item.clone());
1016        }
1017    }
1018
1019    merged
1020}
1021
1022fn opt_yaml_entries_equal(a: Option<&YamlEntry>, b: Option<&YamlEntry>) -> bool {
1023    match (a, b) {
1024        (None, None) => true,
1025        (Some(ea), Some(eb)) => yaml_entry_semantically_equal(ea, eb),
1026        _ => false,
1027    }
1028}
1029
1030fn format_opt_yaml_entry(entry: Option<&YamlEntry>) -> String {
1031    match entry {
1032        None => "<none>".to_string(),
1033        Some(e) => match &e.body {
1034            YamlNode::Scalar(s) => s.clone(),
1035            _ => {
1036                let mut buf = String::new();
1037                emit_yaml_entry(e, &mut buf);
1038                buf.trim().to_string()
1039            }
1040        },
1041    }
1042}
1043
1044fn emit_yaml_map(map: &YamlMap, out: &mut String) {
1045    for entry in &map.entries {
1046        emit_yaml_entry(entry, out);
1047    }
1048    for line in &map.trailing_lines {
1049        out.push_str(line);
1050        out.push('\n');
1051    }
1052}
1053
1054fn emit_yaml_entry(entry: &YamlEntry, out: &mut String) {
1055    for t in &entry.leading_trivia {
1056        out.push_str(t);
1057        out.push('\n');
1058    }
1059    out.push_str(&entry.header_line);
1060    out.push('\n');
1061    match &entry.body {
1062        YamlNode::Scalar(_) => {}
1063        YamlNode::Mapping(sub) => emit_yaml_map(sub, out),
1064        YamlNode::Sequence(items) => {
1065            for item in items {
1066                for t in &item.leading_trivia {
1067                    out.push_str(t);
1068                    out.push('\n');
1069                }
1070                for l in &item.lines {
1071                    out.push_str(l);
1072                    out.push('\n');
1073                }
1074            }
1075        }
1076        YamlNode::RawBlock(lines) => {
1077            for l in lines {
1078                out.push_str(l);
1079                out.push('\n');
1080            }
1081        }
1082    }
1083}