Skip to main content

cardinal_vdir/
lib.rs

1use std::fs;
2use std::path::{Component, Path, PathBuf};
3
4use cardinal_core::calendar::{
5    Atomic, Calendar, CalendarEvent, CalendarWrite, EventEdit, EventId, EventStatus, Validated,
6};
7use thiserror::Error;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct VdirEvent {
11    pub event: CalendarEvent,
12    pub recurring: bool,
13    pub source_path: PathBuf,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct VdirCalendar {
18    pub calendar: Calendar,
19    pub events: Vec<VdirEvent>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct VdirSnapshot {
24    pub calendars: Vec<VdirCalendar>,
25}
26
27#[derive(Debug, Error)]
28pub enum VdirError {
29    #[error("vdir root does not exist: {0}")]
30    RootMissing(PathBuf),
31    #[error("failed to read filesystem path {path}: {source}")]
32    ReadPath {
33        path: PathBuf,
34        source: std::io::Error,
35    },
36    #[error("vdir event path must be inside root {root}: {path}")]
37    PathOutsideRoot { root: PathBuf, path: PathBuf },
38    #[error("vdir calendar name must be a relative folder name: {0}")]
39    InvalidCalendarName(String),
40    #[error("vdir event path must point to an .ics file: {0}")]
41    InvalidEventPath(PathBuf),
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45struct CalendarPath {
46    name: String,
47    path: PathBuf,
48}
49
50pub fn load_vdir(root: &Path, account: &str) -> Result<VdirSnapshot, VdirError> {
51    if !root.exists() {
52        return Err(VdirError::RootMissing(root.to_path_buf()));
53    }
54
55    let mut calendars = Vec::new();
56    for calendar_path in discover_calendars(root, account)? {
57        let events = load_calendar_events(&calendar_path.path, &calendar_path.name)?;
58        let calendar = Calendar {
59            account: account.to_owned(),
60            name: calendar_path.name,
61            path: Some(calendar_path.path.to_string_lossy().to_string()),
62        };
63        calendars.push(VdirCalendar { calendar, events });
64    }
65
66    calendars.sort_by(|left, right| left.calendar.name.cmp(&right.calendar.name));
67    Ok(VdirSnapshot { calendars })
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct EventWriteResult {
72    pub path: PathBuf,
73    pub backup_path: PathBuf,
74}
75
76pub fn create_event(root: &Path, write: CalendarWrite<Atomic>) -> Result<PathBuf, VdirError> {
77    ensure_root_exists(root)?;
78
79    let event = write.into_event();
80    let calendar_dir = resolve_calendar_dir(root, &event.calendar)?;
81    fs::create_dir_all(&calendar_dir).map_err(|source| VdirError::ReadPath {
82        path: calendar_dir.clone(),
83        source,
84    })?;
85
86    let file_name = format!("{}.ics", sanitize_file_stem(&event.id.0));
87    let destination = unique_destination_path(&calendar_dir, &file_name);
88    write_atomic_file(&destination, &render_event_ics(&event))?;
89    Ok(destination)
90}
91
92pub fn update_event(
93    root: &Path,
94    source_path: &Path,
95    write: CalendarWrite<Atomic>,
96) -> Result<EventWriteResult, VdirError> {
97    ensure_root_exists(root)?;
98    let source_path = normalize_event_source(root, source_path)?;
99    let backup_path = backup_event_file(root, &source_path)?;
100    let event = write.into_event();
101    write_atomic_file(&source_path, &render_event_ics(&event))?;
102    Ok(EventWriteResult {
103        path: source_path,
104        backup_path,
105    })
106}
107
108pub fn delete_event(root: &Path, source_path: &Path) -> Result<PathBuf, VdirError> {
109    ensure_root_exists(root)?;
110    let source_path = normalize_event_source(root, source_path)?;
111    let backup_path = backup_event_file(root, &source_path)?;
112    fs::remove_file(&source_path).map_err(|source| VdirError::ReadPath {
113        path: source_path.clone(),
114        source,
115    })?;
116    Ok(backup_path)
117}
118
119pub fn move_event(
120    root: &Path,
121    source_path: &Path,
122    target_calendar: &str,
123) -> Result<PathBuf, VdirError> {
124    ensure_root_exists(root)?;
125    let source_path = normalize_event_source(root, source_path)?;
126    let destination_calendar = resolve_calendar_dir(root, target_calendar)?;
127    fs::create_dir_all(&destination_calendar).map_err(|source| VdirError::ReadPath {
128        path: destination_calendar.clone(),
129        source,
130    })?;
131    if source_path
132        .parent()
133        .is_some_and(|parent| parent == destination_calendar.as_path())
134    {
135        return Ok(source_path);
136    }
137
138    let source_name = source_path
139        .file_name()
140        .map(|value| value.to_string_lossy().to_string())
141        .ok_or_else(|| VdirError::InvalidEventPath(source_path.clone()))?;
142    let destination = unique_destination_path(&destination_calendar, &source_name);
143    fs::rename(&source_path, &destination).map_err(|source| VdirError::ReadPath {
144        path: source_path,
145        source,
146    })?;
147    Ok(destination)
148}
149
150fn discover_calendars(root: &Path, account: &str) -> Result<Vec<CalendarPath>, VdirError> {
151    let mut calendars = Vec::new();
152    if contains_ics_files(root)? {
153        let name = root
154            .file_name()
155            .map(|value| value.to_string_lossy().to_string())
156            .unwrap_or_else(|| account.to_owned());
157        calendars.push(CalendarPath {
158            name,
159            path: root.to_path_buf(),
160        });
161    }
162
163    let entries = fs::read_dir(root).map_err(|source| VdirError::ReadPath {
164        path: root.to_path_buf(),
165        source,
166    })?;
167    for entry in entries {
168        let entry = entry.map_err(|source| VdirError::ReadPath {
169            path: root.to_path_buf(),
170            source,
171        })?;
172        let child = entry.path();
173        if !child.is_dir() {
174            continue;
175        }
176        if contains_ics_files(&child)? {
177            calendars.push(CalendarPath {
178                name: entry.file_name().to_string_lossy().to_string(),
179                path: child,
180            });
181        }
182    }
183
184    calendars.sort_by(|left, right| left.name.cmp(&right.name));
185    Ok(calendars)
186}
187
188fn contains_ics_files(path: &Path) -> Result<bool, VdirError> {
189    let entries = fs::read_dir(path).map_err(|source| VdirError::ReadPath {
190        path: path.to_path_buf(),
191        source,
192    })?;
193    for entry in entries {
194        let entry = entry.map_err(|source| VdirError::ReadPath {
195            path: path.to_path_buf(),
196            source,
197        })?;
198        let child = entry.path();
199        if child.is_file()
200            && child
201                .extension()
202                .is_some_and(|extension| extension.to_string_lossy().eq_ignore_ascii_case("ics"))
203        {
204            return Ok(true);
205        }
206    }
207    Ok(false)
208}
209
210fn load_calendar_events(path: &Path, calendar_name: &str) -> Result<Vec<VdirEvent>, VdirError> {
211    let mut files = Vec::new();
212    let entries = fs::read_dir(path).map_err(|source| VdirError::ReadPath {
213        path: path.to_path_buf(),
214        source,
215    })?;
216    for entry in entries {
217        let entry = entry.map_err(|source| VdirError::ReadPath {
218            path: path.to_path_buf(),
219            source,
220        })?;
221        let child = entry.path();
222        if child.is_file()
223            && child
224                .extension()
225                .is_some_and(|extension| extension.to_string_lossy().eq_ignore_ascii_case("ics"))
226        {
227            files.push(child);
228        }
229    }
230
231    files.sort();
232    let mut events = Vec::new();
233    for file in files {
234        let mut parsed = parse_ics_file(&file, calendar_name)?;
235        events.append(&mut parsed);
236    }
237    events.sort_by(|left, right| {
238        left.event
239            .starts_at
240            .cmp(&right.event.starts_at)
241            .then_with(|| left.event.title.cmp(&right.event.title))
242    });
243    Ok(events)
244}
245
246fn parse_ics_file(path: &Path, calendar_name: &str) -> Result<Vec<VdirEvent>, VdirError> {
247    let raw = fs::read_to_string(path).map_err(|source| VdirError::ReadPath {
248        path: path.to_path_buf(),
249        source,
250    })?;
251    let normalized = raw.replace("\r\n", "\n");
252    let lines = unfold_lines(&normalized);
253
254    let mut events = Vec::new();
255    let mut current = Vec::new();
256    let mut in_event = false;
257    let mut index = 0usize;
258
259    for line in lines {
260        if line == "BEGIN:VEVENT" {
261            in_event = true;
262            current.clear();
263            continue;
264        }
265        if line == "END:VEVENT" {
266            if in_event {
267                index += 1;
268                if let Some(event) = parse_event_lines(path, calendar_name, index, &current) {
269                    events.push(event);
270                }
271            }
272            in_event = false;
273            current.clear();
274            continue;
275        }
276        if in_event {
277            current.push(line);
278        }
279    }
280
281    Ok(events)
282}
283
284fn unfold_lines(raw: &str) -> Vec<String> {
285    let mut unfolded: Vec<String> = Vec::new();
286    for line in raw.lines() {
287        if (line.starts_with(' ') || line.starts_with('\t')) && !unfolded.is_empty() {
288            let previous = unfolded.last_mut().expect("checked is_empty");
289            previous.push_str(line.trim_start());
290        } else {
291            unfolded.push(line.trim_end().to_owned());
292        }
293    }
294    unfolded
295}
296
297fn parse_event_lines(
298    path: &Path,
299    calendar_name: &str,
300    index: usize,
301    lines: &[String],
302) -> Option<VdirEvent> {
303    let mut uid = None;
304    let mut summary = None;
305    let mut starts_at = None;
306    let mut ends_at = None;
307    let mut location = None;
308    let mut description = None;
309    let mut status = None;
310    let mut recurring = false;
311
312    for line in lines {
313        let Some((key, value)) = parse_property(line) else {
314            continue;
315        };
316        match key {
317            "UID" => uid = Some(value.to_owned()),
318            "SUMMARY" => summary = Some(value.to_owned()),
319            "DTSTART" => starts_at = Some(value.to_owned()),
320            "DTEND" => ends_at = Some(value.to_owned()),
321            "LOCATION" => location = Some(value.to_owned()),
322            "DESCRIPTION" => description = Some(value.replace("\\n", "\n")),
323            "STATUS" => status = Some(value.to_owned()),
324            "RRULE" => recurring = true,
325            _ => {}
326        }
327    }
328
329    let starts_at = starts_at?;
330    let default_id = format!(
331        "{}-event-{index}",
332        path.file_stem()
333            .map(|stem| stem.to_string_lossy().to_string())
334            .unwrap_or_else(|| "ics".to_owned())
335    );
336    let id = uid.unwrap_or(default_id);
337    let title = summary.unwrap_or_else(|| "Untitled event".to_owned());
338    let event = CalendarEvent {
339        id: EventId(id),
340        calendar: calendar_name.to_owned(),
341        title,
342        starts_at,
343        ends_at: ends_at.unwrap_or_default(),
344        location: location.filter(|value| !value.is_empty()),
345        description: description.filter(|value| !value.is_empty()),
346        status: parse_status(status.as_deref()),
347    };
348
349    Some(VdirEvent {
350        event,
351        recurring,
352        source_path: path.to_path_buf(),
353    })
354}
355
356fn parse_property(line: &str) -> Option<(&str, &str)> {
357    let (left, value) = line.split_once(':')?;
358    let key = left.split(';').next().unwrap_or(left);
359    Some((key.trim(), value.trim()))
360}
361
362fn parse_status(status: Option<&str>) -> EventStatus {
363    let normalized = status.unwrap_or("UNKNOWN").to_ascii_uppercase();
364    match normalized.as_str() {
365        "CONFIRMED" => EventStatus::Confirmed,
366        "TENTATIVE" => EventStatus::Tentative,
367        "CANCELLED" => EventStatus::Cancelled,
368        _ => EventStatus::Unknown,
369    }
370}
371
372fn ensure_root_exists(root: &Path) -> Result<(), VdirError> {
373    if !root.exists() {
374        return Err(VdirError::RootMissing(root.to_path_buf()));
375    }
376    Ok(())
377}
378
379fn normalize_event_source(root: &Path, source_path: &Path) -> Result<PathBuf, VdirError> {
380    if !source_path.is_file()
381        || !source_path
382            .extension()
383            .is_some_and(|extension| extension.to_string_lossy().eq_ignore_ascii_case("ics"))
384    {
385        return Err(VdirError::InvalidEventPath(source_path.to_path_buf()));
386    }
387
388    let root = root.canonicalize().map_err(|source| VdirError::ReadPath {
389        path: root.to_path_buf(),
390        source,
391    })?;
392    let source = source_path
393        .canonicalize()
394        .map_err(|source| VdirError::ReadPath {
395            path: source_path.to_path_buf(),
396            source,
397        })?;
398    if !source.starts_with(&root) {
399        return Err(VdirError::PathOutsideRoot { root, path: source });
400    }
401    Ok(source)
402}
403
404fn resolve_calendar_dir(root: &Path, calendar: &str) -> Result<PathBuf, VdirError> {
405    let calendar = calendar.trim();
406    if calendar.is_empty() {
407        return Err(VdirError::InvalidCalendarName(calendar.to_owned()));
408    }
409
410    let calendar_path = Path::new(calendar);
411    if calendar_path.is_absolute() {
412        return Err(VdirError::InvalidCalendarName(calendar.to_owned()));
413    }
414    for component in calendar_path.components() {
415        if !matches!(component, Component::Normal(_)) {
416            return Err(VdirError::InvalidCalendarName(calendar.to_owned()));
417        }
418    }
419
420    if contains_ics_files(root)? {
421        let root_name_matches = root.file_name().is_some_and(|name| {
422            name.to_string_lossy()
423                .eq_ignore_ascii_case(calendar_path.as_os_str().to_string_lossy().as_ref())
424        });
425        if root_name_matches {
426            return Ok(root.to_path_buf());
427        }
428    }
429
430    Ok(root.join(calendar_path))
431}
432
433fn render_event_ics(event: &EventEdit<Validated>) -> String {
434    let mut lines = Vec::new();
435    lines.push("BEGIN:VCALENDAR".to_owned());
436    lines.push("VERSION:2.0".to_owned());
437    lines.push("PRODID:-//Cardinal//EN".to_owned());
438    lines.push("BEGIN:VEVENT".to_owned());
439    lines.push(format!("UID:{}", escape_ics_text(&event.id.0)));
440    lines.push(format!("DTSTART:{}", event.starts_at));
441    if !event.ends_at.trim().is_empty() {
442        lines.push(format!("DTEND:{}", event.ends_at));
443    }
444    lines.push(format!("SUMMARY:{}", escape_ics_text(&event.title)));
445    lines.push(format!(
446        "STATUS:{}",
447        event_status_value(event.status.clone())
448    ));
449    if let Some(location) = event.location.as_deref() {
450        lines.push(format!("LOCATION:{}", escape_ics_text(location)));
451    }
452    if let Some(description) = event.description.as_deref() {
453        lines.push(format!("DESCRIPTION:{}", escape_ics_text(description)));
454    }
455    lines.push("END:VEVENT".to_owned());
456    lines.push("END:VCALENDAR".to_owned());
457    lines.push(String::new());
458    lines.join("\n")
459}
460
461fn escape_ics_text(value: &str) -> String {
462    value
463        .replace('\\', "\\\\")
464        .replace('\n', "\\n")
465        .replace(',', "\\,")
466        .replace(';', "\\;")
467}
468
469fn event_status_value(status: EventStatus) -> &'static str {
470    match status {
471        EventStatus::Confirmed => "CONFIRMED",
472        EventStatus::Tentative => "TENTATIVE",
473        EventStatus::Cancelled => "CANCELLED",
474        EventStatus::Unknown => "UNKNOWN",
475    }
476}
477
478fn write_atomic_file(destination: &Path, content: &str) -> Result<(), VdirError> {
479    let parent = destination
480        .parent()
481        .ok_or_else(|| VdirError::InvalidEventPath(destination.to_path_buf()))?;
482    fs::create_dir_all(parent).map_err(|source| VdirError::ReadPath {
483        path: parent.to_path_buf(),
484        source,
485    })?;
486
487    let file_name = destination
488        .file_name()
489        .map(|value| value.to_string_lossy().to_string())
490        .ok_or_else(|| VdirError::InvalidEventPath(destination.to_path_buf()))?;
491    let temporary = parent.join(format!(".{file_name}.tmp{}", unique_token()));
492    fs::write(&temporary, content).map_err(|source| VdirError::ReadPath {
493        path: temporary.clone(),
494        source,
495    })?;
496    fs::rename(&temporary, destination).map_err(|source| VdirError::ReadPath {
497        path: destination.to_path_buf(),
498        source,
499    })?;
500    Ok(())
501}
502
503fn backup_event_file(root: &Path, source_path: &Path) -> Result<PathBuf, VdirError> {
504    let backups_dir = root.join(".cardinal-backups");
505    fs::create_dir_all(&backups_dir).map_err(|source| VdirError::ReadPath {
506        path: backups_dir.clone(),
507        source,
508    })?;
509
510    let source_name = source_path
511        .file_name()
512        .map(|value| value.to_string_lossy().to_string())
513        .ok_or_else(|| VdirError::InvalidEventPath(source_path.to_path_buf()))?;
514    let backup_name = format!("{}-{source_name}", unique_token());
515    let destination = unique_destination_path(&backups_dir, &backup_name);
516    fs::copy(source_path, &destination).map_err(|source| VdirError::ReadPath {
517        path: source_path.to_path_buf(),
518        source,
519    })?;
520    Ok(destination)
521}
522
523fn sanitize_file_stem(raw: &str) -> String {
524    let sanitized: String = raw
525        .trim()
526        .chars()
527        .map(|character| {
528            if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
529                character
530            } else {
531                '_'
532            }
533        })
534        .collect();
535    if sanitized.is_empty() {
536        format!("event-{}", unique_token())
537    } else {
538        sanitized
539    }
540}
541
542fn unique_destination_path(destination_dir: &Path, desired_name: &str) -> PathBuf {
543    let mut candidate = destination_dir.join(desired_name);
544    if !candidate.exists() {
545        return candidate;
546    }
547
548    let unique = unique_token();
549    candidate = destination_dir.join(format!("{desired_name}.m{unique}"));
550    if !candidate.exists() {
551        return candidate;
552    }
553
554    let mut counter = 1usize;
555    loop {
556        let next = destination_dir.join(format!("{desired_name}.m{unique}.{counter}"));
557        if !next.exists() {
558            return next;
559        }
560        counter += 1;
561    }
562}
563
564fn unique_token() -> u128 {
565    std::time::SystemTime::now()
566        .duration_since(std::time::UNIX_EPOCH)
567        .map(|duration| duration.as_nanos())
568        .unwrap_or(0)
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574    use cardinal_core::calendar::{Dirty, EventEdit};
575    use std::time::{SystemTime, UNIX_EPOCH};
576
577    fn fixture_root() -> PathBuf {
578        Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/calendars")
579    }
580
581    fn create_temp_vdir_root(prefix: &str) -> PathBuf {
582        let unique = SystemTime::now()
583            .duration_since(UNIX_EPOCH)
584            .expect("clock should move forward")
585            .as_nanos();
586        let root = std::env::temp_dir().join(format!("cardinal_vdir_{prefix}_{unique}"));
587        fs::create_dir_all(root.join("personal")).expect("create personal calendar");
588        fs::create_dir_all(root.join("work")).expect("create work calendar");
589        root
590    }
591
592    fn write_sample_event(path: &Path, uid: &str, summary: &str) {
593        let raw = format!(
594            "\
595BEGIN:VCALENDAR
596VERSION:2.0
597BEGIN:VEVENT
598UID:{uid}
599DTSTART:20260511T160000Z
600DTEND:20260511T170000Z
601SUMMARY:{summary}
602STATUS:CONFIRMED
603END:VEVENT
604END:VCALENDAR
605"
606        );
607        fs::write(path, raw).expect("write sample event");
608    }
609
610    #[test]
611    fn loads_fixture_vdir_tree() {
612        let snapshot = load_vdir(&fixture_root(), "local").expect("fixture vdir should load");
613        assert_eq!(snapshot.calendars.len(), 1);
614        let calendar = &snapshot.calendars[0];
615        assert_eq!(calendar.calendar.name, "personal");
616        assert_eq!(calendar.events.len(), 1);
617        assert!(calendar.events[0].event.title.contains("Interview"));
618        assert!(calendar.events[0].source_path.ends_with("interview.ics"));
619    }
620
621    #[test]
622    fn loads_direct_calendar_directory() {
623        let root = fixture_root().join("personal");
624        let snapshot = load_vdir(&root, "local").expect("single calendar dir should load");
625        assert_eq!(snapshot.calendars.len(), 1);
626        assert_eq!(snapshot.calendars[0].calendar.name, "personal");
627        assert_eq!(snapshot.calendars[0].events.len(), 1);
628    }
629
630    #[test]
631    fn reports_missing_root() {
632        let missing = Path::new("/tmp/cardinal-vdir-missing-root-does-not-exist");
633        let error = load_vdir(missing, "local").expect_err("missing root should error");
634        assert!(matches!(error, VdirError::RootMissing(_)));
635    }
636
637    #[test]
638    fn marks_rrule_as_recurring_without_expansion() {
639        let unique = SystemTime::now()
640            .duration_since(UNIX_EPOCH)
641            .expect("clock should move forward")
642            .as_nanos();
643        let root = std::env::temp_dir().join(format!("cardinal_vdir_{unique}"));
644        let calendar_dir = root.join("work");
645        fs::create_dir_all(&calendar_dir).expect("create vdir calendar");
646
647        let recurring = "\
648BEGIN:VCALENDAR
649VERSION:2.0
650BEGIN:VEVENT
651UID:recurring-1@example.com
652DTSTART:20260510T130000Z
653DTEND:20260510T133000Z
654SUMMARY:Daily standup
655RRULE:FREQ=DAILY;COUNT=3
656STATUS:CONFIRMED
657END:VEVENT
658END:VCALENDAR
659";
660        fs::write(calendar_dir.join("recurring.ics"), recurring).expect("write recurring file");
661
662        let snapshot = load_vdir(&root, "local").expect("temp vdir should load");
663        let calendar = snapshot
664            .calendars
665            .iter()
666            .find(|calendar| calendar.calendar.name == "work")
667            .expect("work calendar should exist");
668        assert_eq!(calendar.events.len(), 1);
669        assert!(calendar.events[0].recurring);
670
671        fs::remove_dir_all(&root).expect("cleanup temp vdir");
672    }
673
674    #[test]
675    fn create_event_writes_ics_into_calendar() {
676        let root = create_temp_vdir_root("create");
677        let write = EventEdit::<Dirty>::new(EventId("new-1".to_owned()), "work")
678            .with_title("Planning")
679            .with_starts_at("20260515T090000Z")
680            .with_ends_at("20260515T093000Z")
681            .validate()
682            .expect("event should validate")
683            .prepare_atomic_write();
684
685        let path = create_event(&root, write).expect("create event");
686        assert!(path.exists());
687        let raw = fs::read_to_string(path).expect("read created event");
688        assert!(raw.contains("SUMMARY:Planning"));
689        assert!(raw.contains("UID:new-1"));
690
691        fs::remove_dir_all(&root).expect("cleanup temp vdir");
692    }
693
694    #[test]
695    fn update_event_creates_backup_before_atomic_overwrite() {
696        let root = create_temp_vdir_root("edit");
697        let source = root.join("personal/interview.ics");
698        write_sample_event(&source, "evt-1@example.com", "Interview");
699
700        let write = EventEdit::<Dirty>::new(EventId("evt-1@example.com".to_owned()), "personal")
701            .with_title("Interview (edited)")
702            .with_starts_at("20260511T160000Z")
703            .with_ends_at("20260511T173000Z")
704            .with_description(Some("Edited via Cardinal".to_owned()))
705            .validate()
706            .expect("event should validate")
707            .prepare_atomic_write();
708
709        let result = update_event(&root, &source, write).expect("update event");
710        assert_eq!(result.path, source);
711        assert!(result.backup_path.exists());
712
713        let updated = fs::read_to_string(&source).expect("updated event should exist");
714        assert!(updated.contains("SUMMARY:Interview (edited)"));
715        let backup = fs::read_to_string(&result.backup_path).expect("backup should exist");
716        assert!(backup.contains("SUMMARY:Interview"));
717
718        fs::remove_dir_all(&root).expect("cleanup temp vdir");
719    }
720
721    #[test]
722    fn delete_event_keeps_backup_copy() {
723        let root = create_temp_vdir_root("delete");
724        let source = root.join("personal/interview.ics");
725        write_sample_event(&source, "evt-1@example.com", "Interview");
726
727        let backup = delete_event(&root, &source).expect("delete event should keep backup");
728        assert!(!source.exists());
729        assert!(backup.exists());
730        let backup_raw = fs::read_to_string(backup).expect("backup should be readable");
731        assert!(backup_raw.contains("SUMMARY:Interview"));
732
733        fs::remove_dir_all(&root).expect("cleanup temp vdir");
734    }
735
736    #[test]
737    fn move_event_moves_ics_to_target_calendar() {
738        let root = create_temp_vdir_root("move");
739        let source = root.join("personal/interview.ics");
740        write_sample_event(&source, "evt-1@example.com", "Interview");
741
742        let destination = move_event(&root, &source, "work").expect("move event");
743        assert!(!source.exists());
744        assert!(destination.exists());
745        assert!(destination.starts_with(root.join("work")));
746
747        fs::remove_dir_all(&root).expect("cleanup temp vdir");
748    }
749}