1#![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 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 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}