use crate::config::CalendarConfig;
use crate::types::Note;
use anyhow::{Context, Result};
use ical::IcalParser;
use std::io::BufReader;
pub struct CalDavClient {
config: CalendarConfig,
client: reqwest::Client,
}
pub struct CalDavEventInfo {
pub uid: String,
pub summary: String,
}
impl CalDavClient {
pub fn new(config: CalendarConfig) -> Self {
// `reqwest::Client::builder().build()` can only fail if the TLS backend can't be
// initialised; fall back to `Client::new()` semantics rather than panicking.
let client = reqwest::Client::builder()
.build()
.unwrap_or_else(|e| {
tracing::warn!("falling back to default HTTP client: {}", e);
reqwest::Client::new()
});
CalDavClient { config, client }
}
pub async fn test_connection(&self) -> Result<()> {
let body = r#""#;
let resp = self
.client
.request(
reqwest::Method::from_bytes(b"PROPFIND").unwrap(),
&self.config.url,
)
.basic_auth(&self.config.username, Some(&self.config.password))
.header("Depth", "0")
.header("Content-Type", "application/xml; charset=utf-8")
.body(body)
.send()
.await
.context("CalDAV PROPFIND request failed")?;
let status = resp.status();
if status.is_success() || status.as_u16() == 207 {
Ok(())
} else {
anyhow::bail!("CalDAV server returned {}", status);
}
}
pub async fn push_event(&self, note: &Note) -> Result {
let uid = caldav_uid(note);
let ical = build_ical(note, &uid);
let url = event_url(&self.config.url, &uid);
let resp = self
.client
.put(&url)
.basic_auth(&self.config.username, Some(&self.config.password))
.header("Content-Type", "text/calendar; charset=utf-8")
.body(ical)
.send()
.await
.context("CalDAV PUT request failed")?;
let status = resp.status();
if status.is_success() || status.as_u16() == 201 || status.as_u16() == 204 {
Ok(uid)
} else {
anyhow::bail!("CalDAV PUT returned {}", status);
}
}
pub async fn delete_event(&self, uid: &str) -> Result<()> {
let url = event_url(&self.config.url, uid);
let resp = self
.client
.delete(&url)
.basic_auth(&self.config.username, Some(&self.config.password))
.send()
.await
.context("CalDAV DELETE request failed")?;
let status = resp.status();
if status.is_success() || status.as_u16() == 204 || status.as_u16() == 404 {
Ok(())
} else {
anyhow::bail!("CalDAV DELETE returned {}", status);
}
}
pub async fn list_events(&self) -> Result> {
let body = r#"
"#;
let resp = self
.client
.request(
reqwest::Method::from_bytes(b"REPORT").unwrap(),
&self.config.url,
)
.basic_auth(&self.config.username, Some(&self.config.password))
.header("Depth", "1")
.header("Content-Type", "application/xml; charset=utf-8")
.body(body)
.send()
.await
.context("CalDAV REPORT request failed")?;
let status = resp.status();
if !status.is_success() && status.as_u16() != 207 {
anyhow::bail!("CalDAV REPORT returned {}", status);
}
let xml = resp.text().await.context("failed to read CalDAV REPORT body")?;
parse_report_response(&xml)
}
}
pub fn caldav_uid(note: &Note) -> String {
note.caldav_uid
.clone()
.unwrap_or_else(|| format!("{}@breadpad", note.id))
}
fn event_url(base: &str, uid: &str) -> String {
format!("{}/{}.ics", base.trim_end_matches('/'), uid)
}
fn build_ical(note: &Note, uid: &str) -> String {
let dt = note.time.unwrap_or(note.created);
let dtstart = dt.format("%Y%m%dT%H%M%SZ").to_string();
let dtstamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
let mut lines: Vec = vec![
"BEGIN:VCALENDAR".into(),
"VERSION:2.0".into(),
"PRODID:-//breadpad//EN".into(),
"BEGIN:VEVENT".into(),
format!("UID:{}", uid),
fold_line(&format!("SUMMARY:{}", escape_ical(¬e.body))),
format!("DTSTART:{}", dtstart),
format!("DTEND:{}", dtstart),
format!("DTSTAMP:{}", dtstamp),
fold_line(&format!("DESCRIPTION:{}", escape_ical(&format!("type={}", note.note_type.as_str())))),
];
if let Some(rrule) = ¬e.rrule {
lines.push(rrule.as_str().to_string());
}
lines.push("END:VEVENT".into());
lines.push("END:VCALENDAR".into());
lines.join("\r\n") + "\r\n"
}
/// Fold an iCal property line per RFC 5545 ยง3.1: lines longer than 75 octets
/// are split with CRLF + a single space continuation character.
fn fold_line(line: &str) -> String {
let bytes = line.as_bytes();
if bytes.len() <= 75 {
return line.to_string();
}
let mut out = String::with_capacity(line.len() + line.len() / 75 * 3);
let mut pos = 0;
let mut first = true;
while pos < bytes.len() {
if !first {
out.push_str("\r\n ");
}
let limit = if first { 75 } else { 74 }; // continuation lines lose 1 octet to the space
let mut end = (pos + limit).min(bytes.len());
// Step back if we landed in the middle of a multi-byte UTF-8 sequence.
while end > pos && end < bytes.len() && (bytes[end] & 0xC0) == 0x80 {
end -= 1;
}
out.push_str(std::str::from_utf8(&bytes[pos..end]).unwrap_or(""));
pos = end;
first = false;
}
out
}
fn escape_ical(s: &str) -> String {
s.replace('\\', "\\\\")
.replace(';', "\\;")
.replace(',', "\\,")
.replace('\n', "\\n")
}
fn parse_report_response(xml: &str) -> Result> {
let mut events = Vec::new();
let mut search_from = 0;
while let Some(start) = xml[search_from..].find("BEGIN:VCALENDAR") {
let abs_start = search_from + start;
let tail = &xml[abs_start..];
let end = match tail.find("END:VCALENDAR") {
Some(e) => abs_start + e + "END:VCALENDAR".len(),
None => break,
};
let ical_block = &xml[abs_start..end];
events.extend(parse_ical_block(ical_block));
search_from = end;
}
Ok(events)
}
fn parse_ical_block(data: &str) -> Vec {
let reader = BufReader::new(data.as_bytes());
let parser = IcalParser::new(reader);
let mut out = Vec::new();
for item in parser {
match item {
Ok(cal) => {
for event in cal.events {
let uid = event
.properties
.iter()
.find(|p| p.name == "UID")
.and_then(|p| p.value.clone())
.unwrap_or_default();
let summary = event
.properties
.iter()
.find(|p| p.name == "SUMMARY")
.and_then(|p| p.value.clone())
.unwrap_or_default();
out.push(CalDavEventInfo { uid, summary });
}
}
Err(e) => tracing::warn!("CalDAV: failed to parse VCALENDAR block: {}", e),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Note, NoteType, RecurrenceRule};
use chrono::{TimeZone, Utc};
fn reminder(body: &str) -> Note {
let mut n = Note::new(body.into(), NoteType::Reminder, None);
n.time = Some(Utc::now());
n
}
// ---- escape_ical ----
#[test]
fn escape_ical_clean_string_unchanged() {
assert_eq!(escape_ical("hello world"), "hello world");
}
#[test]
fn escape_ical_empty_string() {
assert_eq!(escape_ical(""), "");
}
#[test]
fn escape_ical_escapes_backslash() {
assert_eq!(escape_ical("back\\slash"), "back\\\\slash");
}
#[test]
fn escape_ical_escapes_semicolon() {
assert_eq!(escape_ical("a;b"), "a\\;b");
}
#[test]
fn escape_ical_escapes_comma() {
assert_eq!(escape_ical("apples,oranges"), "apples\\,oranges");
}
#[test]
fn escape_ical_escapes_newline() {
assert_eq!(escape_ical("line1\nline2"), "line1\\nline2");
}
#[test]
fn escape_ical_multiple_special_chars() {
assert_eq!(escape_ical("a;b,c\nd"), "a\\;b\\,c\\nd");
}
// ---- caldav_uid ----
#[test]
fn caldav_uid_uses_existing_field() {
let mut n = Note::new("test".into(), NoteType::Reminder, None);
n.caldav_uid = Some("my-custom-uid".into());
assert_eq!(caldav_uid(&n), "my-custom-uid");
}
#[test]
fn caldav_uid_falls_back_to_id_at_breadpad() {
let n = Note::new("test".into(), NoteType::Reminder, None);
assert_eq!(caldav_uid(&n), format!("{}@breadpad", n.id));
}
// ---- event_url ----
#[test]
fn event_url_with_trailing_slash() {
let url = event_url("https://cloud.example.com/cal/", "abc@breadpad");
assert_eq!(url, "https://cloud.example.com/cal/abc@breadpad.ics");
}
#[test]
fn event_url_without_trailing_slash() {
let url = event_url("https://cloud.example.com/cal", "abc@breadpad");
assert_eq!(url, "https://cloud.example.com/cal/abc@breadpad.ics");
}
// ---- build_ical ----
#[test]
fn build_ical_contains_vcalendar_markers() {
let n = reminder("team sync");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("BEGIN:VCALENDAR"), "missing BEGIN:VCALENDAR");
assert!(ical.contains("END:VCALENDAR"), "missing END:VCALENDAR");
assert!(ical.contains("BEGIN:VEVENT"), "missing BEGIN:VEVENT");
assert!(ical.contains("END:VEVENT"), "missing END:VEVENT");
}
#[test]
fn build_ical_contains_uid() {
let n = reminder("team sync");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains(&format!("UID:{}", uid)));
}
#[test]
fn build_ical_contains_summary() {
let n = reminder("team sync");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("SUMMARY:team sync"));
}
#[test]
fn build_ical_description_contains_type() {
let n = reminder("team sync");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("DESCRIPTION:type=reminder"));
}
#[test]
fn build_ical_uses_note_time_for_dtstart() {
let mut n = Note::new("dentist".into(), NoteType::Reminder, None);
n.time = Some(Utc.with_ymd_and_hms(2026, 6, 15, 14, 30, 0).unwrap());
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("DTSTART:20260615T143000Z"), "ical: {}", &ical[..400]);
}
#[test]
fn build_ical_falls_back_to_created_when_no_time() {
let n = Note::new("no time set".into(), NoteType::Reminder, None);
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("DTSTART:"), "DTSTART should be present");
}
#[test]
fn build_ical_includes_rrule_when_set() {
let mut n = reminder("standup");
n.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0"));
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO"));
}
#[test]
fn build_ical_no_rrule_when_not_set() {
let n = reminder("one-off");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(!ical.contains("RRULE:"));
}
#[test]
fn build_ical_escapes_special_chars_in_summary() {
let n = Note::new("dentist; bring card, and ID".into(), NoteType::Reminder, None);
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("SUMMARY:dentist\\; bring card\\, and ID"), "ical: {}", &ical[..400]);
}
#[test]
fn build_ical_contains_dtstamp() {
let n = reminder("team sync");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("DTSTAMP:"), "missing DTSTAMP in:\n{}", ical);
}
#[test]
fn fold_line_short_unchanged() {
let line = "SUMMARY:short";
assert_eq!(fold_line(line), line);
}
#[test]
fn fold_line_exactly_75_unchanged() {
let line = "A".repeat(75);
assert_eq!(fold_line(&line), line);
}
#[test]
fn fold_line_76_chars_splits() {
let line = "X".repeat(76);
let folded = fold_line(&line);
assert!(folded.contains("\r\n "), "expected fold in: {:?}", folded);
// Reassembled content should equal the original.
let rejoined: String = folded.split("\r\n ").collect();
assert_eq!(rejoined, line);
}
#[test]
fn build_ical_long_summary_is_folded() {
let long_body = "a".repeat(200);
let n = Note::new(long_body.clone(), NoteType::Reminder, None);
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
// Every line (split on CRLF) must be at most 75 octets.
for line in ical.split("\r\n") {
assert!(
line.len() <= 75,
"line too long ({} octets): {:?}",
line.len(),
line
);
}
}
// ---- parse_report_response ----
#[test]
fn parse_report_response_empty_xml_returns_empty() {
let events = parse_report_response("").unwrap();
assert!(events.is_empty());
}
#[test]
fn parse_report_response_single_event() {
let xml = "\
BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
BEGIN:VEVENT\r\n\
UID:abc123@breadpad\r\n\
SUMMARY:team sync\r\n\
DTSTART:20260615T140000Z\r\n\
DTEND:20260615T140000Z\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let events = parse_report_response(xml).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].uid, "abc123@breadpad");
assert_eq!(events[0].summary, "team sync");
}
#[test]
fn parse_report_response_no_vcalendar_block_returns_empty() {
let xml = "HTTP/1.1 200 OK";
let events = parse_report_response(xml).unwrap();
assert!(events.is_empty());
}
}