1 unstable release
| 0.1.0 | Apr 21, 2026 |
|---|
#1327 in Parser implementations
330KB
9K
SLoC
jscalendar-kit
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.
Scope
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
MSRV
Minimum Supported Rust Version (MSRV): jscalendar-kit supports Rust 1.85.0 and later.
Installation
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.
Example
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.
Event
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>(())
Task
use jscalendar_kit::Task;
let mut task = Task::new();
task.common.title = Some("Do something".to_string());
# let _ = task;
# Ok::<(), jscalendar_kit::Error>(())
Group
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>(())
Validation
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.
Validate
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>(())
Recurrence Expansion
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 Example
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.
Search And Diff
Search And Filter
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:
Patch
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>(())
Diff
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 And Time Zones
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.
Conformance
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.
iCalendar Export
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.
Not Yet Supported
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
Test Strategy
The crate includes unit tests, recurrence conformance fixtures, and RFC example round-trip tests.
Run the Rust test suite with:
cargo test --workspace --all-targets
Run lint checks with:
cargo clippy --workspace --all-targets -- -D warnings
Out Of Scope
The following areas are outside the RFC 8984 wire-format implementation scope:
- iCalendar import
- iCalendar export exists only as a compatibility feature.
Code of Conduct
See CODE_OF_CONDUCT.md.
Contributing
See CONTRIBUTING.md.
License
See LICENSE.
Dependencies
~9MB
~152K SLoC