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