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, ¤t) {
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}