use std::fs;
use std::path::{Component, Path, PathBuf};
use cardinal_core::calendar::{
Atomic, Calendar, CalendarEvent, CalendarWrite, EventEdit, EventId, EventStatus, Validated,
};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VdirEvent {
pub event: CalendarEvent,
pub recurring: bool,
pub source_path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VdirCalendar {
pub calendar: Calendar,
pub events: Vec<VdirEvent>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VdirSnapshot {
pub calendars: Vec<VdirCalendar>,
}
#[derive(Debug, Error)]
pub enum VdirError {
#[error("vdir root does not exist: {0}")]
RootMissing(PathBuf),
#[error("failed to read filesystem path {path}: {source}")]
ReadPath {
path: PathBuf,
source: std::io::Error,
},
#[error("vdir event path must be inside root {root}: {path}")]
PathOutsideRoot { root: PathBuf, path: PathBuf },
#[error("vdir calendar name must be a relative folder name: {0}")]
InvalidCalendarName(String),
#[error("vdir event path must point to an .ics file: {0}")]
InvalidEventPath(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CalendarPath {
name: String,
path: PathBuf,
}
pub fn load_vdir(root: &Path, account: &str) -> Result<VdirSnapshot, VdirError> {
if !root.exists() {
return Err(VdirError::RootMissing(root.to_path_buf()));
}
let mut calendars = Vec::new();
for calendar_path in discover_calendars(root, account)? {
let events = load_calendar_events(&calendar_path.path, &calendar_path.name)?;
let calendar = Calendar {
account: account.to_owned(),
name: calendar_path.name,
path: Some(calendar_path.path.to_string_lossy().to_string()),
};
calendars.push(VdirCalendar { calendar, events });
}
calendars.sort_by(|left, right| left.calendar.name.cmp(&right.calendar.name));
Ok(VdirSnapshot { calendars })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EventWriteResult {
pub path: PathBuf,
pub backup_path: PathBuf,
}
pub fn create_event(root: &Path, write: CalendarWrite<Atomic>) -> Result<PathBuf, VdirError> {
ensure_root_exists(root)?;
let event = write.into_event();
let calendar_dir = resolve_calendar_dir(root, &event.calendar)?;
fs::create_dir_all(&calendar_dir).map_err(|source| VdirError::ReadPath {
path: calendar_dir.clone(),
source,
})?;
let file_name = format!("{}.ics", sanitize_file_stem(&event.id.0));
let destination = unique_destination_path(&calendar_dir, &file_name);
write_atomic_file(&destination, &render_event_ics(&event))?;
Ok(destination)
}
pub fn update_event(
root: &Path,
source_path: &Path,
write: CalendarWrite<Atomic>,
) -> Result<EventWriteResult, VdirError> {
ensure_root_exists(root)?;
let source_path = normalize_event_source(root, source_path)?;
let backup_path = backup_event_file(root, &source_path)?;
let event = write.into_event();
write_atomic_file(&source_path, &render_event_ics(&event))?;
Ok(EventWriteResult {
path: source_path,
backup_path,
})
}
pub fn delete_event(root: &Path, source_path: &Path) -> Result<PathBuf, VdirError> {
ensure_root_exists(root)?;
let source_path = normalize_event_source(root, source_path)?;
let backup_path = backup_event_file(root, &source_path)?;
fs::remove_file(&source_path).map_err(|source| VdirError::ReadPath {
path: source_path.clone(),
source,
})?;
Ok(backup_path)
}
pub fn move_event(
root: &Path,
source_path: &Path,
target_calendar: &str,
) -> Result<PathBuf, VdirError> {
ensure_root_exists(root)?;
let source_path = normalize_event_source(root, source_path)?;
let destination_calendar = resolve_calendar_dir(root, target_calendar)?;
fs::create_dir_all(&destination_calendar).map_err(|source| VdirError::ReadPath {
path: destination_calendar.clone(),
source,
})?;
if source_path
.parent()
.is_some_and(|parent| parent == destination_calendar.as_path())
{
return Ok(source_path);
}
let source_name = source_path
.file_name()
.map(|value| value.to_string_lossy().to_string())
.ok_or_else(|| VdirError::InvalidEventPath(source_path.clone()))?;
let destination = unique_destination_path(&destination_calendar, &source_name);
fs::rename(&source_path, &destination).map_err(|source| VdirError::ReadPath {
path: source_path,
source,
})?;
Ok(destination)
}
fn discover_calendars(root: &Path, account: &str) -> Result<Vec<CalendarPath>, VdirError> {
let mut calendars = Vec::new();
if contains_ics_files(root)? {
let name = root
.file_name()
.map(|value| value.to_string_lossy().to_string())
.unwrap_or_else(|| account.to_owned());
calendars.push(CalendarPath {
name,
path: root.to_path_buf(),
});
}
let entries = fs::read_dir(root).map_err(|source| VdirError::ReadPath {
path: root.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| VdirError::ReadPath {
path: root.to_path_buf(),
source,
})?;
let child = entry.path();
if !child.is_dir() {
continue;
}
if contains_ics_files(&child)? {
calendars.push(CalendarPath {
name: entry.file_name().to_string_lossy().to_string(),
path: child,
});
}
}
calendars.sort_by(|left, right| left.name.cmp(&right.name));
Ok(calendars)
}
fn contains_ics_files(path: &Path) -> Result<bool, VdirError> {
let entries = fs::read_dir(path).map_err(|source| VdirError::ReadPath {
path: path.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| VdirError::ReadPath {
path: path.to_path_buf(),
source,
})?;
let child = entry.path();
if child.is_file()
&& child
.extension()
.is_some_and(|extension| extension.to_string_lossy().eq_ignore_ascii_case("ics"))
{
return Ok(true);
}
}
Ok(false)
}
fn load_calendar_events(path: &Path, calendar_name: &str) -> Result<Vec<VdirEvent>, VdirError> {
let mut files = Vec::new();
let entries = fs::read_dir(path).map_err(|source| VdirError::ReadPath {
path: path.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| VdirError::ReadPath {
path: path.to_path_buf(),
source,
})?;
let child = entry.path();
if child.is_file()
&& child
.extension()
.is_some_and(|extension| extension.to_string_lossy().eq_ignore_ascii_case("ics"))
{
files.push(child);
}
}
files.sort();
let mut events = Vec::new();
for file in files {
let mut parsed = parse_ics_file(&file, calendar_name)?;
events.append(&mut parsed);
}
events.sort_by(|left, right| {
left.event
.starts_at
.cmp(&right.event.starts_at)
.then_with(|| left.event.title.cmp(&right.event.title))
});
Ok(events)
}
fn parse_ics_file(path: &Path, calendar_name: &str) -> Result<Vec<VdirEvent>, VdirError> {
let raw = fs::read_to_string(path).map_err(|source| VdirError::ReadPath {
path: path.to_path_buf(),
source,
})?;
let normalized = raw.replace("\r\n", "\n");
let lines = unfold_lines(&normalized);
let mut events = Vec::new();
let mut current = Vec::new();
let mut in_event = false;
let mut index = 0usize;
for line in lines {
if line == "BEGIN:VEVENT" {
in_event = true;
current.clear();
continue;
}
if line == "END:VEVENT" {
if in_event {
index += 1;
if let Some(event) = parse_event_lines(path, calendar_name, index, ¤t) {
events.push(event);
}
}
in_event = false;
current.clear();
continue;
}
if in_event {
current.push(line);
}
}
Ok(events)
}
fn unfold_lines(raw: &str) -> Vec<String> {
let mut unfolded: Vec<String> = Vec::new();
for line in raw.lines() {
if (line.starts_with(' ') || line.starts_with('\t')) && !unfolded.is_empty() {
let previous = unfolded.last_mut().expect("checked is_empty");
previous.push_str(line.trim_start());
} else {
unfolded.push(line.trim_end().to_owned());
}
}
unfolded
}
fn parse_event_lines(
path: &Path,
calendar_name: &str,
index: usize,
lines: &[String],
) -> Option<VdirEvent> {
let mut uid = None;
let mut summary = None;
let mut starts_at = None;
let mut ends_at = None;
let mut location = None;
let mut description = None;
let mut status = None;
let mut recurring = false;
for line in lines {
let Some((key, value)) = parse_property(line) else {
continue;
};
match key {
"UID" => uid = Some(value.to_owned()),
"SUMMARY" => summary = Some(value.to_owned()),
"DTSTART" => starts_at = Some(value.to_owned()),
"DTEND" => ends_at = Some(value.to_owned()),
"LOCATION" => location = Some(value.to_owned()),
"DESCRIPTION" => description = Some(value.replace("\\n", "\n")),
"STATUS" => status = Some(value.to_owned()),
"RRULE" => recurring = true,
_ => {}
}
}
let starts_at = starts_at?;
let default_id = format!(
"{}-event-{index}",
path.file_stem()
.map(|stem| stem.to_string_lossy().to_string())
.unwrap_or_else(|| "ics".to_owned())
);
let id = uid.unwrap_or(default_id);
let title = summary.unwrap_or_else(|| "Untitled event".to_owned());
let event = CalendarEvent {
id: EventId(id),
calendar: calendar_name.to_owned(),
title,
starts_at,
ends_at: ends_at.unwrap_or_default(),
location: location.filter(|value| !value.is_empty()),
description: description.filter(|value| !value.is_empty()),
status: parse_status(status.as_deref()),
};
Some(VdirEvent {
event,
recurring,
source_path: path.to_path_buf(),
})
}
fn parse_property(line: &str) -> Option<(&str, &str)> {
let (left, value) = line.split_once(':')?;
let key = left.split(';').next().unwrap_or(left);
Some((key.trim(), value.trim()))
}
fn parse_status(status: Option<&str>) -> EventStatus {
let normalized = status.unwrap_or("UNKNOWN").to_ascii_uppercase();
match normalized.as_str() {
"CONFIRMED" => EventStatus::Confirmed,
"TENTATIVE" => EventStatus::Tentative,
"CANCELLED" => EventStatus::Cancelled,
_ => EventStatus::Unknown,
}
}
fn ensure_root_exists(root: &Path) -> Result<(), VdirError> {
if !root.exists() {
return Err(VdirError::RootMissing(root.to_path_buf()));
}
Ok(())
}
fn normalize_event_source(root: &Path, source_path: &Path) -> Result<PathBuf, VdirError> {
if !source_path.is_file()
|| !source_path
.extension()
.is_some_and(|extension| extension.to_string_lossy().eq_ignore_ascii_case("ics"))
{
return Err(VdirError::InvalidEventPath(source_path.to_path_buf()));
}
let root = root.canonicalize().map_err(|source| VdirError::ReadPath {
path: root.to_path_buf(),
source,
})?;
let source = source_path
.canonicalize()
.map_err(|source| VdirError::ReadPath {
path: source_path.to_path_buf(),
source,
})?;
if !source.starts_with(&root) {
return Err(VdirError::PathOutsideRoot { root, path: source });
}
Ok(source)
}
fn resolve_calendar_dir(root: &Path, calendar: &str) -> Result<PathBuf, VdirError> {
let calendar = calendar.trim();
if calendar.is_empty() {
return Err(VdirError::InvalidCalendarName(calendar.to_owned()));
}
let calendar_path = Path::new(calendar);
if calendar_path.is_absolute() {
return Err(VdirError::InvalidCalendarName(calendar.to_owned()));
}
for component in calendar_path.components() {
if !matches!(component, Component::Normal(_)) {
return Err(VdirError::InvalidCalendarName(calendar.to_owned()));
}
}
if contains_ics_files(root)? {
let root_name_matches = root.file_name().is_some_and(|name| {
name.to_string_lossy()
.eq_ignore_ascii_case(calendar_path.as_os_str().to_string_lossy().as_ref())
});
if root_name_matches {
return Ok(root.to_path_buf());
}
}
Ok(root.join(calendar_path))
}
fn render_event_ics(event: &EventEdit<Validated>) -> String {
let mut lines = Vec::new();
lines.push("BEGIN:VCALENDAR".to_owned());
lines.push("VERSION:2.0".to_owned());
lines.push("PRODID:-//Cardinal//EN".to_owned());
lines.push("BEGIN:VEVENT".to_owned());
lines.push(format!("UID:{}", escape_ics_text(&event.id.0)));
lines.push(format!("DTSTART:{}", event.starts_at));
if !event.ends_at.trim().is_empty() {
lines.push(format!("DTEND:{}", event.ends_at));
}
lines.push(format!("SUMMARY:{}", escape_ics_text(&event.title)));
lines.push(format!(
"STATUS:{}",
event_status_value(event.status.clone())
));
if let Some(location) = event.location.as_deref() {
lines.push(format!("LOCATION:{}", escape_ics_text(location)));
}
if let Some(description) = event.description.as_deref() {
lines.push(format!("DESCRIPTION:{}", escape_ics_text(description)));
}
lines.push("END:VEVENT".to_owned());
lines.push("END:VCALENDAR".to_owned());
lines.push(String::new());
lines.join("\n")
}
fn escape_ics_text(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace(',', "\\,")
.replace(';', "\\;")
}
fn event_status_value(status: EventStatus) -> &'static str {
match status {
EventStatus::Confirmed => "CONFIRMED",
EventStatus::Tentative => "TENTATIVE",
EventStatus::Cancelled => "CANCELLED",
EventStatus::Unknown => "UNKNOWN",
}
}
fn write_atomic_file(destination: &Path, content: &str) -> Result<(), VdirError> {
let parent = destination
.parent()
.ok_or_else(|| VdirError::InvalidEventPath(destination.to_path_buf()))?;
fs::create_dir_all(parent).map_err(|source| VdirError::ReadPath {
path: parent.to_path_buf(),
source,
})?;
let file_name = destination
.file_name()
.map(|value| value.to_string_lossy().to_string())
.ok_or_else(|| VdirError::InvalidEventPath(destination.to_path_buf()))?;
let temporary = parent.join(format!(".{file_name}.tmp{}", unique_token()));
fs::write(&temporary, content).map_err(|source| VdirError::ReadPath {
path: temporary.clone(),
source,
})?;
fs::rename(&temporary, destination).map_err(|source| VdirError::ReadPath {
path: destination.to_path_buf(),
source,
})?;
Ok(())
}
fn backup_event_file(root: &Path, source_path: &Path) -> Result<PathBuf, VdirError> {
let backups_dir = root.join(".cardinal-backups");
fs::create_dir_all(&backups_dir).map_err(|source| VdirError::ReadPath {
path: backups_dir.clone(),
source,
})?;
let source_name = source_path
.file_name()
.map(|value| value.to_string_lossy().to_string())
.ok_or_else(|| VdirError::InvalidEventPath(source_path.to_path_buf()))?;
let backup_name = format!("{}-{source_name}", unique_token());
let destination = unique_destination_path(&backups_dir, &backup_name);
fs::copy(source_path, &destination).map_err(|source| VdirError::ReadPath {
path: source_path.to_path_buf(),
source,
})?;
Ok(destination)
}
fn sanitize_file_stem(raw: &str) -> String {
let sanitized: String = raw
.trim()
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
character
} else {
'_'
}
})
.collect();
if sanitized.is_empty() {
format!("event-{}", unique_token())
} else {
sanitized
}
}
fn unique_destination_path(destination_dir: &Path, desired_name: &str) -> PathBuf {
let mut candidate = destination_dir.join(desired_name);
if !candidate.exists() {
return candidate;
}
let unique = unique_token();
candidate = destination_dir.join(format!("{desired_name}.m{unique}"));
if !candidate.exists() {
return candidate;
}
let mut counter = 1usize;
loop {
let next = destination_dir.join(format!("{desired_name}.m{unique}.{counter}"));
if !next.exists() {
return next;
}
counter += 1;
}
}
fn unique_token() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use cardinal_core::calendar::{Dirty, EventEdit};
use std::time::{SystemTime, UNIX_EPOCH};
fn fixture_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/calendars")
}
fn create_temp_vdir_root(prefix: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should move forward")
.as_nanos();
let root = std::env::temp_dir().join(format!("cardinal_vdir_{prefix}_{unique}"));
fs::create_dir_all(root.join("personal")).expect("create personal calendar");
fs::create_dir_all(root.join("work")).expect("create work calendar");
root
}
fn write_sample_event(path: &Path, uid: &str, summary: &str) {
let raw = format!(
"\
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:{uid}
DTSTART:20260511T160000Z
DTEND:20260511T170000Z
SUMMARY:{summary}
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR
"
);
fs::write(path, raw).expect("write sample event");
}
#[test]
fn loads_fixture_vdir_tree() {
let snapshot = load_vdir(&fixture_root(), "local").expect("fixture vdir should load");
assert_eq!(snapshot.calendars.len(), 1);
let calendar = &snapshot.calendars[0];
assert_eq!(calendar.calendar.name, "personal");
assert_eq!(calendar.events.len(), 1);
assert!(calendar.events[0].event.title.contains("Interview"));
assert!(calendar.events[0].source_path.ends_with("interview.ics"));
}
#[test]
fn loads_direct_calendar_directory() {
let root = fixture_root().join("personal");
let snapshot = load_vdir(&root, "local").expect("single calendar dir should load");
assert_eq!(snapshot.calendars.len(), 1);
assert_eq!(snapshot.calendars[0].calendar.name, "personal");
assert_eq!(snapshot.calendars[0].events.len(), 1);
}
#[test]
fn reports_missing_root() {
let missing = Path::new("/tmp/cardinal-vdir-missing-root-does-not-exist");
let error = load_vdir(missing, "local").expect_err("missing root should error");
assert!(matches!(error, VdirError::RootMissing(_)));
}
#[test]
fn marks_rrule_as_recurring_without_expansion() {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should move forward")
.as_nanos();
let root = std::env::temp_dir().join(format!("cardinal_vdir_{unique}"));
let calendar_dir = root.join("work");
fs::create_dir_all(&calendar_dir).expect("create vdir calendar");
let recurring = "\
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:recurring-1@example.com
DTSTART:20260510T130000Z
DTEND:20260510T133000Z
SUMMARY:Daily standup
RRULE:FREQ=DAILY;COUNT=3
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR
";
fs::write(calendar_dir.join("recurring.ics"), recurring).expect("write recurring file");
let snapshot = load_vdir(&root, "local").expect("temp vdir should load");
let calendar = snapshot
.calendars
.iter()
.find(|calendar| calendar.calendar.name == "work")
.expect("work calendar should exist");
assert_eq!(calendar.events.len(), 1);
assert!(calendar.events[0].recurring);
fs::remove_dir_all(&root).expect("cleanup temp vdir");
}
#[test]
fn create_event_writes_ics_into_calendar() {
let root = create_temp_vdir_root("create");
let write = EventEdit::<Dirty>::new(EventId("new-1".to_owned()), "work")
.with_title("Planning")
.with_starts_at("20260515T090000Z")
.with_ends_at("20260515T093000Z")
.validate()
.expect("event should validate")
.prepare_atomic_write();
let path = create_event(&root, write).expect("create event");
assert!(path.exists());
let raw = fs::read_to_string(path).expect("read created event");
assert!(raw.contains("SUMMARY:Planning"));
assert!(raw.contains("UID:new-1"));
fs::remove_dir_all(&root).expect("cleanup temp vdir");
}
#[test]
fn update_event_creates_backup_before_atomic_overwrite() {
let root = create_temp_vdir_root("edit");
let source = root.join("personal/interview.ics");
write_sample_event(&source, "evt-1@example.com", "Interview");
let write = EventEdit::<Dirty>::new(EventId("evt-1@example.com".to_owned()), "personal")
.with_title("Interview (edited)")
.with_starts_at("20260511T160000Z")
.with_ends_at("20260511T173000Z")
.with_description(Some("Edited via Cardinal".to_owned()))
.validate()
.expect("event should validate")
.prepare_atomic_write();
let result = update_event(&root, &source, write).expect("update event");
assert_eq!(result.path, source);
assert!(result.backup_path.exists());
let updated = fs::read_to_string(&source).expect("updated event should exist");
assert!(updated.contains("SUMMARY:Interview (edited)"));
let backup = fs::read_to_string(&result.backup_path).expect("backup should exist");
assert!(backup.contains("SUMMARY:Interview"));
fs::remove_dir_all(&root).expect("cleanup temp vdir");
}
#[test]
fn delete_event_keeps_backup_copy() {
let root = create_temp_vdir_root("delete");
let source = root.join("personal/interview.ics");
write_sample_event(&source, "evt-1@example.com", "Interview");
let backup = delete_event(&root, &source).expect("delete event should keep backup");
assert!(!source.exists());
assert!(backup.exists());
let backup_raw = fs::read_to_string(backup).expect("backup should be readable");
assert!(backup_raw.contains("SUMMARY:Interview"));
fs::remove_dir_all(&root).expect("cleanup temp vdir");
}
#[test]
fn move_event_moves_ics_to_target_calendar() {
let root = create_temp_vdir_root("move");
let source = root.join("personal/interview.ics");
write_sample_event(&source, "evt-1@example.com", "Interview");
let destination = move_event(&root, &source, "work").expect("move event");
assert!(!source.exists());
assert!(destination.exists());
assert!(destination.starts_with(root.join("work")));
fs::remove_dir_all(&root).expect("cleanup temp vdir");
}
}