diff --git a/crates/agents/src/lib.rs b/crates/agents/src/lib.rs index 3ab1deca62..d0d8c00e8a 100644 --- a/crates/agents/src/lib.rs +++ b/crates/agents/src/lib.rs @@ -9,6 +9,9 @@ pub mod multimodal; pub mod prompt; pub mod runner; pub mod tool_parsing; +/// Re-export of the `time` crate so dependent crates can use the same +/// date/time types without declaring their own dependency. +pub use time; pub use { model::{ChatMessage, ContentPart, UserContent}, runner::AgentRunError, diff --git a/crates/caldav/src/client.rs b/crates/caldav/src/client.rs index d3427a6168..d981bd1fa9 100644 --- a/crates/caldav/src/client.rs +++ b/crates/caldav/src/client.rs @@ -131,6 +131,23 @@ impl LibDavCalDavClient { } } +/// Build the server-side `calendar-query` REPORT that filters a collection +/// by VEVENT time range. +/// +/// Per RFC 4791 the `time-range` element must sit inside +/// `comp-filter name="VEVENT"`, nested in `comp-filter name="VCALENDAR"` — +/// servers (e.g. Nextcloud) silently ignore filters at the wrong level. +/// `start`/`end` must be iCalendar UTC basic format (`YYYYMMDDTHHMMSSZ`). +fn build_time_range_query<'a>( + calendar_href: &'a str, + start: &'a str, + end: &'a str, +) -> Result> { + libdav::caldav::ListCalendarResources::new(calendar_href) + .with_component_and_time_range("VEVENT", Some(start), Some(end)) + .map_err(|e| Error::Validation(format!("invalid time-range filter: {e}"))) +} + #[async_trait] impl CalDavClient for LibDavCalDavClient { async fn list_calendars(&self) -> Result> { @@ -190,12 +207,46 @@ impl CalDavClient for LibDavCalDavClient { async fn list_events( &self, calendar_href: &str, - _range: Option, + range: Option, ) -> Result> { - // Fetch all calendar resources (iCal data + etags) + // With a range, ask the server which resources match first + // (calendar-query REPORT with a VCALENDAR > VEVENT time-range + // filter), then fetch only those via calendar-multiget. Without a + // range, fetch everything in the collection. + let matching_hrefs = match &range { + Some(r) => { + let start = crate::time_filter::to_ical_utc(&r.start)?; + let end = crate::time_filter::to_ical_utc(&r.end)?; + let listed = self + .inner + .request(build_time_range_query(calendar_href, &start, &end)?) + .await + .map_err(|e| { + Error::Protocol(format!("calendar time-range query failed: {e}")) + })?; + if listed.resources.is_empty() { + return Ok(Vec::new()); + } + Some( + listed + .resources + .into_iter() + .map(|resource| resource.href) + .collect::>(), + ) + }, + None => None, + }; + + let request = match &matching_hrefs { + Some(hrefs) => libdav::caldav::GetCalendarResources::new(calendar_href) + .with_hrefs(hrefs.iter().map(String::as_str)), + None => libdav::caldav::GetCalendarResources::new(calendar_href), + }; + let response = self .inner - .request(libdav::caldav::GetCalendarResources::new(calendar_href)) + .request(request) .await .map_err(|e| Error::Protocol(format!("failed to fetch calendar resources: {e}")))?; @@ -293,3 +344,51 @@ impl CalDavClient for LibDavCalDavClient { /// Thread-safe shared CalDAV client. pub type SharedCalDavClient = Arc; + +#[allow(clippy::unwrap_used, clippy::expect_used)] +#[cfg(test)] +mod tests { + use {super::*, libdav::requests::DavRequest}; + + #[test] + fn time_range_query_nests_time_range_under_vcalendar_vevent() { + let query = + build_time_range_query("/cal/personal/", "20260101T000000Z", "20260201T000000Z") + .unwrap(); + let prepared = query.prepare_request().unwrap(); + + assert_eq!( + prepared.method, + http::Method::from_bytes(b"REPORT").unwrap() + ); + assert!(prepared.body.contains(concat!( + r#""#, + r#""#, + r#""#, + r#""#, + ))); + } + + #[test] + fn time_range_query_uses_utc_z_timestamps_from_iso_input() { + // The full path from tool-level ISO 8601 strings to the REPORT body. + let start = crate::time_filter::to_ical_utc("2026-01-01T02:00:00+02:00").unwrap(); + let end = crate::time_filter::to_ical_utc("2026-02-01").unwrap(); + let query = build_time_range_query("/cal/personal/", &start, &end).unwrap(); + let prepared = query.prepare_request().unwrap(); + + assert!( + prepared + .body + .contains(r#""#) + ); + } + + #[test] + fn time_range_query_rejects_non_utc_timestamps() { + // libdav validates the YYYYMMDDTHHMMSSZ format; a raw ISO string + // must be rejected rather than silently sent to the server. + let result = build_time_range_query("/cal/personal/", "2026-01-01T00:00:00", "2026-02-01"); + assert!(matches!(result, Err(Error::Validation(_)))); + } +} diff --git a/crates/caldav/src/lib.rs b/crates/caldav/src/lib.rs index 2e113dbbd0..ab0bad19e3 100644 --- a/crates/caldav/src/lib.rs +++ b/crates/caldav/src/lib.rs @@ -8,6 +8,7 @@ pub mod client; pub mod discovery; pub mod error; pub mod ical; +mod time_filter; pub mod tool; pub mod types; diff --git a/crates/caldav/src/time_filter.rs b/crates/caldav/src/time_filter.rs new file mode 100644 index 0000000000..7b3ca6455f --- /dev/null +++ b/crates/caldav/src/time_filter.rs @@ -0,0 +1,325 @@ +//! Time-range helpers for filtering calendar events. +//! +//! Converts ISO 8601 range bounds to the iCalendar UTC basic format required +//! by CalDAV `time-range` filters (RFC 4791 §9.9), and provides client-side +//! overlap filtering shared by the mock client used in tests. +//! +//! Naive date-times (no UTC offset) are treated as UTC. Date-only values +//! resolve to midnight UTC. + +use moltis_agents::time::{ + Date, OffsetDateTime, PrimitiveDateTime, UtcOffset, format_description::well_known::Iso8601, +}; + +use crate::error::{Error, Result}; + +#[cfg(test)] +use { + crate::types::{EventSummary, TimeRange}, + moltis_agents::time::Duration, +}; + +/// Parse an ISO 8601 date or date-time string into a UTC instant. +fn parse_iso_utc(value: &str) -> Option { + if let Ok(dt) = OffsetDateTime::parse(value, &Iso8601::DEFAULT) { + return Some(dt); + } + if let Ok(dt) = PrimitiveDateTime::parse(value, &Iso8601::DEFAULT) { + return Some(dt.assume_utc()); + } + if let Ok(date) = Date::parse(value, &Iso8601::DEFAULT) { + return Some(date.midnight().assume_utc()); + } + None +} + +/// Convert an ISO 8601 date/time string to iCalendar UTC basic format +/// (`YYYYMMDDTHHMMSSZ`, e.g. `20260101T000000Z`). +pub(crate) fn to_ical_utc(value: &str) -> Result { + let dt = parse_iso_utc(value) + .ok_or_else(|| Error::Validation(format!("invalid ISO 8601 date/time: '{value}'")))? + .to_offset(UtcOffset::UTC); + Ok(format!( + "{:04}{:02}{:02}T{:02}{:02}{:02}Z", + dt.year(), + u8::from(dt.month()), + dt.day(), + dt.hour(), + dt.minute(), + dt.second() + )) +} + +/// Whether an event overlaps the given range, following the `time-range` +/// overlap semantics of RFC 4791 §9.9 — the same rules a compliant CalDAV +/// server applies server-side — so this mirror stays faithful to it: +/// +/// - Events with an explicit DTEND, and all-day events (treated as spanning +/// one full day), overlap when `range.start < event_end` **and** +/// `event_start < range.end`. Both bounds are strict, so an event that +/// merely touches a boundary — ending exactly at `range.start` or starting +/// exactly at `range.end` — does not match. +/// - Instantaneous events (a DATE-TIME DTSTART with no DTEND) overlap when +/// `range.start <= event_start < range.end`: inclusive at the lower bound, +/// per the RFC's zero-duration rule. +/// +/// Events whose start is missing or unparseable, or a range that cannot be +/// parsed, are kept — silently hiding them would be worse than over-reporting. +#[cfg(test)] +pub(crate) fn event_overlaps(event: &EventSummary, range: &TimeRange) -> bool { + let (Some(range_start), Some(range_end)) = + (parse_iso_utc(&range.start), parse_iso_utc(&range.end)) + else { + return true; + }; + let Some(start) = event.start.as_deref().and_then(parse_iso_utc) else { + return true; + }; + match event.end.as_deref().and_then(parse_iso_utc) { + // Explicit DTEND: half-open [start, end), strict on both bounds. + Some(end) => range_start < end.max(start) && start < range_end, + // All-day without DTEND: spans one full day. + None if event.all_day => range_start < start + Duration::days(1) && start < range_end, + // Instantaneous DATE-TIME: lower bound inclusive, upper bound strict. + None => range_start <= start && start < range_end, + } +} + +/// Drop events that fall entirely outside the range. +#[cfg(test)] +pub(crate) fn filter_events(events: Vec, range: &TimeRange) -> Vec { + events + .into_iter() + .filter(|event| event_overlaps(event, range)) + .collect() +} + +#[allow(clippy::unwrap_used, clippy::expect_used)] +#[cfg(test)] +mod tests { + use super::*; + + fn event(start: Option<&str>, end: Option<&str>, all_day: bool) -> EventSummary { + EventSummary { + href: "/cal/test.ics".into(), + etag: "\"etag\"".into(), + uid: Some("uid@test".into()), + summary: Some("Test".into()), + start: start.map(String::from), + end: end.map(String::from), + all_day, + location: None, + } + } + + fn range(start: &str, end: &str) -> TimeRange { + TimeRange { + start: start.into(), + end: end.into(), + } + } + + #[test] + fn to_ical_utc_naive_datetime() { + assert_eq!( + to_ical_utc("2026-01-01T00:00:00").unwrap(), + "20260101T000000Z" + ); + } + + #[test] + fn to_ical_utc_zulu_datetime() { + assert_eq!( + to_ical_utc("2026-06-15T09:30:00Z").unwrap(), + "20260615T093000Z" + ); + } + + #[test] + fn to_ical_utc_converts_offset_to_utc() { + assert_eq!( + to_ical_utc("2026-01-01T02:00:00+02:00").unwrap(), + "20260101T000000Z" + ); + } + + #[test] + fn to_ical_utc_date_only_is_midnight_utc() { + assert_eq!(to_ical_utc("2026-03-15").unwrap(), "20260315T000000Z"); + } + + #[test] + fn to_ical_utc_rejects_garbage() { + assert!(to_ical_utc("not-a-date").is_err()); + assert!(to_ical_utc("").is_err()); + } + + #[test] + fn event_before_range_is_excluded() { + let ev = event( + Some("2026-01-01T10:00:00"), + Some("2026-01-01T11:00:00"), + false, + ); + assert!(!event_overlaps( + &ev, + &range("2026-02-01T00:00:00", "2026-02-28T00:00:00") + )); + } + + #[test] + fn event_after_range_is_excluded() { + let ev = event( + Some("2026-03-01T10:00:00"), + Some("2026-03-01T11:00:00"), + false, + ); + assert!(!event_overlaps( + &ev, + &range("2026-02-01T00:00:00", "2026-02-28T00:00:00") + )); + } + + #[test] + fn event_overlapping_range_start_is_included() { + // Starts before the range, ends inside it. + let ev = event( + Some("2026-01-31T23:00:00"), + Some("2026-02-01T01:00:00"), + false, + ); + assert!(event_overlaps( + &ev, + &range("2026-02-01T00:00:00", "2026-02-28T00:00:00") + )); + } + + #[test] + fn event_overlapping_range_end_is_included() { + // Starts inside the range, ends after it. + let ev = event( + Some("2026-02-27T23:00:00"), + Some("2026-02-28T01:00:00"), + false, + ); + assert!(event_overlaps( + &ev, + &range("2026-02-01T00:00:00", "2026-02-28T00:00:00") + )); + } + + #[test] + fn event_ending_exactly_at_range_start_is_excluded() { + // RFC 4791 §9.9 uses a strict `range.start < DTEND`, so an event that + // ends the instant the range begins does not overlap. + let ev = event( + Some("2026-01-31T23:00:00"), + Some("2026-02-01T00:00:00"), + false, + ); + assert!(!event_overlaps( + &ev, + &range("2026-02-01T00:00:00", "2026-02-28T00:00:00") + )); + } + + #[test] + fn event_starting_exactly_at_range_end_is_excluded() { + // Strict `DTSTART < range.end`: an event that starts the instant the + // range ends does not overlap. + let ev = event( + Some("2026-02-28T00:00:00"), + Some("2026-02-28T01:00:00"), + false, + ); + assert!(!event_overlaps( + &ev, + &range("2026-02-01T00:00:00", "2026-02-28T00:00:00") + )); + } + + #[test] + fn instantaneous_event_at_range_start_is_included() { + // Instantaneous events use the RFC's inclusive lower bound + // (`range.start <= DTSTART`), unlike events with an explicit DTEND. + let ev = event(Some("2026-02-01T00:00:00"), None, false); + assert!(event_overlaps( + &ev, + &range("2026-02-01T00:00:00", "2026-02-28T00:00:00") + )); + } + + #[test] + fn event_spanning_entire_range_is_included() { + let ev = event( + Some("2026-01-01T00:00:00"), + Some("2026-12-31T00:00:00"), + false, + ); + assert!(event_overlaps( + &ev, + &range("2026-02-01T00:00:00", "2026-02-28T00:00:00") + )); + } + + #[test] + fn instantaneous_event_without_dtend_uses_start() { + let inside = event(Some("2026-02-15T12:00:00"), None, false); + let outside = event(Some("2026-03-15T12:00:00"), None, false); + let r = range("2026-02-01T00:00:00", "2026-02-28T00:00:00"); + assert!(event_overlaps(&inside, &r)); + assert!(!event_overlaps(&outside, &r)); + } + + #[test] + fn all_day_event_without_dtend_spans_one_day() { + let ev = event(Some("2026-02-01"), None, true); + // Range starts in the evening of the event's day: still overlaps. + assert!(event_overlaps( + &ev, + &range("2026-02-01T20:00:00", "2026-02-02T00:00:00") + )); + // Range entirely after that day: no overlap. + assert!(!event_overlaps( + &ev, + &range("2026-02-03T00:00:00", "2026-02-04T00:00:00") + )); + } + + #[test] + fn event_with_missing_start_is_kept() { + let ev = event(None, None, false); + assert!(event_overlaps( + &ev, + &range("2026-02-01T00:00:00", "2026-02-28T00:00:00") + )); + } + + #[test] + fn unparseable_range_keeps_everything() { + let ev = event(Some("2026-01-01T10:00:00"), None, false); + assert!(event_overlaps( + &ev, + &range("garbage", "2026-02-28T00:00:00") + )); + } + + #[test] + fn filter_events_drops_only_out_of_range() { + let events = vec![ + event( + Some("2026-01-01T10:00:00"), + Some("2026-01-01T11:00:00"), + false, + ), + event( + Some("2026-02-15T10:00:00"), + Some("2026-02-15T11:00:00"), + false, + ), + ]; + let kept = filter_events(events, &range("2026-02-01T00:00:00", "2026-02-28T00:00:00")); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].start.as_deref(), Some("2026-02-15T10:00:00")); + } +} diff --git a/crates/caldav/src/tool.rs b/crates/caldav/src/tool.rs index 6ed6c01c1d..61d72427f6 100644 --- a/crates/caldav/src/tool.rs +++ b/crates/caldav/src/tool.rs @@ -123,7 +123,8 @@ impl AgentTool for CalDavTool { "Manage calendar events via CalDAV. Supports multiple accounts (Fastmail, iCloud, generic).\n\n\ Operations:\n\ - list_calendars: List available calendars. Returns href, display_name, color, description.\n\ - - list_events: List events in a calendar. Params: calendar (href, required), start/end (ISO 8601, optional).\n\ + - list_events: List events in a calendar. Params: calendar (href, required), start/end (ISO 8601, optional). \ + Provide BOTH start and end to filter server-side to that window; passing only one is ignored and returns every event.\n\ - create_event: Create a new event. Params: calendar (href), summary, start (ISO 8601), end (optional), \ all_day (bool), location, description.\n\ - update_event: Update an existing event. Params: event_href, etag (required for concurrency), \ @@ -165,11 +166,11 @@ impl AgentTool for CalDavTool { }, "start": { "type": "string", - "description": "Start date/time in ISO 8601 format (e.g. 2025-06-15T10:00:00 or 2025-06-15 for all-day)" + "description": "Start date/time in ISO 8601 format (e.g. 2025-06-15T10:00:00 or 2025-06-15 for all-day). For list_events, filtering only applies when both start and end are set." }, "end": { "type": "string", - "description": "End date/time in ISO 8601 format" + "description": "End date/time in ISO 8601 format. For list_events, filtering only applies when both start and end are set." }, "all_day": { "type": "boolean", @@ -341,14 +342,19 @@ impl crate::client::CalDavClient for MockCalDavClient { async fn list_events( &self, _calendar_href: &str, - _range: Option, + range: Option, ) -> crate::error::Result> { let events = self .events .lock() .unwrap_or_else(|e| e.into_inner()) .clone(); - Ok(events) + // Same range semantics as the real client's server-side query, so + // tests exercise the filtering logic. + Ok(match range { + Some(ref r) => crate::time_filter::filter_events(events, r), + None => events, + }) } async fn create_event( @@ -487,6 +493,120 @@ mod tests { assert!(result.unwrap_err().to_string().contains("calendar")); } + fn mock_event( + uid: &str, + start: Option<&str>, + end: Option<&str>, + all_day: bool, + ) -> crate::types::EventSummary { + crate::types::EventSummary { + href: format!("/cal/{uid}.ics"), + etag: "\"etag\"".into(), + uid: Some(uid.into()), + summary: Some(uid.into()), + start: start.map(String::from), + end: end.map(String::from), + all_day, + location: None, + } + } + + fn mock_client_with_events() -> MockCalDavClient { + MockCalDavClient { + calendars: vec![], + events: std::sync::Mutex::new(vec![ + mock_event( + "before", + Some("2026-01-10T10:00:00"), + Some("2026-01-10T11:00:00"), + false, + ), + mock_event( + "overlaps-start", + Some("2026-01-31T23:00:00"), + Some("2026-02-01T01:00:00"), + false, + ), + mock_event( + "inside", + Some("2026-02-15T10:00:00"), + Some("2026-02-15T11:00:00"), + false, + ), + mock_event( + "overlaps-end", + Some("2026-02-28T23:00:00"), + Some("2026-03-01T01:00:00"), + false, + ), + mock_event( + "after", + Some("2026-04-01T10:00:00"), + Some("2026-04-01T11:00:00"), + false, + ), + mock_event("all-day-inside", Some("2026-02-10"), None, true), + mock_event("no-dtend-inside", Some("2026-02-20T09:00:00"), None, false), + ]), + } + } + + fn february() -> TimeRange { + TimeRange { + start: "2026-02-01T00:00:00".into(), + end: "2026-03-01T00:00:00".into(), + } + } + + #[tokio::test] + async fn mock_list_events_excludes_events_outside_range() { + use crate::client::CalDavClient; + let client = mock_client_with_events(); + let events = client.list_events("/cal/", Some(february())).await.unwrap(); + let uids: Vec<&str> = events.iter().filter_map(|e| e.uid.as_deref()).collect(); + assert!(!uids.contains(&"before")); + assert!(!uids.contains(&"after")); + } + + #[tokio::test] + async fn mock_list_events_includes_boundary_overlapping_events() { + use crate::client::CalDavClient; + let client = mock_client_with_events(); + let events = client.list_events("/cal/", Some(february())).await.unwrap(); + let uids: Vec<&str> = events.iter().filter_map(|e| e.uid.as_deref()).collect(); + assert!(uids.contains(&"overlaps-start")); + assert!(uids.contains(&"inside")); + assert!(uids.contains(&"overlaps-end")); + } + + #[tokio::test] + async fn mock_list_events_handles_all_day_and_missing_dtend() { + use crate::client::CalDavClient; + let client = mock_client_with_events(); + let events = client.list_events("/cal/", Some(february())).await.unwrap(); + let uids: Vec<&str> = events.iter().filter_map(|e| e.uid.as_deref()).collect(); + assert!(uids.contains(&"all-day-inside")); + assert!(uids.contains(&"no-dtend-inside")); + + // A range after the all-day event's day excludes it. + let march = TimeRange { + start: "2026-03-05T00:00:00".into(), + end: "2026-03-10T00:00:00".into(), + }; + let events = client.list_events("/cal/", Some(march)).await.unwrap(); + let uids: Vec<&str> = events.iter().filter_map(|e| e.uid.as_deref()).collect(); + assert!(!uids.contains(&"all-day-inside")); + assert!(!uids.contains(&"no-dtend-inside")); + } + + #[tokio::test] + async fn mock_list_events_without_range_returns_everything() { + use crate::client::CalDavClient; + let client = mock_client_with_events(); + let events = client.list_events("/cal/", None).await.unwrap(); + assert_eq!(events.len(), 7); + } + #[tokio::test] async fn delete_event_missing_etag_errors() { let config = test_config(); diff --git a/docs/src/caldav.md b/docs/src/caldav.md index 1aef0c39df..433cf5e9d4 100644 --- a/docs/src/caldav.md +++ b/docs/src/caldav.md @@ -102,12 +102,16 @@ Returns: `href`, `display_name`, `color`, `description` for each calendar. ### `list_events` Lists events in a specific calendar, optionally filtered by date range. +When both `start` and `end` are given, the filter runs server-side as a +CalDAV `calendar-query` REPORT with a `time-range` element (RFC 4791), so +only events intersecting the window are fetched. If either is omitted, all +events are returned. | Parameter | Required | Description | |-----------|----------|-------------| | `calendar` | yes | Calendar href (from `list_calendars`) | -| `start` | no | ISO 8601 start date/time | -| `end` | no | ISO 8601 end date/time | +| `start` | no | ISO 8601 start date/time (naive times are treated as UTC) | +| `end` | no | ISO 8601 end date/time (naive times are treated as UTC) | Returns: `href`, `etag`, `uid`, `summary`, `start`, `end`, `all_day`, `location` for each event.