Skip to content

Commit fd5bee3

Browse files
thoscutclaudepenso
authored
fix(caldav): honor list_events time ranges (#1147)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Fabien Penso <gpg@pen.so>
1 parent 99692e4 commit fd5bee3

6 files changed

Lines changed: 687 additions & 17 deletions

File tree

crates/agents/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ pub mod multimodal;
99
pub mod prompt;
1010
pub mod runner;
1111
pub mod tool_parsing;
12+
/// Re-export of the `time` crate so dependent crates can use the same
13+
/// date/time types without declaring their own dependency.
14+
pub use time;
1215
pub use {
1316
model::{ChatMessage, ContentPart, UserContent},
1417
runner::AgentRunError,

crates/caldav/src/client.rs

Lines changed: 225 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,68 @@ impl LibDavCalDavClient {
320320
}
321321
}
322322

323+
/// Build the server-side `calendar-query` REPORT that filters a collection
324+
/// by VEVENT time range.
325+
///
326+
/// Per RFC 4791 the `time-range` element must sit inside
327+
/// `comp-filter name="VEVENT"`, nested in `comp-filter name="VCALENDAR"` —
328+
/// servers (e.g. Nextcloud) silently ignore filters at the wrong level.
329+
/// `start`/`end` must be iCalendar UTC basic format (`YYYYMMDDTHHMMSSZ`).
330+
fn build_time_range_query<'a>(
331+
calendar_href: &'a str,
332+
start: &'a str,
333+
end: &'a str,
334+
) -> Result<libdav::caldav::ListCalendarResources<'a>> {
335+
libdav::caldav::ListCalendarResources::new(calendar_href)
336+
.with_component_and_time_range("VEVENT", Some(start), Some(end))
337+
.map_err(|e| Error::Validation(format!("invalid time-range filter: {e}")))
338+
}
339+
340+
struct GetExpandedCalendarResources<'a> {
341+
inner: libdav::caldav::GetCalendarResources<'a>,
342+
start: &'a str,
343+
end: &'a str,
344+
}
345+
346+
impl<'a> GetExpandedCalendarResources<'a> {
347+
fn new(collection_href: &'a str, hrefs: &[String], start: &'a str, end: &'a str) -> Self {
348+
Self {
349+
inner: libdav::caldav::GetCalendarResources::new(collection_href).with_hrefs(hrefs),
350+
start,
351+
end,
352+
}
353+
}
354+
}
355+
356+
impl libdav::requests::DavRequest for GetExpandedCalendarResources<'_> {
357+
type Error<E> = libdav::dav::WebDavError<E>;
358+
type ParseError = libdav::requests::ParseResponseError;
359+
type Response = libdav::caldav::GetCalendarResourcesResponse;
360+
361+
fn prepare_request(
362+
&self,
363+
) -> std::result::Result<libdav::requests::PreparedRequest, http::Error> {
364+
let mut request = self.inner.prepare_request()?;
365+
request.body = request.body.replacen(
366+
"<C:calendar-data/>",
367+
&format!(
368+
r#"<C:calendar-data><C:expand start="{}" end="{}"/></C:calendar-data>"#,
369+
self.start, self.end
370+
),
371+
1,
372+
);
373+
Ok(request)
374+
}
375+
376+
fn parse_response(
377+
&self,
378+
parts: &http::response::Parts,
379+
body: &[u8],
380+
) -> std::result::Result<Self::Response, Self::ParseError> {
381+
self.inner.parse_response(parts, body)
382+
}
383+
}
384+
323385
async fn resolve_uri_addresses(uri: &http::Uri) -> Result<Vec<SocketAddr>> {
324386
let host = uri
325387
.host()
@@ -445,16 +507,61 @@ impl CalDavClient for LibDavCalDavClient {
445507
async fn list_events(
446508
&self,
447509
calendar_href: &str,
448-
_range: Option<TimeRange>,
510+
range: Option<TimeRange>,
449511
) -> Result<Vec<EventSummary>> {
450-
// Fetch all calendar resources (iCal data + etags)
451-
let response = self
452-
.protocol(
453-
"fetch calendar resources",
454-
self.inner
455-
.request(libdav::caldav::GetCalendarResources::new(calendar_href)),
456-
)
457-
.await?;
512+
// With a range, ask the server which resources match first
513+
// (calendar-query REPORT with a VCALENDAR > VEVENT time-range
514+
// filter), then fetch only those via calendar-multiget. Without a
515+
// range, fetch everything in the collection.
516+
let matching_resources = match &range {
517+
Some(r) => {
518+
let start = crate::time_filter::to_ical_utc(&r.start)?;
519+
let end = crate::time_filter::to_ical_utc(&r.end)?;
520+
let listed = self
521+
.protocol(
522+
"query calendar time range",
523+
self.inner
524+
.request(build_time_range_query(calendar_href, &start, &end)?),
525+
)
526+
.await?;
527+
if listed.resources.is_empty() {
528+
return Ok(Vec::new());
529+
}
530+
Some((
531+
start,
532+
end,
533+
listed
534+
.resources
535+
.into_iter()
536+
.map(|resource| resource.href)
537+
.collect::<Vec<_>>(),
538+
))
539+
},
540+
None => None,
541+
};
542+
543+
let response = match &matching_resources {
544+
Some((start, end, hrefs)) => {
545+
self.protocol(
546+
"fetch expanded calendar resources",
547+
self.inner.request(GetExpandedCalendarResources::new(
548+
calendar_href,
549+
hrefs,
550+
start,
551+
end,
552+
)),
553+
)
554+
.await?
555+
},
556+
None => {
557+
self.protocol(
558+
"fetch calendar resources",
559+
self.inner
560+
.request(libdav::caldav::GetCalendarResources::new(calendar_href)),
561+
)
562+
.await?
563+
},
564+
};
458565

459566
let mut events = Vec::new();
460567
for resource in &response.resources {
@@ -695,7 +802,115 @@ mod tests {
695802
hyper_util::rt::TokioIo,
696803
};
697804

698-
use super::*;
805+
use {super::*, libdav::requests::DavRequest};
806+
807+
#[test]
808+
fn time_range_query_nests_time_range_under_vcalendar_vevent() {
809+
let query =
810+
build_time_range_query("/cal/personal/", "20260101T000000Z", "20260201T000000Z")
811+
.unwrap();
812+
let prepared = query.prepare_request().unwrap();
813+
814+
assert_eq!(
815+
prepared.method,
816+
http::Method::from_bytes(b"REPORT").unwrap()
817+
);
818+
assert!(prepared.body.contains(concat!(
819+
r#"<C:comp-filter name="VCALENDAR">"#,
820+
r#"<C:comp-filter name="VEVENT">"#,
821+
r#"<C:time-range start="20260101T000000Z" end="20260201T000000Z"/>"#,
822+
r#"</C:comp-filter></C:comp-filter>"#,
823+
)));
824+
}
825+
826+
#[test]
827+
fn expanded_resource_query_uses_requested_range() {
828+
let request = GetExpandedCalendarResources::new(
829+
"/cal/personal/",
830+
&["/cal/personal/series.ics".to_string()],
831+
"20260201T000000Z",
832+
"20260301T000000Z",
833+
);
834+
let prepared = request.prepare_request().unwrap();
835+
836+
assert!(prepared.body.contains(concat!(
837+
r#"<C:calendar-data><C:expand start="20260201T000000Z" "#,
838+
r#"end="20260301T000000Z"/></C:calendar-data>"#,
839+
)));
840+
assert!(
841+
prepared
842+
.body
843+
.contains("<D:href>/cal/personal/series.ics</D:href>")
844+
);
845+
}
846+
847+
#[test]
848+
fn expanded_resource_response_returns_recurring_occurrence_in_range() {
849+
let request = GetExpandedCalendarResources::new(
850+
"/cal/personal/",
851+
&["/cal/personal/series.ics".to_string()],
852+
"20260201T000000Z",
853+
"20260301T000000Z",
854+
);
855+
let response = Response::builder()
856+
.status(http::StatusCode::MULTI_STATUS)
857+
.body(())
858+
.unwrap();
859+
let (parts, ()) = response.into_parts();
860+
let body = br#"<?xml version="1.0" encoding="utf-8"?>
861+
<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
862+
<response>
863+
<href>/cal/personal/series.ics</href>
864+
<propstat>
865+
<prop>
866+
<getetag>"series-etag"</getetag>
867+
<C:calendar-data>BEGIN:VCALENDAR
868+
VERSION:2.0
869+
BEGIN:VEVENT
870+
UID:daily-series@example.test
871+
RECURRENCE-ID:20260205T090000Z
872+
SUMMARY:Daily standup
873+
DTSTART:20260205T090000Z
874+
DTEND:20260205T093000Z
875+
END:VEVENT
876+
END:VCALENDAR
877+
</C:calendar-data>
878+
</prop>
879+
<status>HTTP/1.1 200 OK</status>
880+
</propstat>
881+
</response>
882+
</multistatus>"#;
883+
884+
let fetched = request.parse_response(&parts, body).unwrap();
885+
let content = fetched.resources[0].content.as_ref().unwrap();
886+
let events =
887+
crate::ical::parse_events(&content.data, &fetched.resources[0].href, &content.etag)
888+
.unwrap();
889+
890+
assert_eq!(events.len(), 1);
891+
assert_eq!(events[0].uid.as_deref(), Some("daily-series@example.test"));
892+
assert_eq!(events[0].start.as_deref(), Some("2026-02-05T09:00:00"));
893+
}
894+
895+
#[test]
896+
fn time_range_query_uses_utc_z_timestamps_from_iso_input() {
897+
let start = crate::time_filter::to_ical_utc("2026-01-01T02:00:00+02:00").unwrap();
898+
let end = crate::time_filter::to_ical_utc("2026-02-01").unwrap();
899+
let query = build_time_range_query("/cal/personal/", &start, &end).unwrap();
900+
let prepared = query.prepare_request().unwrap();
901+
902+
assert!(
903+
prepared
904+
.body
905+
.contains(r#"<C:time-range start="20260101T000000Z" end="20260201T000000Z"/>"#)
906+
);
907+
}
908+
909+
#[test]
910+
fn time_range_query_rejects_non_utc_timestamps() {
911+
let result = build_time_range_query("/cal/personal/", "2026-01-01T00:00:00", "2026-02-01");
912+
assert!(matches!(result, Err(Error::Validation(_))));
913+
}
699914

700915
#[tokio::test]
701916
async fn pinned_resolver_never_performs_a_second_hostname_lookup() {

crates/caldav/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod client;
88
pub mod discovery;
99
pub mod error;
1010
pub mod ical;
11+
mod time_filter;
1112
pub mod tool;
1213
pub mod types;
1314

0 commit comments

Comments
 (0)