///|
pub fn validate_calendar(
calendar : Calendar,
options : ValidationOptions,
) -> ValidationReport {
let findings : Array[Finding] = []
validate_calendar_properties(calendar, options, findings)
validate_components(calendar, options, findings)
if findings.length() == 0 {
findings.push({
severity: Info,
code: "accepted",
message: "calendar passed validation",
line: None,
})
}
{ accepted: !has_deny(findings), findings }
}
///|
fn validate_calendar_properties(
calendar : Calendar,
options : ValidationOptions,
findings : Array[Finding],
) -> Unit {
if options.require_version && calendar.get("VERSION") is None {
findings.push({
severity: Deny,
code: "missing-version",
message: "VCALENDAR should contain VERSION:2.0",
line: None,
})
}
match calendar.get("VERSION") {
Some(version) =>
if version != "2.0" {
findings.push({
severity: Deny,
code: "unsupported-version",
message: "only iCalendar VERSION:2.0 is supported",
line: None,
})
}
None => ()
}
if options.require_prodid && !calendar_has_value(calendar, "PRODID") {
findings.push({
severity: Deny,
code: "missing-prodid",
message: "VCALENDAR must contain PRODID in strict mode",
line: None,
})
}
validate_calendar_property_catalog(calendar, findings)
let events = calendar.events()
match options.max_events {
Some(limit) =>
if events.length() > limit {
findings.push({
severity: Deny,
code: "too-many-events",
message: "calendar has \{events.length()} events, above limit \{limit}",
line: None,
})
}
None => ()
}
}
///|
fn validate_components(
calendar : Calendar,
options : ValidationOptions,
findings : Array[Finding],
) -> Unit {
for component in calendar.components {
validate_component(component, "VCALENDAR", options, findings)
}
}
///|
fn validate_component(
component : Component,
parent : String,
options : ValidationOptions,
findings : Array[Finding],
) -> Unit {
validate_component_property_catalog(component, findings)
if !component_allowed_under(component.name, parent) &&
!options.allow_unknown_components {
findings.push({
severity: Warn,
code: "component-placement",
message: "component \{component.name} is unusual under \{parent}",
line: Some(component.line),
})
}
if component.name == "VEVENT" {
validate_event(component, options, findings)
} else if component.name == "VALARM" {
validate_alarm(component, findings)
} else if component.name == "VTODO" {
validate_todo(component, options, findings)
} else if component.name == "VJOURNAL" {
validate_journal(component, options, findings)
} else if component.name == "VTIMEZONE" {
validate_timezone(component, findings)
} else if component.name == "STANDARD" || component.name == "DAYLIGHT" {
validate_timezone_observance(component, findings)
} else if !options.allow_unknown_components {
findings.push({
severity: Warn,
code: "unknown-component",
message: "component \{component.name} is not explicitly supported",
line: Some(component.line),
})
}
for child in component.components {
validate_component(child, component.name, options, findings)
}
}
///|
fn component_allowed_under(name : String, parent : String) -> Bool {
if parent == "VCALENDAR" {
name == "VEVENT" ||
name == "VTODO" ||
name == "VJOURNAL" ||
name == "VTIMEZONE"
} else if parent == "VEVENT" || parent == "VTODO" {
name == "VALARM"
} else if parent == "VTIMEZONE" {
name == "STANDARD" || name == "DAYLIGHT"
} else {
false
}
}
///|
fn validate_calendar_property_catalog(
calendar : Calendar,
findings : Array[Finding],
) -> Unit {
for prop in calendar.properties {
validate_property_against_role(prop, CalendarRole, findings)
}
validate_property_multiplicity(calendar.properties, CalendarRole, findings)
}
///|
fn validate_component_property_catalog(
component : Component,
findings : Array[Finding],
) -> Unit {
let role = role_for_component(component.name)
for prop in component.properties {
validate_property_against_role(prop, role, findings)
}
validate_property_multiplicity(component.properties, role, findings)
}
///|
fn validate_property_against_role(
prop : Property,
role : ComponentRole,
findings : Array[Finding],
) -> Unit {
match find_property_spec(prop.name) {
Some(item) =>
if !spec_allows_role(item, role) {
findings.push({
severity: Warn,
code: "property-placement",
message: "\{prop.name} is not normally allowed in \{role.name()}",
line: Some(prop.line),
})
}
None =>
if !is_x_name(prop.name) {
findings.push({
severity: Info,
code: "unknown-property",
message: "\{prop.name} is not in the built-in property catalog",
line: Some(prop.line),
})
}
}
}
///|
fn validate_property_multiplicity(
properties : Array[Property],
role : ComponentRole,
findings : Array[Finding],
) -> Unit {
for item in all_property_specs() {
if spec_allows_role(item, role) &&
!cardinality_allows_many(item.cardinality) {
let count = count_properties(properties, item.name)
if count > 1 {
findings.push({
severity: Warn,
code: "property-duplicate",
message: "\{role.name()} contains \{count} copies of \{item.name}",
line: first_property_line(properties, item.name),
})
}
}
}
}
///|
fn count_properties(properties : Array[Property], name : String) -> Int {
let mut count = 0
for prop in properties {
if prop.name == name {
count += 1
}
}
count
}
///|
fn first_property_line(properties : Array[Property], name : String) -> Int? {
for prop in properties {
if prop.name == name {
return Some(prop.line)
}
}
None
}
///|
fn is_x_name(name : String) -> Bool {
name.length() > 2 && name[0] == 88 && name[1] == 45
}
///|
fn validate_event(
event : Component,
options : ValidationOptions,
findings : Array[Finding],
) -> Unit {
if options.require_uid && !component_has_value(event, "UID") {
findings.push({
severity: Deny,
code: "event-missing-uid",
message: "VEVENT must contain UID",
line: None,
})
}
if options.require_dtstamp && !component_has_value(event, "DTSTAMP") {
findings.push({
severity: Deny,
code: "event-missing-dtstamp",
message: "VEVENT must contain DTSTAMP",
line: None,
})
}
if options.require_uid && !component_has_value(event, "DTSTART") {
findings.push({
severity: Deny,
code: "event-missing-dtstart",
message: "VEVENT must contain DTSTART in strict mode",
line: Some(event.line),
})
}
if event.count("DTSTART") > 1 {
findings.push({
severity: Deny,
code: "event-duplicate-dtstart",
message: "VEVENT should not contain multiple DTSTART values",
line: None,
})
}
if event.has("DTEND") && event.has("DURATION") {
findings.push({
severity: Deny,
code: "event-dtend-duration-conflict",
message: "VEVENT must not contain both DTEND and DURATION",
line: None,
})
}
validate_time_value(event, "DTSTAMP", true, options, findings)
validate_time_value(event, "DTSTART", false, options, findings)
validate_time_value(event, "DTEND", false, options, findings)
validate_order(event, findings)
validate_duration_property(event, "DURATION", findings)
validate_recurrence(event, findings)
validate_recurrence_dates(event, options, findings)
validate_event_status(event, findings)
validate_event_transparency(event, findings)
validate_event_priority(event, findings)
validate_event_alarms(event, findings)
}
///|
fn validate_todo(
todo : Component,
options : ValidationOptions,
findings : Array[Finding],
) -> Unit {
if options.require_uid && !component_has_value(todo, "UID") {
findings.push({
severity: Deny,
code: "todo-missing-uid",
message: "VTODO must contain UID in strict mode",
line: Some(todo.line),
})
}
if options.require_dtstamp && !component_has_value(todo, "DTSTAMP") {
findings.push({
severity: Deny,
code: "todo-missing-dtstamp",
message: "VTODO must contain DTSTAMP in strict mode",
line: Some(todo.line),
})
}
validate_time_value(todo, "DTSTAMP", true, options, findings)
validate_time_value(todo, "DUE", false, options, findings)
validate_time_value(todo, "COMPLETED", true, options, findings)
validate_duration_property(todo, "DURATION", findings)
if todo.has("DUE") && todo.has("DURATION") {
findings.push({
severity: Deny,
code: "todo-due-duration-conflict",
message: "VTODO must not contain both DUE and DURATION",
line: Some(todo.line),
})
}
validate_percent_complete(todo, findings)
validate_todo_status(todo, findings)
validate_recurrence(todo, findings)
validate_recurrence_dates(todo, options, findings)
validate_event_alarms(todo, findings)
}
///|
fn validate_journal(
journal : Component,
options : ValidationOptions,
findings : Array[Finding],
) -> Unit {
if options.require_uid && !component_has_value(journal, "UID") {
findings.push({
severity: Deny,
code: "journal-missing-uid",
message: "VJOURNAL must contain UID in strict mode",
line: Some(journal.line),
})
}
if options.require_dtstamp && !component_has_value(journal, "DTSTAMP") {
findings.push({
severity: Deny,
code: "journal-missing-dtstamp",
message: "VJOURNAL must contain DTSTAMP in strict mode",
line: Some(journal.line),
})
}
validate_time_value(journal, "DTSTAMP", true, options, findings)
validate_time_value(journal, "DTSTART", false, options, findings)
validate_journal_status(journal, findings)
}
///|
fn validate_timezone(timezone : Component, findings : Array[Finding]) -> Unit {
if !component_has_value(timezone, "TZID") {
findings.push({
severity: Deny,
code: "timezone-missing-tzid",
message: "VTIMEZONE must contain TZID",
line: Some(timezone.line),
})
}
if timezone.children("STANDARD").length() == 0 &&
timezone.children("DAYLIGHT").length() == 0 {
findings.push({
severity: Warn,
code: "timezone-no-observance",
message: "VTIMEZONE normally contains STANDARD or DAYLIGHT observance",
line: Some(timezone.line),
})
}
}
///|
fn validate_timezone_observance(
component : Component,
findings : Array[Finding],
) -> Unit {
if !component_has_value(component, "DTSTART") {
findings.push({
severity: Deny,
code: "timezone-observance-missing-dtstart",
message: "\{component.name} observance must contain DTSTART",
line: Some(component.line),
})
}
if !component_has_value(component, "TZOFFSETFROM") {
findings.push({
severity: Deny,
code: "timezone-observance-missing-offset-from",
message: "\{component.name} observance must contain TZOFFSETFROM",
line: Some(component.line),
})
}
if !component_has_value(component, "TZOFFSETTO") {
findings.push({
severity: Deny,
code: "timezone-observance-missing-offset-to",
message: "\{component.name} observance must contain TZOFFSETTO",
line: Some(component.line),
})
}
}
///|
fn validate_time_value(
event : Component,
name : String,
must_be_utc : Bool,
options : ValidationOptions,
findings : Array[Finding],
) -> Unit {
match event.get(name) {
None => ()
Some(value) => {
let valid_time = is_date(value) || is_datetime(value)
if !valid_time {
findings.push({
severity: Deny,
code: "bad-time-value",
message: "\{name} has invalid DATE or DATE-TIME value: \{value}",
line: None,
})
}
if valid_time &&
must_be_utc &&
(!is_datetime(value) || !ends_with_z(value)) {
findings.push({
severity: Deny,
code: "dtstamp-not-utc",
message: "DTSTAMP must be UTC DATE-TIME ending with Z",
line: None,
})
}
if !options.allow_floating_time && is_floating_datetime(value) {
match event.param_value(name, "TZID") {
None =>
findings.push({
severity: Deny,
code: "floating-time",
message: "\{name} is floating time without TZID",
line: None,
})
Some(_) => ()
}
}
}
}
}
///|
fn validate_duration_property(
component : Component,
name : String,
findings : Array[Finding],
) -> Unit {
for prop in component.properties_named(name) {
match parse_duration(prop.value) {
Ok(value) =>
if value.is_zero() {
findings.push({
severity: Warn,
code: "zero-duration",
message: "\{name} is zero length",
line: Some(prop.line),
})
}
Err(err) =>
findings.push({
severity: Deny,
code: "bad-duration",
message: "\{name} has invalid duration: \{err.message()}",
line: Some(prop.line),
})
}
}
}
///|
fn validate_recurrence(
component : Component,
findings : Array[Finding],
) -> Unit {
let rules = component.properties_named("RRULE")
if rules.length() > 1 {
findings.push({
severity: Deny,
code: "duplicate-rrule",
message: "\{component.name} must not contain multiple RRULE properties",
line: Some(rules[1].line),
})
}
for prop in rules {
match parse_recurrence_rule(prop.value) {
Ok(rule) => {
if !rule.is_finite() {
findings.push({
severity: Warn,
code: "rrule-unbounded",
message: "RRULE has no COUNT or UNTIL limit",
line: Some(prop.line),
})
}
if rule.looks_dense_for_feed() {
findings.push({
severity: Warn,
code: "rrule-dense",
message: "RRULE frequency may create a very dense calendar feed",
line: Some(prop.line),
})
}
if rule.frequency is Monthly &&
rule.by_day.length() > 0 &&
rule.by_set_pos.length() == 0 {
findings.push({
severity: Info,
code: "rrule-monthly-byday",
message: "monthly BYDAY without BYSETPOS expands every matching weekday",
line: Some(prop.line),
})
}
}
Err(err) =>
findings.push({
severity: Deny,
code: "bad-rrule",
message: err.message(),
line: Some(prop.line),
})
}
}
}
///|
fn validate_recurrence_dates(
component : Component,
options : ValidationOptions,
findings : Array[Finding],
) -> Unit {
for prop in component.properties_named("EXDATE") {
validate_date_list_property(prop, options, findings)
}
for prop in component.properties_named("RDATE") {
validate_date_list_property(prop, options, findings)
}
if component.has("EXRULE") {
findings.push({
severity: Warn,
code: "deprecated-exrule",
message: "EXRULE is deprecated; prefer EXDATE with RRULE",
line: component_property_line(component, "EXRULE"),
})
}
}
///|
fn validate_date_list_property(
prop : Property,
options : ValidationOptions,
findings : Array[Finding],
) -> Unit {
let values = split_by_char(prop.value, 44)
for value in values {
if !(is_date(value) || is_datetime(value)) {
findings.push({
severity: Deny,
code: "bad-date-list-value",
message: "\{prop.name} contains invalid date value: \{value}",
line: Some(prop.line),
})
}
if !options.allow_floating_time && is_floating_datetime(value) {
match prop.param_value("TZID") {
Some(_) => ()
None =>
findings.push({
severity: Deny,
code: "floating-recurrence-date",
message: "\{prop.name} has floating DATE-TIME without TZID",
line: Some(prop.line),
})
}
}
}
}
///|
fn validate_event_alarms(
component : Component,
findings : Array[Finding],
) -> Unit {
let alarms = component.alarms()
if alarms.length() > 10 {
findings.push({
severity: Warn,
code: "event-many-alarms",
message: "\{component.name} contains \{alarms.length()} alarms",
line: Some(component.line),
})
}
for child in component.components {
if child.name != "VALARM" {
findings.push({
severity: Warn,
code: "event-unknown-child",
message: "\{component.name} contains unsupported child \{child.name}",
line: Some(child.line),
})
}
}
}
///|
fn validate_alarm(alarm : Component, findings : Array[Finding]) -> Unit {
let summary = summarize_alarm(alarm)
if alarm.count("ACTION") != 1 {
findings.push({
severity: Deny,
code: "alarm-action-count",
message: "VALARM must contain exactly one ACTION",
line: Some(alarm.line),
})
}
if !alarm_is_action_supported(summary.action) {
findings.push({
severity: Deny,
code: "alarm-action-unsupported",
message: "VALARM ACTION is unsupported: \{summary.action.name()}",
line: component_property_line(alarm, "ACTION"),
})
}
if alarm.count("TRIGGER") != 1 {
findings.push({
severity: Deny,
code: "alarm-trigger-count",
message: "VALARM must contain exactly one TRIGGER",
line: Some(alarm.line),
})
} else if !trigger_is_relative(summary.trigger) &&
!trigger_is_absolute(summary.trigger) {
findings.push({
severity: Deny,
code: "alarm-bad-trigger",
message: "VALARM TRIGGER must be duration or DATE-TIME",
line: component_property_line(alarm, "TRIGGER"),
})
}
if alarm_requires_description(summary.action) && summary.description is None {
findings.push({
severity: Deny,
code: "alarm-missing-description",
message: "\{summary.action.name()} alarm must contain DESCRIPTION",
line: Some(alarm.line),
})
}
if alarm_requires_summary(summary.action) && summary.summary is None {
findings.push({
severity: Deny,
code: "alarm-missing-summary",
message: "EMAIL alarm must contain SUMMARY",
line: Some(alarm.line),
})
}
if alarm_requires_attendee(summary.action) && summary.attendee_count == 0 {
findings.push({
severity: Deny,
code: "alarm-missing-attendee",
message: "EMAIL alarm must contain at least one ATTENDEE",
line: Some(alarm.line),
})
}
validate_alarm_repeat(alarm, findings)
}
///|
fn validate_alarm_repeat(alarm : Component, findings : Array[Finding]) -> Unit {
if alarm.has("REPEAT") && !alarm.has("DURATION") {
findings.push({
severity: Deny,
code: "alarm-repeat-without-duration",
message: "VALARM REPEAT requires DURATION",
line: component_property_line(alarm, "REPEAT"),
})
}
if alarm.has("DURATION") && !alarm.has("REPEAT") {
findings.push({
severity: Deny,
code: "alarm-duration-without-repeat",
message: "VALARM DURATION requires REPEAT",
line: component_property_line(alarm, "DURATION"),
})
}
validate_duration_property(alarm, "DURATION", findings)
match alarm.get("REPEAT") {
Some(value) =>
if !is_positive_plain_int(value) {
findings.push({
severity: Deny,
code: "alarm-bad-repeat",
message: "VALARM REPEAT must be a positive integer",
line: component_property_line(alarm, "REPEAT"),
})
}
None => ()
}
}
///|
fn validate_event_status(event : Component, findings : Array[Finding]) -> Unit {
match event.get("STATUS") {
Some(value) => {
let normalized = ascii_upper(trim_ascii(value))
if normalized != "TENTATIVE" &&
normalized != "CONFIRMED" &&
normalized != "CANCELLED" {
findings.push({
severity: Warn,
code: "event-bad-status",
message: "VEVENT STATUS is unusual: \{value}",
line: component_property_line(event, "STATUS"),
})
}
}
None => ()
}
}
///|
fn validate_todo_status(todo : Component, findings : Array[Finding]) -> Unit {
match todo.get("STATUS") {
Some(value) => {
let normalized = ascii_upper(trim_ascii(value))
if normalized != "NEEDS-ACTION" &&
normalized != "COMPLETED" &&
normalized != "IN-PROCESS" &&
normalized != "CANCELLED" {
findings.push({
severity: Warn,
code: "todo-bad-status",
message: "VTODO STATUS is unusual: \{value}",
line: component_property_line(todo, "STATUS"),
})
}
}
None => ()
}
}
///|
fn validate_journal_status(
journal : Component,
findings : Array[Finding],
) -> Unit {
match journal.get("STATUS") {
Some(value) => {
let normalized = ascii_upper(trim_ascii(value))
if normalized != "DRAFT" &&
normalized != "FINAL" &&
normalized != "CANCELLED" {
findings.push({
severity: Warn,
code: "journal-bad-status",
message: "VJOURNAL STATUS is unusual: \{value}",
line: component_property_line(journal, "STATUS"),
})
}
}
None => ()
}
}
///|
fn validate_event_transparency(
event : Component,
findings : Array[Finding],
) -> Unit {
match event.get("TRANSP") {
Some(value) => {
let normalized = ascii_upper(trim_ascii(value))
if normalized != "OPAQUE" && normalized != "TRANSPARENT" {
findings.push({
severity: Warn,
code: "event-bad-transparency",
message: "VEVENT TRANSP should be OPAQUE or TRANSPARENT",
line: component_property_line(event, "TRANSP"),
})
}
}
None => ()
}
}
///|
fn validate_event_priority(
event : Component,
findings : Array[Finding],
) -> Unit {
match event.get("PRIORITY") {
Some(value) =>
if !is_priority_value(value) {
findings.push({
severity: Warn,
code: "event-bad-priority",
message: "PRIORITY should be an integer from 0 to 9",
line: component_property_line(event, "PRIORITY"),
})
}
None => ()
}
}
///|
fn validate_percent_complete(
todo : Component,
findings : Array[Finding],
) -> Unit {
match todo.get("PERCENT-COMPLETE") {
Some(value) =>
if !is_percent_value(value) {
findings.push({
severity: Warn,
code: "todo-bad-percent",
message: "PERCENT-COMPLETE should be an integer from 0 to 100",
line: component_property_line(todo, "PERCENT-COMPLETE"),
})
}
None => ()
}
}
///|
fn validate_order(event : Component, findings : Array[Finding]) -> Unit {
match (event.get("DTSTART"), event.get("DTEND")) {
(Some(start), Some(end)) =>
if (is_date(start) && is_datetime(end)) ||
(is_datetime(start) && is_date(end)) {
findings.push({
severity: Deny,
code: "event-time-kind-mismatch",
message: "DTSTART and DTEND must both be DATE or both be DATE-TIME",
line: None,
})
} else if comparable_datetime(start) &&
comparable_datetime(end) &&
end <= start {
findings.push({
severity: Deny,
code: "event-non-positive-duration",
message: "DTEND should be later than DTSTART",
line: None,
})
}
_ => ()
}
}
///|
fn component_property_line(component : Component, name : String) -> Int? {
for prop in component.properties_named(name) {
return Some(prop.line)
}
Some(component.line)
}
///|
fn calendar_has_value(calendar : Calendar, name : String) -> Bool {
match calendar.get(name) {
Some(value) => trim_ascii(value).length() > 0
None => false
}
}
///|
fn component_has_value(component : Component, name : String) -> Bool {
match component.get(name) {
Some(value) => trim_ascii(value).length() > 0
None => false
}
}
///|
fn is_positive_plain_int(value : String) -> Bool {
if !all_digits(value) {
return false
}
let mut non_zero = false
let mut i = 0
while i < value.length() {
if value[i] != 48 {
non_zero = true
}
i += 1
}
non_zero
}
///|
fn is_priority_value(value : String) -> Bool {
let cleaned = trim_ascii(value)
cleaned.length() == 1 && cleaned[0] >= 48 && cleaned[0] <= 57
}
///|
fn is_percent_value(value : String) -> Bool {
let cleaned = trim_ascii(value)
if !all_digits(cleaned) {
return false
}
if cleaned.length() == 1 {
return true
}
if cleaned.length() == 2 {
return true
}
cleaned == "100"
}
///|
fn is_date(s : String) -> Bool {
if s.length() != 8 || !all_digits(s) {
return false
}
let year = digits_to_int(s[0:4].to_owned())
let month = digits_to_int(s[4:6].to_owned())
let day = digits_to_int(s[6:8].to_owned())
if month < 1 || month > 12 || day < 1 {
return false
}
day <= days_in_month(year, month)
}
///|
fn is_datetime(s : String) -> Bool {
let has_z = s.length() == 16 && s[15] == 90
let has_no_z = s.length() == 15
if !has_z && !has_no_z {
return false
}
if s[8] != 84 ||
!is_date(s[0:8].to_owned()) ||
!all_digits(s[9:15].to_owned()) {
return false
}
let hour = digits_to_int(s[9:11].to_owned())
let minute = digits_to_int(s[11:13].to_owned())
let second = digits_to_int(s[13:15].to_owned())
hour <= 23 && minute <= 59 && second <= 60
}
///|
fn digits_to_int(s : String) -> Int {
let mut value = 0
let mut i = 0
while i < s.length() {
value = value * 10 + s[i].to_int() - 48
i += 1
}
value
}
///|
fn days_in_month(year : Int, month : Int) -> Int {
if month == 2 {
if is_leap_year(year) {
29
} else {
28
}
} else if month == 4 || month == 6 || month == 9 || month == 11 {
30
} else {
31
}
}
///|
fn is_leap_year(year : Int) -> Bool {
year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)
}
///|
fn all_digits(s : String) -> Bool {
if s.length() == 0 {
return false
}
let mut i = 0
while i < s.length() {
if s[i] < 48 || s[i] > 57 {
return false
}
i += 1
}
true
}
///|
fn ends_with_z(s : String) -> Bool {
s.length() > 0 && s[s.length() - 1] == 90
}
///|
fn is_floating_datetime(s : String) -> Bool {
s.length() == 15 && is_datetime(s)
}
///|
fn comparable_datetime(s : String) -> Bool {
is_date(s) || is_datetime(s)
}
///|
fn has_deny(findings : Array[Finding]) -> Bool {
for finding in findings {
if finding.severity is Deny {
return true
}
}
false
}