Rust implementation for RFC 8984 JSCalendar data handling.
This crate is centered on JSCalendar. iCalendar support is intentionally limited to export for compatibility with older calendar clients.
serdedeserialization for JSCalendarEvent,Task,Group, andJSCalendarObject- checked and explicit unchecked serialization boundaries for top-level objects
- typed wrappers for core scalar values such as
Uid,LocalDateTime,UtcDateTime,Duration, andTimeZoneId - validation for core RFC 8984 fields and recurrence rules
- RFC 8984 PatchObject application
- PatchObject diff generation
- Gregorian and non-Gregorian recurrence expansion via
rscale - paged recurrence expansion
- time zone aware recurrence and date-range handling through Jiff, IANA TZDB,
and RFC 8984 custom
timeZones - search helpers for UID, type, text, and date ranges
- convenience helpers for RFC 8984 object media type strings
- iCalendar export for
VEVENT,VTODO, andGroupmetadata - JSON helper APIs for conformance tests and external harnesses
Minimum Supported Rust Version (MSRV): jscalendar-kit supports Rust 1.85.0 and later.
Add the crate to your Cargo.toml:
[dependencies]
jscalendar-kit = "0.1"The crate currently has no feature flags and uses std.
The crate uses Rust-native typed data models instead of dynamic object APIs.
Top-level Event, Task, Group, and JSCalendarObject values are
deserializable and field-mutable, but direct serialization is intentionally not
implemented; serialize a validated wrapper for RFC output or an unchecked
wrapper for draft or migration data.
The crate does not implement iCalendar import. iCalendar support is limited to compatibility export.
use jscalendar_kit::{
expand_recurrence, validate, validate_scheduling_message, Event, JSCalendarObject,
LocalDateTime, RecurrenceRange,
};
let mut event = Event::new(LocalDateTime::parse("2026-01-01T09:00:00").unwrap());
event.common.title = Some("Weekly Sync".to_string());
validate(&JSCalendarObject::Event(event.clone()))?;
let json = serde_json::to_value(event.as_validated()?)?;
let items = expand_recurrence(
&[JSCalendarObject::Event(event)],
&RecurrenceRange {
from: LocalDateTime::parse("2026-01-01T00:00:00").unwrap(),
to: LocalDateTime::parse("2026-01-31T23:59:59").unwrap(),
},
Default::default(),
)?;
# let _ = (items, json);
# Ok::<(), jscalendar_kit::Error>(())validate() is strict object validation. Use validate_for_storage() for
server-storable objects; it also rejects transient scheduling fields such as
participant scheduleForceSend. Use validate_scheduling_message() when
scheduling-message-only metadata such as method, replyTo, sentBy, or
requestStatus is expected; it also rejects participant scheduleStatus and
scheduleForceSend.
Some RFC defaults are exposed as helpers instead of mutating omitted fields.
For example, Task::effective_progress() derives the RFC 5.2.5 effective
progress when task.progress is absent, while preserving the wire-format
distinction between an omitted field and an explicit value.
use jscalendar_kit::{Event, LocalDateTime};
let mut event = Event::new(LocalDateTime::parse("2026-01-15T13:00:00")?);
event.common.title = Some("Some event".to_string());
# let _ = event;
# Ok::<(), jscalendar_kit::Error>(())use jscalendar_kit::Task;
let mut task = Task::new();
task.common.title = Some("Do something".to_string());
# let _ = task;
# Ok::<(), jscalendar_kit::Error>(())use jscalendar_kit::{Event, Group, GroupEntry, LocalDateTime, Task};
let mut event = Event::new(LocalDateTime::parse("2026-01-15T13:00:00")?);
event.common.title = Some("Some event".to_string());
let mut task = Task::new();
task.common.title = Some("Do something".to_string());
let group = Group::new(vec![GroupEntry::Event(event), GroupEntry::Task(task)]);
# let _ = group;
# Ok::<(), jscalendar_kit::Error>(())For checked imports from JSON text, use jscalendar_kit::json::validate_str.
That entry point rejects duplicate object member names before decoding, which is
required by RFC 8984's I-JSON rules. serde_json::from_str::<Validated<_>>
still validates the typed data model, but it is not a complete wire-format
validator because generic serde deserialization cannot recover duplicate JSON
member names after parsing.
validate() performs strict object validation. validate_for_storage()
applies storage-specific rules, including rejection of transient scheduling
fields such as participant scheduleForceSend. validate_scheduling_message()
accepts scheduling-message metadata such as method, replyTo, sentBy, and
requestStatus, while rejecting participant fields that are not allowed in
scheduling messages.
The media type helper API is only a convenience for I/O boundaries. It exposes
the RFC 8984 strings for event, task, and group objects, and a strict parser
for the registered application/jscalendar+json;type=... form, but it does not
implement general HTTP Content-Type parsing or normalization.
use jscalendar_kit::{validate, Event, JSCalendarObject, LocalDateTime};
let event = Event::new(LocalDateTime::parse("2026-01-15T13:00:00")?);
validate(&JSCalendarObject::Event(event))?;
# Ok::<(), jscalendar_kit::Error>(())use jscalendar_kit::{
expand_recurrence, Event, JSCalendarObject, LocalDateTime, RecurrenceRange,
Frequency, RecurrenceRuleBuilder,
};
let mut event = Event::new(LocalDateTime::parse("2026-01-01T09:00:00")?);
event.common.recurrence_rules.push(
RecurrenceRuleBuilder::new(Frequency::Daily).build()?,
);
let items = expand_recurrence(
&[JSCalendarObject::Event(event)],
&RecurrenceRange {
from: LocalDateTime::parse("2026-01-01T00:00:00")?,
to: LocalDateTime::parse("2026-01-03T23:59:59")?,
},
Default::default(),
)?;
# let _ = items;
# Ok::<(), jscalendar_kit::Error>(())Paged recurrence expansion requires a stable object_keys entry for each input
object. Reuse the same keys when you request the next page.
use jscalendar_kit::{
expand_recurrence_paged, Event, JSCalendarObject, LocalDateTime,
RecurrencePageOptions, RecurrenceRange,
};
let mut event = Event::new(LocalDateTime::parse("2026-02-02T09:00:00").unwrap());
event.uid = "weekly-sync".parse().unwrap();
event.common.recurrence_rules.push(
serde_json::from_value(serde_json::json!({
"@type": "RecurrenceRule",
"frequency": "weekly"
}))
.unwrap(),
);
let items = vec![JSCalendarObject::Event(event)];
let object_keys = vec!["primary-event".to_string()];
let range = RecurrenceRange {
from: LocalDateTime::parse("2026-02-01T00:00:00").unwrap(),
to: LocalDateTime::parse("2026-03-01T00:00:00").unwrap(),
};
let page1 = expand_recurrence_paged(
&items,
&object_keys,
&range,
&RecurrencePageOptions::new(2),
)?
;
let page2 = expand_recurrence_paged(
&items,
&object_keys,
&range,
&RecurrencePageOptions {
include_anchor: true,
limit: 2,
cursor: page1.next_cursor.clone(),
},
)?
;
# let _ = (page1, page2);
# Ok::<(), jscalendar_kit::Error>(())next_cursor is the last occurrence returned in the current page. Pass it back
as RecurrencePageOptions::cursor to continue strictly after that occurrence.
The cursor stays reusable as long as the query range, recurrence inputs, and
their caller-provided object_keys still describe the same logical object set.
Paged recurrence expansion is incremental. expand_recurrence_paged() only
walks far enough to produce the requested page plus one lookahead item for
next_cursor. The paged API requires caller-provided object_keys with the
same length as the input items. object_keys are per input calendar object,
not per expanded occurrence. One recurring definition in items requires
exactly one stable key, regardless of page size or how many occurrences that
definition expands into. Oversized page limits are rejected, and Group
objects are not accepted by the paged API.
use jscalendar_kit::{
filter_by_text, filter_by_type, find_by_uid, Event, JSCalendarObject,
LocalDateTime,
};
let event = Event::new(LocalDateTime::parse("2026-01-15T13:00:00")?);
let uid = event.uid.clone();
let items = vec![JSCalendarObject::Event(event)];
let by_uid = find_by_uid(&items, &uid);
let text_matches = filter_by_text(&items, "event");
let event_items = filter_by_type(&items, "Event");
# let _ = (by_uid, text_matches, event_items);
# Ok::<(), jscalendar_kit::Error>(())Search helpers return references into the input slice:
use jscalendar_kit::{filter_by_text, find_by_uid, JSCalendarObject, Uid};
fn find(items: &[JSCalendarObject]) -> jscalendar_kit::Result<()> {
let uid = Uid::parse("event-1").unwrap();
let by_uid = find_by_uid(items, &uid);
let text_matches = filter_by_text(items, "planning");
let _ = (by_uid, text_matches);
Ok(())
}diff creates a JSCalendar PatchObject. Use patch_checked on top-level
JSCalendar objects so the patched result is validated before it is returned:
use jscalendar_kit::{Event, JSCalendarObject, LocalDateTime};
use serde_json::{from_value, json};
let event = JSCalendarObject::Event(Event::new(LocalDateTime::parse("2026-01-15T13:00:00")?));
let patch = from_value(json!({
"title": "Updated title"
}))?;
let patched = event.patch_checked(&patch)?;
# let _ = patched;
# Ok::<(), jscalendar_kit::Error>(())use jscalendar_kit::{diff, Event, JSCalendarObject, LocalDateTime};
let mut before = Event::new(LocalDateTime::parse("2026-01-15T13:00:00")?);
before.common.title = Some("Before".to_string());
let mut after = before.clone();
after.common.title = Some("After".to_string());
let before = JSCalendarObject::Event(before);
let after = JSCalendarObject::Event(after);
let patch = diff(&before, &after)?;
let updated = before.patch_checked(&patch)?;
# let _ = updated;
# Ok::<(), jscalendar_kit::Error>(())use jscalendar_kit::{diff, JSCalendarObject};
fn update(before: &JSCalendarObject, after: &JSCalendarObject) -> jscalendar_kit::Result<JSCalendarObject> {
let patch = diff(before, after)?;
let updated = before.patch_checked(&patch)?;
Ok(updated)
}Recurrence expansion supports Gregorian recurrence rules and supported
non-Gregorian rscale values such as Hebrew, Chinese, Indian, Persian,
Japanese, Buddhist, Coptic, Ethiopic, and Islamic calendar variants. Time zone
aware expansion and date-range filtering use Jiff with the IANA Time Zone
Database and RFC 8984 custom timeZones. DST gaps and folds are resolved
through Jiff's compatible disambiguation.
The crate includes RFC-oriented conformance fixtures for recurrence expansion,
non-Gregorian rscale behavior, and RFC 8984 example objects. The JSON helper
APIs exercise the same expansion surface that external harnesses can call.
Recurring example coverage and conformance fixtures are part of the Rust test suite and are treated as the reference behavior for this crate.
The crate provides to_ical and to_ical_default for compatibility export.
This is a secondary feature; JSCalendar remains the source data model.
Supported export includes:
VCALENDARVEVENTVTODO- recurrence rules as
RRULE TZIDfor time zone aware date-time properties- custom
timeZonesasVTIMEZONE - optional
X-JSCALENDARpayloads Groupentries asX-JSCALENDAR-GROUP
iCalendar import is not implemented and is not currently in scope.
The following JSCalendar areas are deferred:
- unsupported
rscalevalues for recurrence expansion - validation accepts CLDR-registered calendar system names and vendor-specific values separately from expansion support
- values that are otherwise valid JSCalendar but unsupported by the recurrence engine fail explicitly during expansion
The crate includes unit tests, recurrence conformance fixtures, and RFC example round-trip tests.
Run the Rust test suite with:
cargo test --workspace --all-targetsRun lint checks with:
cargo clippy --workspace --all-targets -- -D warningsThe following areas are outside the RFC 8984 wire-format implementation scope:
- iCalendar import
- iCalendar export exists only as a compatibility feature.
See CODE_OF_CONDUCT.md.
See CONTRIBUTING.md.
See LICENSE.