#ical #calendar #timezone #recurrence #validation #serde-json #date-range #deserialize #jiff #json-text

jscalendar-kit

JSCalendar core data types, validation, patching, recurrence expansion, and export

1 unstable release

0.1.0 Apr 21, 2026

#1327 in Parser implementations

MIT license

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

  • serde deserialization for JSCalendar Event, Task, Group, and JSCalendarObject
  • checked and explicit unchecked serialization boundaries for top-level objects
  • typed wrappers for core scalar values such as Uid, LocalDateTime, UtcDateTime, Duration, and TimeZoneId
  • 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, and Group metadata
  • 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:

  • VCALENDAR
  • VEVENT
  • VTODO
  • recurrence rules as RRULE
  • TZID for time zone aware date-time properties
  • custom timeZones as VTIMEZONE
  • optional X-JSCALENDAR payloads
  • Group entries as X-JSCALENDAR-GROUP

iCalendar import is not implemented and is not currently in scope.

Not Yet Supported

The following JSCalendar areas are deferred:

  • unsupported rscale values 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