Skip to main content

ical/tree/
merge.rs

1//! # Three-way merge
2//!
3//! Reconcile two divergent edits of a calendar against their common base.
4//!
5//! [`IcalMerge::merge`] is the unit a synchronisation engine needs: given a
6//! base calendar and two calendars derived from it, it reports what each side
7//! changed relative to the base, and builds one merged calendar. Never
8//! last-writer-wins: a field only one side touched is taken from that side, and
9//! a field both sides touched is a conflict, reported so a caller can resolve
10//! it differently.
11//!
12//! The merged calendar starts as a clone of the left one, so the left side's
13//! bytes are there exactly as they were, folds included. The right side's
14//! actions are then replayed onto it line by line, so every line the right side
15//! did not touch keeps its bytes too.
16//!
17//! ## What is matched with what
18//!
19//! A component is matched across the three calendars by its `UID` and its
20//! `RECURRENCE-ID`, which is the identity iCalendar itself uses (RFC 5545
21//! 3.8.4.7, 3.8.4.4): an override of one instance is never confused with the
22//! series it belongs to, however the two are ordered in the file. A component
23//! carrying no `UID` (a `VALARM`, a `STANDARD`, a `VTIMEZONE` observance) is
24//! matched by its position among its same-named siblings.
25//!
26//! Inside a matched component, properties are matched by name, then by
27//! equality, then by position. iCalendar has no `PID`, so there is nothing
28//! finer to go on.
29//!
30//! ## What counts as a change
31//!
32//! A whole property added or removed, a value changed, one item of a list
33//! value added or removed, a parameter added, removed or changed. List items
34//! merge as a set, both sides' additions and removals applying, so they never
35//! collide.
36//!
37//! ## The three ways a merge can conflict
38//!
39//! **Divergence.** Both sides changed the same field. The left side's outcome
40//! is kept, except where a removal meets an update: there the update wins,
41//! because keeping data beats losing it silently.
42//!
43//! **Recurrence.** One side changed the series (its `RRULE`, `RDATE`, `EXDATE`
44//! or start) while the other changed one instance of it. Neither is wrong and
45//! both survive, but a rule that moved may have moved the ground the override
46//! stood on, so it is reported.
47//!
48//! **Authority.** An attendee may not rewrite what the organiser owns (RFC
49//! 5546 3.2). Set [`right_speaks_for`](IcalMerge::right_speaks_for) to the
50//! calendar address the right side edits as, and a right-side change to an
51//! organiser-owned property of a component someone else organises is refused
52//! and reported. Left unset, no such claim is made and nothing is refused on
53//! this ground.
54
55use alloc::{
56    borrow::{Cow, ToOwned},
57    format,
58    string::{String, ToString},
59    vec::Vec,
60};
61
62use crate::{
63    component::IcalComponentKind,
64    param::IcalParam,
65    prop::{IcalPropKind, IcalPropName},
66    tree::{
67        cst::{IcalCst, IcalItem},
68        line::IcalLine,
69        value::IcalValueCursor,
70    },
71    value::IcalValue,
72    version::IcalVersion,
73};
74
75/// A three-way merge waiting to run.
76///
77/// The three calendars, plus who the right side speaks for. See the module
78/// documentation for the matching, granularity and conflict rules.
79pub struct IcalMerge<'m, 'a> {
80    /// The common ancestor both sides were derived from.
81    pub base: &'m IcalCst<'a>,
82    /// One side. Its bytes are the ones the merged calendar keeps.
83    pub left: &'m IcalCst<'a>,
84    /// The other side. Its changes are replayed onto the left's bytes.
85    pub right: &'m IcalCst<'a>,
86    /// The calendar address the right side edits on behalf of, when it is an
87    /// attendee rather than the organiser of what it changed. Unset means no
88    /// claim, and no change is refused for want of authority.
89    pub right_speaks_for: Option<Cow<'a, str>>,
90}
91
92impl<'a> IcalMerge<'_, 'a> {
93    /// Run the merge.
94    pub fn merge(self) -> IcalMergeReport<'a> {
95        let version = self.base.version();
96
97        let base = nodes(self.base);
98        let left = nodes(self.left);
99        let right = nodes(self.right);
100
101        let left_ops = diff(&base, &left, version);
102        let right_ops = diff(&base, &right, version);
103
104        let mut merged = self.left.clone();
105        let mut conflicts = Vec::new();
106
107        for op in &right_ops {
108            let verdict = self.judge(op, &left_ops, &base, &left);
109
110            if verdict.applies {
111                apply(&mut merged, op, self.right);
112            }
113
114            if let Some(reason) = verdict.reason {
115                conflicts.push(IcalMergeConflict {
116                    right: op.action.clone(),
117                    reason,
118                });
119            }
120        }
121
122        IcalMergeReport {
123            merged,
124            left: left_ops.into_iter().map(|op| op.action).collect(),
125            right: right_ops.into_iter().map(|op| op.action).collect(),
126            conflicts,
127        }
128    }
129
130    /// Whether a right-side action applies, and what to report about it.
131    fn judge(
132        &self,
133        op: &Op<'a>,
134        left_ops: &[Op<'a>],
135        base: &[Node<'_, 'a>],
136        left: &[Node<'_, 'a>],
137    ) -> Verdict<'a> {
138        if let Some(speaker) = &self.right_speaks_for
139            && op.organiser_owned
140            && organiser_of(op.path(), base, left).is_some_and(|held| held != *speaker)
141        {
142            return Verdict {
143                applies: false,
144                reason: Some(IcalMergeReason::Authority),
145            };
146        }
147
148        if let Some(collision) = left_ops.iter().find(|left| collides(left, op)) {
149            // NOTE: A removal against an update is not a stand-off: one side
150            // says the data is gone and the other says what it now is. The
151            // update survives whichever side it came from, since keeping data
152            // beats losing it silently, and the collision is reported either
153            // way.
154            let applies = collision.action.is_removal() && !op.action.is_removal();
155
156            return Verdict {
157                applies,
158                reason: Some(IcalMergeReason::Divergent(collision.action.clone())),
159            };
160        }
161
162        // NOTE: A recurrence conflict refuses nothing. Both sides said
163        // something true about different parts of one series, and the caller is
164        // told only because one may have moved the ground the other stood on.
165        Verdict {
166            applies: true,
167            reason: left_ops
168                .iter()
169                .find(|left| across_the_series(left, op))
170                .map(|left| IcalMergeReason::Recurrence(left.action.clone())),
171        }
172    }
173}
174
175/// What a merge decided about one right-side action.
176struct Verdict<'a> {
177    /// Whether the action lands in the merged calendar.
178    applies: bool,
179    /// What to report about it, if anything.
180    reason: Option<IcalMergeReason<'a>>,
181}
182
183/// The outcome of a three-way merge.
184#[derive(Clone, Debug)]
185pub struct IcalMergeReport<'a> {
186    /// The merged calendar: the left one with the right side's applicable
187    /// actions replayed onto it, line by line.
188    pub merged: IcalCst<'a>,
189    /// What the left calendar changed relative to the base.
190    pub left: Vec<IcalMergeAction<'a>>,
191    /// What the right calendar changed relative to the base.
192    pub right: Vec<IcalMergeAction<'a>>,
193    /// The right-side actions that did not simply apply, and why.
194    pub conflicts: Vec<IcalMergeConflict<'a>>,
195}
196
197/// A right-side action that did not simply apply.
198#[derive(Clone, Debug, PartialEq, Eq)]
199pub struct IcalMergeConflict<'a> {
200    /// The action the right side wanted.
201    pub right: IcalMergeAction<'a>,
202    /// Why it did not simply apply.
203    pub reason: IcalMergeReason<'a>,
204}
205
206/// Why a right-side action did not simply apply.
207#[derive(Clone, Debug, PartialEq, Eq)]
208pub enum IcalMergeReason<'a> {
209    /// Both sides changed the same field. The merged calendar holds the left
210    /// side's outcome, except where a removal met an update, in which case the
211    /// update was kept whichever side it came from.
212    Divergent(IcalMergeAction<'a>),
213    /// One side changed a series and the other changed one of its instances.
214    /// Both survive in the merged calendar; a rule that moved may have moved
215    /// the ground the override stood on, which is why this is said out loud.
216    Recurrence(IcalMergeAction<'a>),
217    /// The right side does not speak for the organiser of the component, and
218    /// the property is the organiser's to set (RFC 5546 3.2).
219    Authority,
220}
221
222/// One component's address: the steps from the calendar root down to it.
223#[derive(Clone, Debug, Default, PartialEq, Eq)]
224pub struct IcalComponentPath<'a>(pub Vec<IcalComponentStep<'a>>);
225
226/// One step of a component path: a name and the identity that tells it from
227/// its same-named siblings.
228#[derive(Clone, Debug, PartialEq, Eq)]
229pub struct IcalComponentStep<'a> {
230    /// The component name, uppercase.
231    pub name: Cow<'a, str>,
232    /// The `UID`, with the `RECURRENCE-ID` after a solidus when the component
233    /// overrides one instance; the position among same-named siblings when the
234    /// component carries no `UID`.
235    pub key: Cow<'a, str>,
236}
237
238/// One property's address: the component holding it, its name, and which of
239/// that component's same-named properties it is.
240#[derive(Clone, Debug, PartialEq, Eq)]
241pub struct IcalPropPath<'a> {
242    /// The component the property belongs to.
243    pub component: IcalComponentPath<'a>,
244    /// The property name as written.
245    pub name: Cow<'a, str>,
246    /// The position among the component's properties of that name.
247    pub index: usize,
248}
249
250/// One change a side made relative to the base.
251#[derive(Clone, Debug, PartialEq, Eq)]
252pub enum IcalMergeAction<'a> {
253    /// A component the side added.
254    ComponentAdded {
255        /// Where it was added.
256        at: IcalComponentPath<'a>,
257    },
258    /// A component the side removed.
259    ComponentRemoved {
260        /// What it removed.
261        at: IcalComponentPath<'a>,
262    },
263    /// A property the side added.
264    PropAdded {
265        /// Where it was added.
266        at: IcalPropPath<'a>,
267        /// The added value.
268        value: IcalValue<'a>,
269    },
270    /// A property the side removed.
271    PropRemoved {
272        /// What it removed.
273        at: IcalPropPath<'a>,
274        /// The removed value.
275        value: IcalValue<'a>,
276    },
277    /// A matched property whose value changed.
278    ValueChanged {
279        /// The changed property.
280        at: IcalPropPath<'a>,
281        /// The base value.
282        old: IcalValue<'a>,
283        /// The changed value.
284        new: IcalValue<'a>,
285    },
286    /// One item joined a list value (`CATEGORIES`, `RDATE`, `EXDATE`).
287    ValueItemAdded {
288        /// The changed property.
289        at: IcalPropPath<'a>,
290        /// The added item.
291        item: Cow<'a, str>,
292    },
293    /// One item left a list value.
294    ValueItemRemoved {
295        /// The changed property.
296        at: IcalPropPath<'a>,
297        /// The removed item.
298        item: Cow<'a, str>,
299    },
300    /// A parameter the side added.
301    ParamAdded {
302        /// The changed property.
303        at: IcalPropPath<'a>,
304        /// The added parameter.
305        param: IcalParam<'a>,
306    },
307    /// A parameter the side removed.
308    ParamRemoved {
309        /// The changed property.
310        at: IcalPropPath<'a>,
311        /// The removed parameter.
312        param: IcalParam<'a>,
313    },
314    /// A parameter whose value changed.
315    ParamChanged {
316        /// The changed property.
317        at: IcalPropPath<'a>,
318        /// The base parameter.
319        old: IcalParam<'a>,
320        /// The changed parameter.
321        new: IcalParam<'a>,
322    },
323}
324
325impl IcalMergeAction<'_> {
326    /// Whether the action takes something away.
327    fn is_removal(&self) -> bool {
328        matches!(
329            self,
330            Self::ComponentRemoved { .. }
331                | Self::PropRemoved { .. }
332                | Self::ValueItemRemoved { .. }
333                | Self::ParamRemoved { .. }
334        )
335    }
336}
337
338/// One diffed change, with what the merge needs to route and judge it.
339struct Op<'a> {
340    /// The change itself.
341    action: IcalMergeAction<'a>,
342    /// The field it occupies, at which two sides collide.
343    slot: Slot,
344    /// Whether the property is one only the organiser may set.
345    organiser_owned: bool,
346}
347
348impl<'a> Op<'a> {
349    /// The component the action lands in.
350    fn path(&self) -> &IcalComponentPath<'a> {
351        match &self.action {
352            IcalMergeAction::ComponentAdded { at } | IcalMergeAction::ComponentRemoved { at } => at,
353            IcalMergeAction::PropAdded { at, .. }
354            | IcalMergeAction::PropRemoved { at, .. }
355            | IcalMergeAction::ValueChanged { at, .. }
356            | IcalMergeAction::ValueItemAdded { at, .. }
357            | IcalMergeAction::ValueItemRemoved { at, .. }
358            | IcalMergeAction::ParamAdded { at, .. }
359            | IcalMergeAction::ParamRemoved { at, .. }
360            | IcalMergeAction::ParamChanged { at, .. } => &at.component,
361        }
362    }
363
364    /// The property the action lands on, for the actions that have one.
365    fn prop(&self) -> Option<&IcalPropPath<'a>> {
366        match &self.action {
367            IcalMergeAction::ComponentAdded { .. } | IcalMergeAction::ComponentRemoved { .. } => {
368                None
369            }
370            IcalMergeAction::PropAdded { at, .. }
371            | IcalMergeAction::PropRemoved { at, .. }
372            | IcalMergeAction::ValueChanged { at, .. }
373            | IcalMergeAction::ValueItemAdded { at, .. }
374            | IcalMergeAction::ValueItemRemoved { at, .. }
375            | IcalMergeAction::ParamAdded { at, .. }
376            | IcalMergeAction::ParamRemoved { at, .. }
377            | IcalMergeAction::ParamChanged { at, .. } => Some(at),
378        }
379    }
380}
381
382/// The field of a property an action occupies.
383#[derive(Clone, Debug, PartialEq, Eq)]
384enum Slot {
385    /// The whole component.
386    Component,
387    /// The whole property.
388    Prop,
389    /// The whole value.
390    Value,
391    /// The items of a list value, which merge as a set and never collide.
392    Items,
393    /// One parameter, by name.
394    Param(String),
395}
396
397/// Whether two actions collide on one field.
398fn collides(left: &Op<'_>, right: &Op<'_>) -> bool {
399    if left.path() != right.path() {
400        return false;
401    }
402
403    match (&left.slot, &right.slot) {
404        (Slot::Component, Slot::Component) => true,
405        // NOTE: A component one side removed is a component the other side
406        // cannot usefully edit, so every change inside it collides with the
407        // removal rather than quietly applying to something that is gone.
408        (Slot::Component, _) | (_, Slot::Component) => true,
409        _ if left.prop() != right.prop() => false,
410        (Slot::Items, _) | (_, Slot::Items) => false,
411        (Slot::Param(left), Slot::Param(right)) => left == right,
412        (Slot::Param(_), _) | (_, Slot::Param(_)) => false,
413        _ => true,
414    }
415}
416
417/// Whether one action changed a series and the other one of its instances.
418fn across_the_series(left: &Op<'_>, right: &Op<'_>) -> bool {
419    let (Some(left), Some(right)) = (left.path().0.last(), right.path().0.last()) else {
420        return false;
421    };
422
423    let (Some(left_uid), Some(right_uid)) =
424        (left.key.split('/').next(), right.key.split('/').next())
425    else {
426        return false;
427    };
428
429    // NOTE: Same UID, and exactly one of the two carries a RECURRENCE-ID: one
430    // side is talking about the whole series and the other about one of its
431    // occurrences.
432    left.name == right.name
433        && left_uid == right_uid
434        && left.key.contains('/') != right.key.contains('/')
435}
436
437/// One component of a calendar, with the path that addresses it.
438struct Node<'c, 'a> {
439    /// The path from the calendar root.
440    path: IcalComponentPath<'a>,
441    /// The component itself.
442    cst: &'c IcalCst<'a>,
443}
444
445/// Every component of a calendar, the root first, each with its path.
446fn nodes<'c, 'a>(cst: &'c IcalCst<'a>) -> Vec<Node<'c, 'a>> {
447    let mut out = Vec::new();
448    walk(cst, IcalComponentPath::default(), &mut out);
449    out
450}
451
452/// Collect one component and everything nested in it.
453fn walk<'c, 'a>(cst: &'c IcalCst<'a>, path: IcalComponentPath<'a>, out: &mut Vec<Node<'c, 'a>>) {
454    out.push(Node {
455        path: path.clone(),
456        cst,
457    });
458
459    let mut seen: Vec<(String, usize)> = Vec::new();
460
461    for child in components(cst) {
462        let name = component_name(child);
463        let ordinal = match seen.iter_mut().find(|(held, _)| *held == name) {
464            Some((_, count)) => {
465                *count += 1;
466                *count
467            }
468            None => {
469                seen.push((name.clone(), 0));
470                0
471            }
472        };
473
474        let mut nested = path.clone();
475        nested.0.push(IcalComponentStep {
476            key: Cow::Owned(key(child, ordinal)),
477            name: Cow::Owned(name),
478        });
479
480        walk(child, nested, out);
481    }
482}
483
484/// The components nested directly in one.
485fn components<'c, 'a>(cst: &'c IcalCst<'a>) -> impl Iterator<Item = &'c IcalCst<'a>> {
486    cst.items.iter().filter_map(|item| match item {
487        IcalItem::Component(child) => Some(&**child),
488        _ => None,
489    })
490}
491
492/// A component's name, uppercase.
493fn component_name(cst: &IcalCst<'_>) -> String {
494    cst.begin
495        .as_ref()
496        .map(|begin| begin.raw_value_str().to_ascii_uppercase())
497        .unwrap_or_default()
498}
499
500/// A component's identity among its same-named siblings: its `UID`, with the
501/// `RECURRENCE-ID` after a solidus when it overrides one instance, or its
502/// position when it carries no `UID`.
503fn key(cst: &IcalCst<'_>, ordinal: usize) -> String {
504    let Some(uid) = raw(cst, IcalPropKind::Uid) else {
505        return ordinal.to_string();
506    };
507
508    match raw(cst, IcalPropKind::RecurrenceId) {
509        Some(id) => format!("{uid}/{id}"),
510        None => uid,
511    }
512}
513
514/// The raw text of a component's first property of this kind.
515fn raw(cst: &IcalCst<'_>, kind: IcalPropKind) -> Option<String> {
516    lines(cst)
517        .find(|line| line.name.get().eq_ignore_ascii_case(&kind))
518        .map(|line| line.raw_value_str().into_owned())
519}
520
521/// The property lines of a component, in source order.
522fn lines<'c, 'a>(cst: &'c IcalCst<'a>) -> impl Iterator<Item = &'c IcalLine<'a>> {
523    cst.items.iter().filter_map(|item| match item {
524        IcalItem::Prop(line) => Some(line),
525        _ => None,
526    })
527}
528
529/// The calendar address organising the component an action lands in, read from
530/// the base and, failing that, from the left side.
531fn organiser_of<'a>(
532    path: &IcalComponentPath<'a>,
533    base: &[Node<'_, 'a>],
534    left: &[Node<'_, 'a>],
535) -> Option<String> {
536    base.iter()
537        .chain(left)
538        .find(|node| node.path == *path)
539        .and_then(|node| raw(node.cst, IcalPropKind::Organizer))
540}
541
542/// Whether a whole component is the organiser's to add or remove.
543///
544/// An attendee sets their own alarms on a meeting they were invited to; the
545/// meeting itself is the organiser's.
546fn whole_component_owned(path: &IcalComponentPath<'_>) -> bool {
547    !path
548        .0
549        .last()
550        .is_some_and(|step| matches!(step.name.parse(), Ok(IcalComponentKind::VAlarm)))
551}
552
553/// Whether a property of a scheduled component is one only its organiser may
554/// set (RFC 5546 3.2).
555///
556/// An attendee owns their own `ATTENDEE` line, the transparency they show to
557/// others, their alarms, and anything outside the vocabulary. Everything that
558/// describes the meeting itself is the organiser's.
559fn organiser_owned(component: &IcalComponentPath<'_>, name: &IcalPropName<'_>) -> bool {
560    let scheduled = component.0.last().is_some_and(|step| {
561        matches!(
562            step.name.parse(),
563            Ok(IcalComponentKind::VEvent | IcalComponentKind::VTodo | IcalComponentKind::VJournal)
564        )
565    });
566
567    let IcalPropName::Kind(kind) = name else {
568        return false;
569    };
570
571    scheduled
572        && !matches!(
573            kind,
574            IcalPropKind::Attendee | IcalPropKind::Transp | IcalPropKind::DtStamp
575        )
576}
577
578/// Diff one side against the base: one op per observed change.
579fn diff<'a>(base: &[Node<'_, 'a>], side: &[Node<'_, 'a>], version: IcalVersion) -> Vec<Op<'a>> {
580    let mut ops = Vec::new();
581
582    for node in base {
583        if !side.iter().any(|held| held.path == node.path) && !removed_above(&node.path, side, base)
584        {
585            ops.push(Op {
586                action: IcalMergeAction::ComponentRemoved {
587                    at: node.path.clone(),
588                },
589                slot: Slot::Component,
590                organiser_owned: whole_component_owned(&node.path),
591            });
592        }
593    }
594
595    for node in side {
596        if !base.iter().any(|held| held.path == node.path) && !added_above(&node.path, side, base) {
597            ops.push(Op {
598                action: IcalMergeAction::ComponentAdded {
599                    at: node.path.clone(),
600                },
601                slot: Slot::Component,
602                organiser_owned: whole_component_owned(&node.path),
603            });
604        }
605    }
606
607    for node in base {
608        let Some(held) = side.iter().find(|held| held.path == node.path) else {
609            continue;
610        };
611
612        diff_component(node, held, version, &mut ops);
613    }
614
615    ops
616}
617
618/// Whether an ancestor of this path is itself missing from the side, so the
619/// removal is already reported one level up.
620fn removed_above(
621    path: &IcalComponentPath<'_>,
622    side: &[Node<'_, '_>],
623    base: &[Node<'_, '_>],
624) -> bool {
625    ancestors(path).any(|above| {
626        base.iter().any(|node| node.path == above) && !side.iter().any(|node| node.path == above)
627    })
628}
629
630/// The mirror of [`removed_above`] for an addition.
631fn added_above(path: &IcalComponentPath<'_>, side: &[Node<'_, '_>], base: &[Node<'_, '_>]) -> bool {
632    ancestors(path).any(|above| {
633        side.iter().any(|node| node.path == above) && !base.iter().any(|node| node.path == above)
634    })
635}
636
637/// Every proper ancestor path of a path, nearest first.
638fn ancestors<'p, 'a>(
639    path: &'p IcalComponentPath<'a>,
640) -> impl Iterator<Item = IcalComponentPath<'a>> + 'p {
641    (1..path.0.len()).map(|depth| IcalComponentPath(path.0[..depth].to_vec()))
642}
643
644/// Diff the properties of one matched component pair.
645fn diff_component<'a>(
646    base: &Node<'_, 'a>,
647    side: &Node<'_, 'a>,
648    version: IcalVersion,
649    ops: &mut Vec<Op<'a>>,
650) {
651    let base_props: Vec<&IcalLine<'a>> = lines(base.cst).collect();
652    let side_props: Vec<&IcalLine<'a>> = lines(side.cst).collect();
653
654    let mut names: Vec<String> = Vec::new();
655    for line in base_props.iter().chain(&side_props) {
656        let name = line.name.get().to_ascii_uppercase();
657        if !names.contains(&name) {
658            names.push(name);
659        }
660    }
661
662    for name in names {
663        let of = |lines: &[&IcalLine<'a>]| -> Vec<usize> {
664            lines
665                .iter()
666                .enumerate()
667                .filter(|(_, line)| line.name.get().eq_ignore_ascii_case(&name))
668                .map(|(index, _)| index)
669                .collect()
670        };
671
672        let mut base_free = of(&base_props);
673        let mut side_free = of(&side_props);
674
675        // NOTE: An untouched property pairs with itself before position is
676        // consulted, so adding one line does not renumber every line after it.
677        let mut pairs = Vec::new();
678        let mut b = 0;
679        while b < base_free.len() {
680            let same = side_free.iter().position(|&s| {
681                base_props[base_free[b]].decode(version) == side_props[s].decode(version)
682            });
683
684            match same {
685                Some(s) => pairs.push((base_free.remove(b), side_free.remove(s))),
686                None => b += 1,
687            }
688        }
689
690        while !base_free.is_empty() && !side_free.is_empty() {
691            pairs.push((base_free.remove(0), side_free.remove(0)));
692        }
693
694        for index in base_free {
695            let line = base_props[index];
696            let at = prop_path(&base.path, &base_props, index);
697
698            ops.push(Op {
699                organiser_owned: organiser_owned(&base.path, &decode_name(line)),
700                action: IcalMergeAction::PropRemoved {
701                    value: line.decode(version).value.into_owned(),
702                    at,
703                },
704                slot: Slot::Prop,
705            });
706        }
707
708        for index in side_free {
709            let line = side_props[index];
710            let at = prop_path(&side.path, &side_props, index);
711
712            ops.push(Op {
713                organiser_owned: organiser_owned(&side.path, &decode_name(line)),
714                action: IcalMergeAction::PropAdded {
715                    value: line.decode(version).value.into_owned(),
716                    at,
717                },
718                slot: Slot::Prop,
719            });
720        }
721
722        for (b, s) in pairs {
723            diff_prop(&base.path, &base_props, b, side_props[s], version, ops);
724        }
725    }
726}
727
728/// The name a line decodes to.
729fn decode_name<'a>(line: &IcalLine<'a>) -> IcalPropName<'a> {
730    IcalPropName::from(Cow::Owned(line.name.get().to_owned()))
731}
732
733/// Where a line sits among its component's same-named properties.
734fn prop_path<'a>(
735    component: &IcalComponentPath<'a>,
736    lines: &[&IcalLine<'a>],
737    at: usize,
738) -> IcalPropPath<'a> {
739    let name = lines[at].name.get();
740    let index = lines[..at]
741        .iter()
742        .filter(|held| held.name.get().eq_ignore_ascii_case(name))
743        .count();
744
745    IcalPropPath {
746        component: component.clone(),
747        name: Cow::Owned(name.to_owned()),
748        index,
749    }
750}
751
752/// Diff one matched property pair: its parameters, then its value.
753fn diff_prop<'a>(
754    component: &IcalComponentPath<'a>,
755    lines: &[&IcalLine<'a>],
756    at: usize,
757    side: &IcalLine<'a>,
758    version: IcalVersion,
759    ops: &mut Vec<Op<'a>>,
760) {
761    let base = lines[at];
762    let at = prop_path(component, lines, at);
763    let owned = organiser_owned(component, &decode_name(base));
764
765    let base_prop = base.decode(version);
766    let side_prop = side.decode(version);
767
768    for param in &base_prop.params {
769        let name = param_name(param);
770        let held = side_prop
771            .params
772            .iter()
773            .find(|held| param_name(held) == name);
774
775        let action = match held {
776            None => IcalMergeAction::ParamRemoved {
777                at: at.clone(),
778                param: param.clone().into_owned(),
779            },
780            Some(held) if held != param => IcalMergeAction::ParamChanged {
781                at: at.clone(),
782                old: param.clone().into_owned(),
783                new: held.clone().into_owned(),
784            },
785            Some(_) => continue,
786        };
787
788        ops.push(Op {
789            action,
790            slot: Slot::Param(name),
791            organiser_owned: owned,
792        });
793    }
794
795    for param in &side_prop.params {
796        let name = param_name(param);
797
798        if base_prop.params.iter().any(|held| param_name(held) == name) {
799            continue;
800        }
801
802        ops.push(Op {
803            action: IcalMergeAction::ParamAdded {
804                at: at.clone(),
805                param: param.clone().into_owned(),
806            },
807            slot: Slot::Param(name),
808            organiser_owned: owned,
809        });
810    }
811
812    // NOTE: The decoded values are what is compared, not the raw bytes: a line
813    // that was rewritten without changing what it says has not changed, and the
814    // merged calendar keeps the left side's spelling of it either way.
815    if base_prop.value == side_prop.value {
816        return;
817    }
818
819    match (&base_prop.value, &side_prop.value) {
820        // NOTE: A list is a set: both sides' additions and both sides'
821        // removals apply, so two sides editing one list never collide.
822        (IcalValue::TextList(old), IcalValue::TextList(new)) => {
823            list_ops(&at, &old.0, &new.0, owned, ops)
824        }
825        (IcalValue::DateTimeList(old), IcalValue::DateTimeList(new)) => {
826            list_ops(&at, &old.0, &new.0, owned, ops)
827        }
828        (old, new) => ops.push(Op {
829            action: IcalMergeAction::ValueChanged {
830                at,
831                old: old.clone().into_owned(),
832                new: new.clone().into_owned(),
833            },
834            slot: Slot::Value,
835            organiser_owned: owned,
836        }),
837    }
838}
839
840/// The item-by-item difference between two list values.
841fn list_ops<'a>(
842    at: &IcalPropPath<'a>,
843    old: &[Cow<'_, str>],
844    new: &[Cow<'_, str>],
845    owned: bool,
846    ops: &mut Vec<Op<'a>>,
847) {
848    let removed = old.iter().filter(|item| !new.contains(item));
849    let added = new.iter().filter(|item| !old.contains(item));
850
851    for item in removed {
852        ops.push(Op {
853            action: IcalMergeAction::ValueItemRemoved {
854                at: at.clone(),
855                item: Cow::Owned(item.to_string()),
856            },
857            slot: Slot::Items,
858            organiser_owned: owned,
859        });
860    }
861
862    for item in added {
863        ops.push(Op {
864            action: IcalMergeAction::ValueItemAdded {
865                at: at.clone(),
866                item: Cow::Owned(item.to_string()),
867            },
868            slot: Slot::Items,
869            organiser_owned: owned,
870        });
871    }
872}
873
874/// A parameter's name, the key two sides' parameters are matched on.
875fn param_name(param: &IcalParam<'_>) -> String {
876    match param {
877        IcalParam::Unknown { name, .. } => name.to_ascii_uppercase(),
878        known => known
879            .kind()
880            .map(|kind| kind.to_ascii_uppercase())
881            .unwrap_or_default(),
882    }
883}
884
885/// Replay one right-side action onto the merged calendar.
886fn apply<'a>(merged: &mut IcalCst<'a>, op: &Op<'a>, right: &IcalCst<'a>) {
887    match &op.action {
888        IcalMergeAction::ComponentAdded { at } => {
889            let (Some(source), Some(target)) = (find(right, at), find_mut(merged, &parent(at)))
890            else {
891                return;
892            };
893
894            target
895                .items
896                .push(IcalItem::Component(alloc::boxed::Box::new(source.clone())));
897        }
898        IcalMergeAction::ComponentRemoved { at } => {
899            let (Some(step), Some(target)) = (at.0.last(), find_mut(merged, &parent(at))) else {
900                return;
901            };
902
903            let step = step.clone();
904            let mut ordinal = 0;
905
906            target.items.retain(|item| {
907                let IcalItem::Component(child) = item else {
908                    return true;
909                };
910
911                if component_name(child) != step.name {
912                    return true;
913                }
914
915                let held = key(child, ordinal);
916                ordinal += 1;
917                held != step.key
918            });
919        }
920        action => apply_to_line(merged, action, right),
921    }
922}
923
924/// Replay a property-level action onto the line it lands on.
925fn apply_to_line<'a>(merged: &mut IcalCst<'a>, action: &IcalMergeAction<'a>, right: &IcalCst<'a>) {
926    let Some(at) = prop_path_of(action) else {
927        return;
928    };
929
930    let Some(component) = find_mut(merged, &at.component) else {
931        return;
932    };
933
934    if let IcalMergeAction::PropAdded { .. } = action {
935        // NOTE: The right side's own line is copied, bytes and all, rather than
936        // re-encoded from the model, so an added property arrives as written.
937        if let Some(line) = find(right, &at.component).and_then(|cst| nth_line(cst, at)) {
938            component.items.push(IcalItem::Prop(line.clone()));
939        }
940
941        return;
942    }
943
944    if let IcalMergeAction::PropRemoved { .. } = action {
945        let mut index = 0;
946        let name = at.name.clone();
947        let nth = at.index;
948
949        component.items.retain(|item| {
950            let IcalItem::Prop(line) = item else {
951                return true;
952            };
953
954            if !line.name.get().eq_ignore_ascii_case(&name) {
955                return true;
956            }
957
958            let held = index;
959            index += 1;
960            held != nth
961        });
962
963        return;
964    }
965
966    let Some(source) = find(right, &at.component).and_then(|cst| nth_line(cst, at)) else {
967        return;
968    };
969
970    // NOTE: The line may be gone because the left side removed it while the
971    // right side updated it. The update is what survives that stand-off, so the
972    // line comes back rather than the update landing nowhere.
973    if nth_line_mut(component, at).is_none() {
974        component.items.push(IcalItem::Prop(source.clone()));
975        return;
976    }
977
978    let Some(line) = nth_line_mut(component, at) else {
979        return;
980    };
981
982    match action {
983        IcalMergeAction::ValueChanged { .. } => line.value = source.value.clone(),
984        // NOTE: A list is merged item by item rather than replaced, or the
985        // right side's whole value would undo the left side's additions.
986        IcalMergeAction::ValueItemAdded { item, .. } => {
987            let mut items: Vec<String> = list(line);
988
989            if !items.iter().any(|held| held == item) {
990                items.push(item.to_string());
991            }
992
993            set_list(line, &items);
994        }
995        IcalMergeAction::ValueItemRemoved { item, .. } => {
996            let kept: Vec<String> = list(line).into_iter().filter(|held| held != item).collect();
997
998            set_list(line, &kept);
999        }
1000        IcalMergeAction::ParamRemoved { param, .. } => {
1001            let name = param_name(param);
1002            line.params
1003                .retain(|held| held.name.get().to_ascii_uppercase() != name);
1004        }
1005        IcalMergeAction::ParamAdded { param, .. }
1006        | IcalMergeAction::ParamChanged { new: param, .. } => {
1007            let name = param_name(param);
1008            let encoded = param.encode();
1009
1010            match line
1011                .params
1012                .iter_mut()
1013                .find(|held| held.name.get().to_ascii_uppercase() == name)
1014            {
1015                Some(held) => *held = encoded,
1016                None => line.params.push(encoded),
1017            }
1018        }
1019        _ => {}
1020    }
1021}
1022
1023/// The items of a line's list value.
1024fn list(line: &mut IcalLine<'_>) -> Vec<String> {
1025    IcalValueCursor { line }
1026        .list()
1027        .into_iter()
1028        .map(Cow::into_owned)
1029        .collect()
1030}
1031
1032/// Replace the items of a line's list value.
1033fn set_list(line: &mut IcalLine<'_>, items: &[String]) {
1034    IcalValueCursor { line }.set_list(items);
1035}
1036
1037/// The property an action lands on, for the actions that land on one.
1038fn prop_path_of<'p, 'a>(action: &'p IcalMergeAction<'a>) -> Option<&'p IcalPropPath<'a>> {
1039    match action {
1040        IcalMergeAction::ComponentAdded { .. } | IcalMergeAction::ComponentRemoved { .. } => None,
1041        IcalMergeAction::PropAdded { at, .. }
1042        | IcalMergeAction::PropRemoved { at, .. }
1043        | IcalMergeAction::ValueChanged { at, .. }
1044        | IcalMergeAction::ValueItemAdded { at, .. }
1045        | IcalMergeAction::ValueItemRemoved { at, .. }
1046        | IcalMergeAction::ParamAdded { at, .. }
1047        | IcalMergeAction::ParamRemoved { at, .. }
1048        | IcalMergeAction::ParamChanged { at, .. } => Some(at),
1049    }
1050}
1051
1052/// A path with its last step dropped: the component holding the one it names.
1053fn parent<'a>(path: &IcalComponentPath<'a>) -> IcalComponentPath<'a> {
1054    let mut parent = path.clone();
1055    parent.0.pop();
1056    parent
1057}
1058
1059/// The component a path names.
1060fn find<'c, 'a>(cst: &'c IcalCst<'a>, path: &IcalComponentPath<'a>) -> Option<&'c IcalCst<'a>> {
1061    let mut held = cst;
1062
1063    for step in &path.0 {
1064        held = components(held)
1065            .enumerate()
1066            .find(|(ordinal, child)| {
1067                component_name(child) == step.name && key(child, *ordinal) == step.key
1068            })
1069            .map(|(_, child)| child)?;
1070    }
1071
1072    Some(held)
1073}
1074
1075/// The same, mutably.
1076fn find_mut<'c, 'a>(
1077    cst: &'c mut IcalCst<'a>,
1078    path: &IcalComponentPath<'a>,
1079) -> Option<&'c mut IcalCst<'a>> {
1080    let mut held = cst;
1081
1082    for step in &path.0 {
1083        let mut ordinal = 0;
1084        held = held.items.iter_mut().find_map(|item| {
1085            let IcalItem::Component(child) = item else {
1086                return None;
1087            };
1088
1089            if component_name(child) != step.name {
1090                return None;
1091            }
1092
1093            let matched = key(child, ordinal) == step.key;
1094            ordinal += 1;
1095            matched.then_some(&mut **child)
1096        })?;
1097    }
1098
1099    Some(held)
1100}
1101
1102/// The line a property path names inside a component.
1103fn nth_line<'c, 'a>(cst: &'c IcalCst<'a>, at: &IcalPropPath<'a>) -> Option<&'c IcalLine<'a>> {
1104    lines(cst)
1105        .filter(|line| line.name.get().eq_ignore_ascii_case(&at.name))
1106        .nth(at.index)
1107}
1108
1109/// The same, mutably.
1110fn nth_line_mut<'c, 'a>(
1111    cst: &'c mut IcalCst<'a>,
1112    at: &IcalPropPath<'a>,
1113) -> Option<&'c mut IcalLine<'a>> {
1114    cst.items
1115        .iter_mut()
1116        .filter_map(|item| match item {
1117            IcalItem::Prop(line) => Some(line),
1118            _ => None,
1119        })
1120        .filter(|line| line.name.get().eq_ignore_ascii_case(&at.name))
1121        .nth(at.index)
1122}