///|
/// Severity used by schedule audit findings.
pub(all) enum AuditSeverity {
AuditInfo
AuditWarning
AuditError
} derive(Eq, Compare, Debug)
///|
/// A stable finding code plus human-readable evidence and remediation.
pub struct AuditFinding {
severity : AuditSeverity
code : String
message : String
evidence : Array[DateTime]
suggestion : String
} derive(Eq, Debug)
///|
/// Distribution of recurrence instances over ISO weekdays.
pub struct WeekdayHistogram {
monday : Int
tuesday : Int
wednesday : Int
thursday : Int
friday : Int
saturday : Int
sunday : Int
} derive(Eq, Debug)
///|
pub fn WeekdayHistogram::empty() -> WeekdayHistogram {
{
monday: 0,
tuesday: 0,
wednesday: 0,
thursday: 0,
friday: 0,
saturday: 0,
sunday: 0,
}
}
///|
pub fn WeekdayHistogram::total(self : WeekdayHistogram) -> Int {
self.monday +
self.tuesday +
self.wednesday +
self.thursday +
self.friday +
self.saturday +
self.sunday
}
///|
pub fn WeekdayHistogram::weekend_total(self : WeekdayHistogram) -> Int {
self.saturday + self.sunday
}
///|
fn histogram_add(
histogram : WeekdayHistogram,
weekday : Weekday,
) -> WeekdayHistogram {
match weekday {
Monday => { ..histogram, monday: histogram.monday + 1 }
Tuesday => { ..histogram, tuesday: histogram.tuesday + 1 }
Wednesday => { ..histogram, wednesday: histogram.wednesday + 1 }
Thursday => { ..histogram, thursday: histogram.thursday + 1 }
Friday => { ..histogram, friday: histogram.friday + 1 }
Saturday => { ..histogram, saturday: histogram.saturday + 1 }
Sunday => { ..histogram, sunday: histogram.sunday + 1 }
}
}
///|
/// Summary metrics and findings produced for one expanded recurrence set.
pub struct ScheduleAudit {
occurrence_count : Int
unique_count : Int
duplicate_count : Int
weekend_count : Int
outside_business_hours_count : Int
first_occurrence : DateTime?
last_occurrence : DateTime?
minimum_gap_seconds : Int64?
maximum_gap_seconds : Int64?
average_gap_seconds : Int64?
longest_daily_streak : Int
weekday_histogram : WeekdayHistogram
findings : Array[AuditFinding]
} derive(Eq, Debug)
///|
fn is_weekend(value : DateTime) -> Bool {
match value.date.weekday() {
Saturday | Sunday => true
_ => false
}
}
///|
fn is_outside_business_hours(
value : DateTime,
business_start_hour : Int,
business_end_hour : Int,
) -> Bool {
value.time.hour < business_start_hour || value.time.hour >= business_end_hour
}
///|
fn sorted_datetimes(values : Array[DateTime]) -> Array[DateTime] {
let result = values.copy()
result.sort()
result
}
///|
fn unique_datetimes(values : Array[DateTime]) -> Array[DateTime] {
let result : Array[DateTime] = []
for value in sorted_datetimes(values) {
if result.length() == 0 || result[result.length() - 1] != value {
result.push(value)
}
}
result
}
///|
fn duplicate_evidence(values : Array[DateTime]) -> Array[DateTime] {
let sorted = sorted_datetimes(values)
let result : Array[DateTime] = []
for index = 1; index < sorted.length(); index = index + 1 {
if sorted[index] == sorted[index - 1] && !result.contains(sorted[index]) {
result.push(sorted[index])
}
}
result
}
///|
fn gap_statistics(unique : Array[DateTime]) -> (Int64?, Int64?, Int64?) {
if unique.length() < 2 {
return (None, None, None)
}
let mut minimum = unique[1].to_epoch_second() - unique[0].to_epoch_second()
let mut maximum = minimum
let mut total = 0L
for index = 1; index < unique.length(); index = index + 1 {
let gap = unique[index].to_epoch_second() -
unique[index - 1].to_epoch_second()
minimum = minimum.min(gap)
maximum = maximum.max(gap)
total += gap
}
(Some(minimum), Some(maximum), Some(total / (unique.length() - 1).to_int64()))
}
///|
fn longest_daily_streak(values : Array[DateTime]) -> Int {
if values.length() == 0 {
return 0
}
let unique_days : Array[Int] = []
for value in values {
let day = value.date.to_epoch_day()
if !unique_days.contains(day) {
unique_days.push(day)
}
}
unique_days.sort()
let mut best = 1
let mut current = 1
for index = 1; index < unique_days.length(); index = index + 1 {
if unique_days[index] == unique_days[index - 1] + 1 {
current += 1
best = best.max(current)
} else {
current = 1
}
}
best
}
///|
fn finding(
severity : AuditSeverity,
code : String,
message : String,
evidence : Array[DateTime],
suggestion : String,
) -> AuditFinding {
{ severity, code, message, evidence, suggestion }
}
///|
/// Analyze concrete occurrence data and return reproducible findings.
pub fn audit_occurrences(
values : Array[DateTime],
business_start_hour? : Int = 9,
business_end_hour? : Int = 18,
expected_minimum? : Int = 1,
dense_gap_threshold_seconds? : Int64 = 300L,
) -> ScheduleAudit {
let unique = unique_datetimes(values)
let duplicates = duplicate_evidence(values)
let weekend_values : Array[DateTime] = []
let outside_values : Array[DateTime] = []
let mut histogram = WeekdayHistogram::empty()
for value in unique {
histogram = histogram_add(histogram, value.date.weekday())
if is_weekend(value) {
weekend_values.push(value)
}
if is_outside_business_hours(value, business_start_hour, business_end_hour) {
outside_values.push(value)
}
}
let (minimum_gap, maximum_gap, average_gap) = gap_statistics(unique)
let findings : Array[AuditFinding] = []
if unique.length() < expected_minimum {
findings.push(
finding(
AuditError,
"AUDIT_TOO_FEW_OCCURRENCES",
"The schedule produces fewer occurrences than expected.",
unique,
"Review the date window, COUNT, UNTIL and BY filters.",
),
)
}
if duplicates.length() > 0 {
findings.push(
finding(
AuditWarning,
"AUDIT_DUPLICATE_OCCURRENCE",
"Multiple recurrence sources produce the same timestamp.",
duplicates,
"Remove redundant RRULE or RDATE entries if duplication is unintended.",
),
)
}
if weekend_values.length() > 0 {
findings.push(
finding(
AuditInfo,
"AUDIT_WEEKEND_OCCURRENCE",
"The schedule includes Saturday or Sunday occurrences.",
weekend_values,
"Confirm weekend execution is intended or add weekday filters.",
),
)
}
if outside_values.length() > 0 {
findings.push(
finding(
AuditInfo,
"AUDIT_OUTSIDE_BUSINESS_HOURS",
"The schedule includes occurrences outside configured business hours.",
outside_values,
"Adjust BYHOUR or the audit business-hours window.",
),
)
}
match minimum_gap {
Some(gap) if gap < dense_gap_threshold_seconds =>
findings.push(
finding(
AuditWarning,
"AUDIT_DENSE_SCHEDULE",
"Two adjacent occurrences are closer than the configured threshold.",
unique,
"Increase INTERVAL or narrow the BYMINUTE/BYSECOND selectors.",
),
)
_ => ()
}
{
occurrence_count: values.length(),
unique_count: unique.length(),
duplicate_count: values.length() - unique.length(),
weekend_count: weekend_values.length(),
outside_business_hours_count: outside_values.length(),
first_occurrence: if unique.length() == 0 {
None
} else {
Some(unique[0])
},
last_occurrence: unique.last(),
minimum_gap_seconds: minimum_gap,
maximum_gap_seconds: maximum_gap,
average_gap_seconds: average_gap,
longest_daily_streak: longest_daily_streak(unique),
weekday_histogram: histogram,
findings,
}
}
///|
/// Expand and audit a recurrence rule in one bounded operation.
pub fn audit_rule(
start : DateTime,
rule : RRule,
options : ExpansionOptions,
business_start_hour? : Int = 9,
business_end_hour? : Int = 18,
) -> ScheduleAudit raise ExpansionError {
audit_occurrences(
expand_rrule(start, rule, options),
business_start_hour~,
business_end_hour~,
)
}
///|
/// One occurrence represented as a half-open [start, finish) interval.
pub struct EventSlot {
schedule : String
ordinal : Int
start : DateTime
finish : DateTime
} derive(Eq, Debug)
///|
pub suberror EventSlotError {
NonPositiveDuration
} derive(Eq, Debug)
///|
pub fn make_event_slots(
schedule : String,
starts : Array[DateTime],
duration : Duration,
) -> Array[EventSlot] raise {
let seconds = duration.fixed_seconds()
if seconds <= 0L {
raise NonPositiveDuration
}
let result : Array[EventSlot] = []
for index, start in starts {
result.push({
schedule,
ordinal: index + 1,
start,
finish: start.add_seconds(seconds),
})
}
result
}
///|
pub struct ScheduleCollision {
left : EventSlot
right : EventSlot
overlap_start : DateTime
overlap_finish : DateTime
overlap_seconds : Int64
} derive(Eq, Debug)
///|
fn slot_overlaps(left : EventSlot, right : EventSlot) -> Bool {
left.start.to_epoch_second() < right.finish.to_epoch_second() &&
right.start.to_epoch_second() < left.finish.to_epoch_second()
}
///|
fn later_datetime(left : DateTime, right : DateTime) -> DateTime {
if left.to_epoch_second() >= right.to_epoch_second() {
left
} else {
right
}
}
///|
fn earlier_datetime(left : DateTime, right : DateTime) -> DateTime {
if left.to_epoch_second() <= right.to_epoch_second() {
left
} else {
right
}
}
///|
/// Detect interval collisions between two named schedules. Touching endpoints
/// are not collisions because event slots use half-open interval semantics.
pub fn detect_collisions(
left : Array[EventSlot],
right : Array[EventSlot],
) -> Array[ScheduleCollision] {
let result : Array[ScheduleCollision] = []
for left_slot in left {
for right_slot in right {
if slot_overlaps(left_slot, right_slot) {
let overlap_start = later_datetime(left_slot.start, right_slot.start)
let overlap_finish = earlier_datetime(
left_slot.finish,
right_slot.finish,
)
result.push({
left: left_slot,
right: right_slot,
overlap_start,
overlap_finish,
overlap_seconds: overlap_finish.to_epoch_second() -
overlap_start.to_epoch_second(),
})
}
}
}
result
}
///|
/// Behavioral impact of replacing one rule with another over a finite horizon.
pub struct ChangeImpact {
baseline_count : Int
candidate_count : Int
added : Array[DateTime]
removed : Array[DateTime]
unchanged : Array[DateTime]
first_divergence : DateTime?
similarity_permille : Int
} derive(Eq, Debug)
///|
fn first_of_two_arrays(
left : Array[DateTime],
right : Array[DateTime],
) -> DateTime? {
if left.length() == 0 {
return if right.length() == 0 { None } else { Some(right[0]) }
}
if right.length() == 0 {
return if left.length() == 0 { None } else { Some(left[0]) }
}
Some(earlier_datetime(left[0], right[0]))
}
///|
/// Compare rule behavior using occurrence sets rather than textual syntax.
pub fn analyze_rule_change(
start : DateTime,
baseline : RRule,
candidate : RRule,
options : ExpansionOptions,
) -> ChangeImpact raise ExpansionError {
let baseline_values = expand_rrule(start, baseline, options)
let candidate_values = expand_rrule(start, candidate, options)
let difference = compare_rules(start, baseline, candidate, options)
let union_count = difference.only_left.length() +
difference.only_right.length() +
difference.shared.length()
let similarity = if union_count == 0 {
1000
} else {
difference.shared.length() * 1000 / union_count
}
{
baseline_count: baseline_values.length(),
candidate_count: candidate_values.length(),
added: difference.only_right,
removed: difference.only_left,
unchanged: difference.shared,
first_divergence: first_of_two_arrays(
difference.only_left,
difference.only_right,
),
similarity_permille: similarity,
}
}
///|
pub fn AuditSeverity::label(self : AuditSeverity) -> String {
match self {
AuditInfo => "info"
AuditWarning => "warning"
AuditError => "error"
}
}
///|
/// Stable plain-text report intended for terminals, issues and CI logs.
pub fn ScheduleAudit::to_text(self : ScheduleAudit) -> String {
let lines : Array[String] = [
"occurrences: " + self.occurrence_count.to_string(),
"unique: " + self.unique_count.to_string(),
"duplicates: " + self.duplicate_count.to_string(),
"weekend: " + self.weekend_count.to_string(),
"outside-business-hours: " + self.outside_business_hours_count.to_string(),
"longest-daily-streak: " + self.longest_daily_streak.to_string(),
]
match self.minimum_gap_seconds {
Some(value) => lines.push("minimum-gap-seconds: " + value.to_string())
None => lines.push("minimum-gap-seconds: n/a")
}
match self.maximum_gap_seconds {
Some(value) => lines.push("maximum-gap-seconds: " + value.to_string())
None => lines.push("maximum-gap-seconds: n/a")
}
if self.findings.length() == 0 {
lines.push("findings: none")
} else {
lines.push("findings:")
for item in self.findings {
lines.push(
"- [" + item.severity.label() + "] " + item.code + ": " + item.message,
)
}
}
lines.join("\n")
}