Skip to main content

suture_driver_ical/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2#![allow(clippy::collapsible_match)]
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4
5use suture_driver::{DriverError, SemanticChange, SutureDriver};
6
7type Component = (String, Vec<(String, String)>);
8
9pub struct IcalDriver;
10
11impl IcalDriver {
12    pub fn new() -> Self {
13        Self
14    }
15
16    fn unfold_lines(content: &str) -> Vec<String> {
17        let mut lines = Vec::new();
18        let mut current = String::new();
19        for raw_line in content.lines() {
20            let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
21            if line.starts_with(' ') || line.starts_with('\t') {
22                current.push_str(&line[1..]);
23            } else {
24                if !current.is_empty() {
25                    lines.push(current);
26                }
27                current = line.to_string();
28            }
29        }
30        if !current.is_empty() {
31            lines.push(current);
32        }
33        lines
34    }
35
36    fn parse_ical(content: &str) -> Result<Vec<Component>, DriverError> {
37        let lines = Self::unfold_lines(content);
38        let mut components: Vec<Component> = Vec::new();
39        let mut component_stack: Vec<Component> = Vec::new();
40
41        for line in &lines {
42            if line.is_empty() {
43                continue;
44            }
45            if let Some(rest) = line.strip_prefix("BEGIN:") {
46                let comp_type = rest.trim();
47                component_stack.push((comp_type.to_string(), Vec::new()));
48            } else if let Some(rest) = line.strip_prefix("END:") {
49                let end_type = rest.trim();
50                if let Some((comp_type, props)) = component_stack.pop()
51                    && comp_type == end_type
52                {
53                    if component_stack.is_empty() {
54                        components.push((comp_type, props));
55                    } else if let Some(parent) = component_stack.last_mut() {
56                        parent
57                            .1
58                            .push((format!("BEGIN:{comp_type}"), comp_type.clone()));
59                        for (k, v) in props {
60                            parent.1.push((k, v));
61                        }
62                        parent
63                            .1
64                            .push((format!("END:{comp_type}"), comp_type.clone()));
65                    }
66                }
67            } else if let Some(entry) = component_stack.last_mut()
68                && let Some((key, value)) = Self::parse_property_line(line)
69            {
70                entry.1.push((key, value));
71            }
72        }
73
74        Ok(components)
75    }
76
77    fn parse_property_line(line: &str) -> Option<(String, String)> {
78        let colon_pos = line.find(':')?;
79        let value = &line[colon_pos + 1..];
80        let prop_part = &line[..colon_pos];
81
82        let prop_name = if let Some(semi_pos) = prop_part.find(';') {
83            &prop_part[..semi_pos]
84        } else {
85            prop_part
86        };
87
88        Some((prop_name.to_string(), value.to_string()))
89    }
90
91    fn extract_uid(props: &[(String, String)]) -> Option<String> {
92        for (key, value) in props {
93            if key == "UID" {
94                return Some(value.clone());
95            }
96        }
97        None
98    }
99
100    fn components_by_uid(components: &[Component]) -> BTreeMap<String, Vec<(String, String)>> {
101        let mut map = BTreeMap::new();
102        for (comp_type, props) in components {
103            if matches!(
104                comp_type.as_str(),
105                "VEVENT" | "VTODO" | "VJOURNAL" | "VFREEBUSY"
106            ) {
107                let uid = Self::extract_uid(props).unwrap_or_default();
108                let key = format!("{comp_type}[UID={uid}]");
109                map.insert(key, props.clone());
110            }
111        }
112        map
113    }
114
115    fn diff_properties(
116        comp_type: &str,
117        uid: &str,
118        old_props: &[(String, String)],
119        new_props: &[(String, String)],
120    ) -> Vec<SemanticChange> {
121        let mut changes = Vec::new();
122        let base_path = format!("/VCALENDAR/{comp_type}[UID={uid}]");
123
124        let old_map: HashMap<&str, &str> = old_props
125            .iter()
126            .map(|(k, v)| (k.as_str(), v.as_str()))
127            .collect();
128        let new_map: HashMap<&str, &str> = new_props
129            .iter()
130            .map(|(k, v)| (k.as_str(), v.as_str()))
131            .collect();
132
133        let old_keys: BTreeSet<&str> = old_map.keys().copied().collect();
134        let new_keys: BTreeSet<&str> = new_map.keys().copied().collect();
135
136        for key in &old_keys {
137            if !new_keys.contains(key) {
138                changes.push(SemanticChange::Removed {
139                    path: format!("{base_path}/{key}"),
140                    old_value: old_map[key].to_string(),
141                });
142            }
143        }
144
145        for key in &new_keys {
146            if !old_keys.contains(key) {
147                changes.push(SemanticChange::Added {
148                    path: format!("{base_path}/{key}"),
149                    value: new_map[key].to_string(),
150                });
151            }
152        }
153
154        for key in &old_keys {
155            if let Some(new_val) = new_keys.contains(key).then(|| new_map[key]) {
156                let old_val = old_map[key];
157                if old_val != new_val {
158                    changes.push(SemanticChange::Modified {
159                        path: format!("{base_path}/{key}"),
160                        old_value: old_val.to_string(),
161                        new_value: new_val.to_string(),
162                    });
163                }
164            }
165        }
166
167        changes
168    }
169
170    fn serialize_components(components: &[Component]) -> String {
171        let mut output = String::new();
172        output.push_str("BEGIN:VCALENDAR\r\n");
173        output.push_str("VERSION:2.0\r\n");
174        output.push_str("PRODID:-//Suture//ICAL//EN\r\n");
175        for (comp_type, props) in components {
176            output.push_str(&format!("BEGIN:{comp_type}\r\n"));
177            for (key, value) in props {
178                output.push_str(&format!("{key}:{value}\r\n"));
179            }
180            output.push_str(&format!("END:{comp_type}\r\n"));
181        }
182        output.push_str("END:VCALENDAR\r\n");
183        output
184    }
185
186    fn extract_inner_components(components: &[Component]) -> Vec<Component> {
187        let mut inner = Vec::new();
188        for (comp_type, props) in components {
189            if *comp_type == "VCALENDAR" {
190                let mut i = 0;
191                while i < props.len() {
192                    if let Some(ct) = props[i].0.strip_prefix("BEGIN:") {
193                        let mut inner_props = Vec::new();
194                        i += 1;
195                        while i < props.len() && !props[i].0.starts_with("END:") {
196                            inner_props.push(props[i].clone());
197                            i += 1;
198                        }
199                        inner.push((ct.to_string(), inner_props));
200                    }
201                    i += 1;
202                }
203            } else {
204                inner.push((comp_type.clone(), props.clone()));
205            }
206        }
207        inner
208    }
209
210    fn merge_components(
211        base: &[Component],
212        ours: &[Component],
213        theirs: &[Component],
214    ) -> Result<Option<Vec<Component>>, DriverError> {
215        let base_by_uid = Self::components_by_uid(base);
216        let ours_by_uid = Self::components_by_uid(ours);
217        let theirs_by_uid = Self::components_by_uid(theirs);
218
219        let all_uids: BTreeSet<String> = base_by_uid
220            .keys()
221            .chain(ours_by_uid.keys())
222            .chain(theirs_by_uid.keys())
223            .cloned()
224            .collect();
225
226        let mut merged: Vec<Component> = Vec::new();
227
228        for uid_key in &all_uids {
229            let in_base = base_by_uid.contains_key(uid_key);
230            let in_ours = ours_by_uid.contains_key(uid_key);
231            let in_theirs = theirs_by_uid.contains_key(uid_key);
232
233            match (in_base, in_ours, in_theirs) {
234                (true, false, false) => continue,
235                (false, true, false) => {
236                    let comp_type = uid_key.split('[').next().unwrap_or("VEVENT").to_string();
237                    merged.push((comp_type, ours_by_uid[uid_key].clone()));
238                }
239                (false, false, true) => {
240                    let comp_type = uid_key.split('[').next().unwrap_or("VEVENT").to_string();
241                    merged.push((comp_type, theirs_by_uid[uid_key].clone()));
242                }
243                (false, true, true) => {
244                    if ours_by_uid[uid_key] == theirs_by_uid[uid_key] {
245                        let comp_type = uid_key.split('[').next().unwrap_or("VEVENT").to_string();
246                        merged.push((comp_type, ours_by_uid[uid_key].clone()));
247                    } else {
248                        return Ok(None);
249                    }
250                }
251                (true, true, false) => {
252                    let comp_type = uid_key.split('[').next().unwrap_or("VEVENT").to_string();
253                    merged.push((comp_type, ours_by_uid[uid_key].clone()));
254                }
255                (true, false, true) => {
256                    let comp_type = uid_key.split('[').next().unwrap_or("VEVENT").to_string();
257                    merged.push((comp_type, theirs_by_uid[uid_key].clone()));
258                }
259                (false, false, false) => {}
260                (true, true, true) => {
261                    let base_props = &base_by_uid[uid_key];
262                    let ours_props = &ours_by_uid[uid_key];
263                    let theirs_props = &theirs_by_uid[uid_key];
264
265                    if ours_props == theirs_props {
266                        let comp_type = uid_key.split('[').next().unwrap_or("VEVENT").to_string();
267                        merged.push((comp_type, ours_props.clone()));
268                        continue;
269                    }
270
271                    let base_map: HashMap<&str, &str> = base_props
272                        .iter()
273                        .map(|(k, v)| (k.as_str(), v.as_str()))
274                        .collect();
275                    let ours_map: HashMap<&str, &str> = ours_props
276                        .iter()
277                        .map(|(k, v)| (k.as_str(), v.as_str()))
278                        .collect();
279                    let theirs_map: HashMap<&str, &str> = theirs_props
280                        .iter()
281                        .map(|(k, v)| (k.as_str(), v.as_str()))
282                        .collect();
283
284                    let all_keys: BTreeSet<&str> = base_map
285                        .keys()
286                        .chain(ours_map.keys())
287                        .chain(theirs_map.keys())
288                        .copied()
289                        .collect();
290
291                    let mut merged_props = Vec::new();
292
293                    for key in &all_keys {
294                        let bv = base_map.get(key).copied();
295                        let ov = ours_map.get(key).copied();
296                        let tv = theirs_map.get(key).copied();
297
298                        match (bv, ov, tv) {
299                            (_, Some(o), None) => {
300                                merged_props.push((key.to_string(), o.to_string()))
301                            }
302                            (_, None, Some(t)) => {
303                                merged_props.push((key.to_string(), t.to_string()))
304                            }
305                            (_, Some(o), Some(t)) => {
306                                if o == t {
307                                    merged_props.push((key.to_string(), o.to_string()));
308                                } else if o == bv.unwrap_or("") {
309                                    merged_props.push((key.to_string(), t.to_string()));
310                                } else if t == bv.unwrap_or("") {
311                                    merged_props.push((key.to_string(), o.to_string()));
312                                } else {
313                                    return Ok(None);
314                                }
315                            }
316                            (_, None, None) => {}
317                        }
318                    }
319
320                    let comp_type = uid_key.split('[').next().unwrap_or("VEVENT").to_string();
321                    merged.push((comp_type, merged_props));
322                }
323            }
324        }
325
326        Ok(Some(merged))
327    }
328
329    fn format_change(change: &SemanticChange) -> String {
330        match change {
331            SemanticChange::Added { path, value } => {
332                format!("  ADDED     {path}: {value}")
333            }
334            SemanticChange::Removed { path, old_value } => {
335                format!("  REMOVED   {path}: {old_value}")
336            }
337            SemanticChange::Modified {
338                path,
339                old_value,
340                new_value,
341            } => {
342                format!("  MODIFIED  {path}: {old_value} -> {new_value}")
343            }
344            SemanticChange::Moved {
345                old_path,
346                new_path,
347                value,
348            } => {
349                format!("  MOVED     {old_path} -> {new_path}: {value}")
350            }
351        }
352    }
353}
354
355fn _merged_components_from_props(
356    merged: &mut Vec<String>,
357    comp_type: &str,
358    merged_props: &[(String, String)],
359) {
360    let uid = merged_props
361        .iter()
362        .find(|(k, _)| k == "UID")
363        .map(|(_, v)| v.as_str())
364        .unwrap_or("");
365    let uid_key = format!("{comp_type}[UID={uid}]");
366    if !merged.contains(&uid_key) {
367        merged.push(uid_key);
368    }
369}
370
371impl Default for IcalDriver {
372    fn default() -> Self {
373        Self::new()
374    }
375}
376
377impl SutureDriver for IcalDriver {
378    fn name(&self) -> &str {
379        "ICAL"
380    }
381
382    fn supported_extensions(&self) -> &[&str] {
383        &[".ics", ".ifb"]
384    }
385
386    fn diff(
387        &self,
388        base_content: Option<&str>,
389        new_content: &str,
390    ) -> Result<Vec<SemanticChange>, DriverError> {
391        let new_components = Self::parse_ical(new_content)?;
392
393        match base_content {
394            None => {
395                let mut changes = Vec::new();
396                let inner = Self::extract_inner_components(&new_components);
397                for (comp_type, props) in &inner {
398                    let uid = Self::extract_uid(props).unwrap_or_else(|| "?".to_string());
399                    let base_path = format!("/VCALENDAR/{comp_type}[UID={uid}]");
400                    for (key, value) in props {
401                        changes.push(SemanticChange::Added {
402                            path: format!("{base_path}/{key}"),
403                            value: value.clone(),
404                        });
405                    }
406                }
407                Ok(changes)
408            }
409            Some(base) => {
410                let old_components = Self::parse_ical(base)?;
411                let old_inner = Self::extract_inner_components(&old_components);
412                let new_inner = Self::extract_inner_components(&new_components);
413
414                let old_by_uid = Self::components_by_uid(&old_inner);
415                let new_by_uid = Self::components_by_uid(&new_inner);
416
417                let mut changes = Vec::new();
418                let all_keys: BTreeSet<&String> =
419                    old_by_uid.keys().chain(new_by_uid.keys()).collect();
420
421                for key in &all_keys {
422                    let in_old = old_by_uid.contains_key(*key);
423                    let in_new = new_by_uid.contains_key(*key);
424
425                    match (in_old, in_new) {
426                        (true, false) => {
427                            let props = &old_by_uid[*key];
428                            changes.push(SemanticChange::Removed {
429                                path: format!("/VCALENDAR/{key}"),
430                                old_value: format!(
431                                    "\"{}\"",
432                                    Self::extract_uid(props).unwrap_or_default()
433                                ),
434                            });
435                        }
436                        (false, true) => {
437                            let props = &new_by_uid[*key];
438                            changes.push(SemanticChange::Added {
439                                path: format!("/VCALENDAR/{key}"),
440                                value: format!(
441                                    "\"{}\"",
442                                    Self::extract_uid(props).as_deref().unwrap_or("?")
443                                ),
444                            });
445                        }
446                        (true, true) => {
447                            let old_props = &old_by_uid[*key];
448                            let new_props = &new_by_uid[*key];
449                            changes.extend(Self::diff_properties(key, "", old_props, new_props));
450                        }
451                        (false, false) => {}
452                    }
453                }
454
455                Ok(changes)
456            }
457        }
458    }
459
460    fn format_diff(
461        &self,
462        base_content: Option<&str>,
463        new_content: &str,
464    ) -> Result<String, DriverError> {
465        let changes = self.diff(base_content, new_content)?;
466
467        if changes.is_empty() {
468            return Ok("no changes".to_string());
469        }
470
471        let lines: Vec<String> = changes.iter().map(Self::format_change).collect();
472        Ok(lines.join("\n"))
473    }
474
475    fn merge(&self, base: &str, ours: &str, theirs: &str) -> Result<Option<String>, DriverError> {
476        let base_components = Self::parse_ical(base)?;
477        let ours_components = Self::parse_ical(ours)?;
478        let theirs_components = Self::parse_ical(theirs)?;
479
480        let base_inner = Self::extract_inner_components(&base_components);
481        let ours_inner = Self::extract_inner_components(&ours_components);
482        let theirs_inner = Self::extract_inner_components(&theirs_components);
483
484        match Self::merge_components(&base_inner, &ours_inner, &theirs_inner)? {
485            Some(merged) => Ok(Some(Self::serialize_components(&merged))),
486            None => Ok(None),
487        }
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    const BASE_ICAL: &str = "BEGIN:VCALENDAR\r\n\
496        VERSION:2.0\r\n\
497        PRODID:-//Test//EN\r\n\
498        BEGIN:VEVENT\r\n\
499        DTSTART:20240101T100000Z\r\n\
500        DTEND:20240101T110000Z\r\n\
501        SUMMARY:Team Meeting\r\n\
502        LOCATION:Room 101\r\n\
503        UID:abc123@example.com\r\n\
504        END:VEVENT\r\n\
505        BEGIN:VEVENT\r\n\
506        DTSTART:20240102T090000Z\r\n\
507        DTEND:20240102T100000Z\r\n\
508        SUMMARY:Standup\r\n\
509        UID:def456@example.com\r\n\
510        END:VEVENT\r\n\
511        END:VCALENDAR\r\n";
512
513    #[test]
514    fn test_new_ics_file() {
515        let driver = IcalDriver::new();
516        let new_content = "BEGIN:VCALENDAR\r\n\
517            VERSION:2.0\r\n\
518            PRODID:-//Test//EN\r\n\
519            BEGIN:VEVENT\r\n\
520            DTSTART:20240101T100000Z\r\n\
521            SUMMARY:New Event\r\n\
522            UID:abc123@example.com\r\n\
523            END:VEVENT\r\n\
524            END:VCALENDAR\r\n";
525
526        let changes = driver.diff(None, new_content).unwrap();
527        assert!(!changes.is_empty());
528        assert!(changes.iter().any(|c| matches!(
529            c,
530            SemanticChange::Added { path, value } if path.contains("SUMMARY") && value == "New Event"
531        )));
532    }
533
534    #[test]
535    fn test_single_event_summary_change() {
536        let driver = IcalDriver::new();
537        let new_content = BASE_ICAL.replace("SUMMARY:Team Meeting", "SUMMARY:Sprint Planning");
538
539        let changes = driver.diff(Some(BASE_ICAL), &new_content).unwrap();
540        assert!(changes.iter().any(|c| matches!(
541            c,
542            SemanticChange::Modified {
543                path,
544                old_value,
545                new_value,
546            } if path.contains("SUMMARY")
547                && old_value == "Team Meeting"
548                && new_value == "Sprint Planning"
549        )));
550    }
551
552    #[test]
553    fn test_event_dtstart_change() {
554        let driver = IcalDriver::new();
555        let new_content = BASE_ICAL.replace("DTSTART:20240101T100000Z", "DTSTART:20240102T100000Z");
556
557        let changes = driver.diff(Some(BASE_ICAL), &new_content).unwrap();
558        assert!(changes.iter().any(|c| matches!(
559            c,
560            SemanticChange::Modified {
561                path,
562                old_value,
563                new_value,
564            } if path.contains("DTSTART")
565                && old_value == "20240101T100000Z"
566                && new_value == "20240102T100000Z"
567        )));
568    }
569
570    #[test]
571    fn test_event_location_change() {
572        let driver = IcalDriver::new();
573        let new_content = BASE_ICAL.replace("LOCATION:Room 101", "LOCATION:Conference Room B");
574
575        let changes = driver.diff(Some(BASE_ICAL), &new_content).unwrap();
576        assert!(changes.iter().any(|c| matches!(
577            c,
578            SemanticChange::Modified {
579                path,
580                old_value,
581                new_value,
582            } if path.contains("LOCATION")
583                && old_value == "Room 101"
584                && new_value == "Conference Room B"
585        )));
586    }
587
588    #[test]
589    fn test_new_event_added() {
590        let driver = IcalDriver::new();
591        let new_content = "BEGIN:VCALENDAR\r\n\
592            VERSION:2.0\r\n\
593            PRODID:-//Test//EN\r\n\
594            BEGIN:VEVENT\r\n\
595            DTSTART:20240101T100000Z\r\n\
596            DTEND:20240101T110000Z\r\n\
597            SUMMARY:Team Meeting\r\n\
598            LOCATION:Room 101\r\n\
599            UID:abc123@example.com\r\n\
600            END:VEVENT\r\n\
601            BEGIN:VEVENT\r\n\
602            DTSTART:20240102T090000Z\r\n\
603            DTEND:20240102T100000Z\r\n\
604            SUMMARY:Standup\r\n\
605            UID:def456@example.com\r\n\
606            END:VEVENT\r\n\
607            BEGIN:VEVENT\r\n\
608            DTSTART:20240103T140000Z\r\n\
609            SUMMARY:Workshop\r\n\
610            UID:ghi789@example.com\r\n\
611            END:VEVENT\r\n\
612            END:VCALENDAR\r\n";
613
614        let changes = driver.diff(Some(BASE_ICAL), &new_content).unwrap();
615        assert!(changes.iter().any(|c| matches!(
616            c,
617            SemanticChange::Added { path, .. } if path.contains("ghi789")
618        )));
619    }
620
621    #[test]
622    fn test_event_removed() {
623        let driver = IcalDriver::new();
624        let new_content = "BEGIN:VCALENDAR\r\n\
625            VERSION:2.0\r\n\
626            PRODID:-//Test//EN\r\n\
627            BEGIN:VEVENT\r\n\
628            DTSTART:20240101T100000Z\r\n\
629            DTEND:20240101T110000Z\r\n\
630            SUMMARY:Team Meeting\r\n\
631            LOCATION:Room 101\r\n\
632            UID:abc123@example.com\r\n\
633            END:VEVENT\r\n\
634            END:VCALENDAR\r\n";
635
636        let changes = driver.diff(Some(BASE_ICAL), &new_content).unwrap();
637        assert!(changes.iter().any(|c| matches!(
638            c,
639            SemanticChange::Removed { path, .. } if path.contains("def456")
640        )));
641    }
642
643    #[test]
644    fn test_attendee_added_to_event() {
645        let driver = IcalDriver::new();
646        let new_content = "BEGIN:VCALENDAR\r\n\
647            VERSION:2.0\r\n\
648            PRODID:-//Test//EN\r\n\
649            BEGIN:VEVENT\r\n\
650            DTSTART:20240101T100000Z\r\n\
651            DTEND:20240101T110000Z\r\n\
652            SUMMARY:Team Meeting\r\n\
653            LOCATION:Room 101\r\n\
654            UID:abc123@example.com\r\n\
655            ATTENDEE:mailto:bob@example.com\r\n\
656            END:VEVENT\r\n\
657            BEGIN:VEVENT\r\n\
658            DTSTART:20240102T090000Z\r\n\
659            DTEND:20240102T100000Z\r\n\
660            SUMMARY:Standup\r\n\
661            UID:def456@example.com\r\n\
662            END:VEVENT\r\n\
663            END:VCALENDAR\r\n";
664
665        let changes = driver.diff(Some(BASE_ICAL), &new_content).unwrap();
666        assert!(changes.iter().any(|c| matches!(
667            c,
668            SemanticChange::Added { path, value } if path.contains("ATTENDEE")
669                && value == "mailto:bob@example.com"
670        )));
671    }
672
673    #[test]
674    fn test_vtodo_priority_change() {
675        let driver = IcalDriver::new();
676        let base = "BEGIN:VCALENDAR\r\n\
677            VERSION:2.0\r\n\
678            PRODID:-//Test//EN\r\n\
679            BEGIN:VTODO\r\n\
680            SUMMARY:Review PRs\r\n\
681            PRIORITY:5\r\n\
682            UID:todo1@example.com\r\n\
683            END:VTODO\r\n\
684            END:VCALENDAR\r\n";
685        let new = "BEGIN:VCALENDAR\r\n\
686            VERSION:2.0\r\n\
687            PRODID:-//Test//EN\r\n\
688            BEGIN:VTODO\r\n\
689            SUMMARY:Review PRs\r\n\
690            PRIORITY:1\r\n\
691            UID:todo1@example.com\r\n\
692            END:VTODO\r\n\
693            END:VCALENDAR\r\n";
694
695        let changes = driver.diff(Some(base), &new).unwrap();
696        assert!(changes.iter().any(|c| matches!(
697            c,
698            SemanticChange::Modified {
699                path,
700                old_value,
701                new_value,
702            } if path.contains("PRIORITY")
703                && old_value == "5"
704                && new_value == "1"
705        )));
706    }
707
708    #[test]
709    fn test_clean_merge_different_events_modified() {
710        let driver = IcalDriver::new();
711        let ours = BASE_ICAL.replace("SUMMARY:Team Meeting", "SUMMARY:Sprint Planning");
712        let theirs = BASE_ICAL.replace("SUMMARY:Standup", "SUMMARY:Daily Sync");
713
714        let result = driver.merge(BASE_ICAL, &ours, &theirs).unwrap();
715        assert!(result.is_some());
716        let merged = result.unwrap();
717        assert!(merged.contains("Sprint Planning"));
718        assert!(merged.contains("Daily Sync"));
719    }
720
721    #[test]
722    fn test_conflict_merge_same_event_summary_changed() {
723        let driver = IcalDriver::new();
724        let ours = BASE_ICAL.replace("SUMMARY:Team Meeting", "SUMMARY:Sprint Planning");
725        let theirs = BASE_ICAL.replace("SUMMARY:Team Meeting", "SUMMARY:Retrospective");
726
727        let result = driver.merge(BASE_ICAL, &ours, &theirs).unwrap();
728        assert!(result.is_none());
729    }
730
731    #[test]
732    fn test_multiline_description_handling() {
733        let driver = IcalDriver::new();
734        let base = "BEGIN:VCALENDAR\r\n\
735            VERSION:2.0\r\n\
736            PRODID:-//Test//EN\r\n\
737            BEGIN:VEVENT\r\n\
738            SUMMARY:Meeting\r\n\
739            UID:multi@example.com\r\n\
740            END:VEVENT\r\n\
741            END:VCALENDAR\r\n";
742
743        // Note: RFC 5545 fold = CRLF + single space. We build this manually
744        // because Rust's \ line continuation strips leading whitespace.
745        let new = [
746            "BEGIN:VCALENDAR\r\n",
747            "VERSION:2.0\r\n",
748            "PRODID:-//Test//EN\r\n",
749            "BEGIN:VEVENT\r\n",
750            "SUMMARY:Meeting\r\n",
751            "UID:multi@example.com\r\n",
752            "DESCRIPTION:This is a long description that is folded\r\n",
753            " across multiple lines as per RFC 5545.\r\n",
754            "END:VEVENT\r\n",
755            "END:VCALENDAR\r\n",
756        ]
757        .concat();
758
759        let changes = driver.diff(Some(base), &new).unwrap();
760        // RFC 5545 ยง3.1: the leading space (fold indicator) is removed during unfolding,
761        // so "folded\r\n across" unfolds to "foldedacross" (no space between).
762        assert!(changes.iter().any(|c| matches!(
763            c,
764            SemanticChange::Added { path, value } if path.contains("DESCRIPTION")
765                && value.contains("foldedacross multiple lines")
766        )));
767    }
768
769    #[test]
770    fn test_rrule_modification() {
771        let driver = IcalDriver::new();
772        let base = "BEGIN:VCALENDAR\r\n\
773            VERSION:2.0\r\n\
774            PRODID:-//Test//EN\r\n\
775            BEGIN:VEVENT\r\n\
776            SUMMARY:Weekly Standup\r\n\
777            UID:rrule@example.com\r\n\
778            RRULE:FREQ=WEEKLY;COUNT=10\r\n\
779            END:VEVENT\r\n\
780            END:VCALENDAR\r\n";
781        let new = "BEGIN:VCALENDAR\r\n\
782            VERSION:2.0\r\n\
783            PRODID:-//Test//EN\r\n\
784            BEGIN:VEVENT\r\n\
785            SUMMARY:Weekly Standup\r\n\
786            UID:rrule@example.com\r\n\
787            RRULE:FREQ=WEEKLY;COUNT=20\r\n\
788            END:VEVENT\r\n\
789            END:VCALENDAR\r\n";
790
791        let changes = driver.diff(Some(base), &new).unwrap();
792        assert!(changes.iter().any(|c| matches!(
793            c,
794            SemanticChange::Modified {
795                path,
796                old_value,
797                new_value,
798            } if path.contains("RRULE")
799                && old_value == "FREQ=WEEKLY;COUNT=10"
800                && new_value == "FREQ=WEEKLY;COUNT=20"
801        )));
802    }
803
804    #[test]
805    fn test_driver_name() {
806        let driver = IcalDriver::new();
807        assert_eq!(driver.name(), "ICAL");
808    }
809
810    #[test]
811    fn test_driver_extensions() {
812        let driver = IcalDriver::new();
813        assert_eq!(driver.supported_extensions(), &[".ics", ".ifb"]);
814    }
815
816    #[test]
817    fn test_format_diff_no_changes() {
818        let driver = IcalDriver::new();
819        let result = driver.format_diff(Some(BASE_ICAL), BASE_ICAL).unwrap();
820        assert_eq!(result, "no changes");
821    }
822}