use crate::calendar::Calendar;
use crate::error::Result;
use chrono::{DateTime, Duration};
use chrono_tz::Tz;
#[derive(Debug, Clone)]
pub struct TimeGap {
pub start: DateTime<Tz>,
pub end: DateTime<Tz>,
pub duration: Duration,
pub before_event: Option<String>,
pub after_event: Option<String>,
}
impl TimeGap {
pub fn new(
start: DateTime<Tz>,
end: DateTime<Tz>,
before_event: Option<String>,
after_event: Option<String>,
) -> Self {
let duration = end.signed_duration_since(start);
Self {
start,
end,
duration,
before_event,
after_event,
}
}
pub fn duration_minutes(&self) -> i64 {
self.duration.num_minutes()
}
pub fn duration_hours(&self) -> i64 {
self.duration.num_hours()
}
pub fn is_at_least(&self, min_duration: Duration) -> bool {
self.duration >= min_duration
}
}
#[derive(Debug, Clone)]
pub struct EventOverlap {
pub start: DateTime<Tz>,
pub end: DateTime<Tz>,
pub duration: Duration,
pub events: Vec<String>,
}
impl EventOverlap {
pub fn new(start: DateTime<Tz>, end: DateTime<Tz>, events: Vec<String>) -> Self {
let duration = end.signed_duration_since(start);
Self {
start,
end,
duration,
events,
}
}
pub fn duration_minutes(&self) -> i64 {
self.duration.num_minutes()
}
pub fn event_count(&self) -> usize {
self.events.len()
}
}
#[derive(Debug, Clone)]
pub struct ScheduleDensity {
pub total_duration: Duration,
pub busy_duration: Duration,
pub free_duration: Duration,
pub occupancy_percentage: f64,
pub event_count: usize,
pub gap_count: usize,
pub overlap_count: usize,
}
impl ScheduleDensity {
pub fn is_busy(&self) -> bool {
self.occupancy_percentage > 60.0
}
pub fn is_light(&self) -> bool {
self.occupancy_percentage < 30.0
}
pub fn has_conflicts(&self) -> bool {
self.overlap_count > 0
}
}
pub fn find_gaps(
calendar: &Calendar,
start: DateTime<Tz>,
end: DateTime<Tz>,
min_gap_duration: Duration,
) -> Result<Vec<TimeGap>> {
if start >= end {
return Err(crate::error::EventixError::ValidationError(
"Start time must be before end time".to_string(),
));
}
if min_gap_duration < Duration::zero() {
return Err(crate::error::EventixError::ValidationError(
"min_gap_duration cannot be negative".to_string(),
));
}
let mut occurrences = calendar.events_between(start, end)?;
occurrences.retain(|e| e.event.is_active());
occurrences.sort_by_key(|o| o.occurrence_time);
let mut gaps = Vec::new();
let mut current_time = start;
let mut last_event_title: Option<String> = None;
for occurrence in occurrences.iter() {
let event_start = occurrence.occurrence_time;
if event_start > current_time {
let gap = TimeGap::new(
current_time,
event_start,
last_event_title.clone(),
Some(occurrence.title().to_string()),
);
if gap.duration >= min_gap_duration {
gaps.push(gap);
}
}
let event_end = occurrence.end_time();
if event_end > current_time {
current_time = event_end;
last_event_title = Some(occurrence.title().to_string());
}
}
if end > current_time {
let gap = TimeGap::new(current_time, end, last_event_title, None);
if gap.duration >= min_gap_duration {
gaps.push(gap);
}
}
Ok(gaps)
}
pub fn find_overlaps(
calendar: &Calendar,
start: DateTime<Tz>,
end: DateTime<Tz>,
) -> Result<Vec<EventOverlap>> {
if start >= end {
return Err(crate::error::EventixError::ValidationError(
"Start time must be before end time".to_string(),
));
}
use std::collections::BTreeSet;
let mut occurrences = calendar.events_between(start, end)?;
occurrences.retain(|e| e.event.is_active());
occurrences.retain(|occ| occ.occurrence_time != occ.end_time());
if occurrences.len() < 2 {
return Ok(Vec::new());
}
let mut checkpoints: Vec<(DateTime<Tz>, bool, usize)> =
Vec::with_capacity(occurrences.len() * 2);
for (i, occ) in occurrences.iter().enumerate() {
checkpoints.push((occ.occurrence_time, false, i)); checkpoints.push((occ.end_time(), true, i)); }
checkpoints.sort_by(|a, b| {
a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)) });
let mut active: BTreeSet<usize> = BTreeSet::new();
let mut overlaps = Vec::new();
for (_time, is_end, idx) in checkpoints {
if is_end {
active.remove(&idx);
} else {
for &active_idx in &active {
let e1 = &occurrences[idx];
let e2 = &occurrences[active_idx];
let overlap_start = e1.occurrence_time.max(e2.occurrence_time);
let overlap_end = e1.end_time().min(e2.end_time());
overlaps.push(EventOverlap::new(
overlap_start,
overlap_end,
vec![e1.title().to_string(), e2.title().to_string()],
));
}
active.insert(idx);
}
}
Ok(overlaps)
}
pub fn calculate_density(
calendar: &Calendar,
start: DateTime<Tz>,
end: DateTime<Tz>,
) -> Result<ScheduleDensity> {
if start >= end {
return Err(crate::error::EventixError::ValidationError(
"Start time must be before end time".to_string(),
));
}
let total_duration = end.signed_duration_since(start);
let mut occurrences = calendar.events_between(start, end)?;
occurrences.retain(|e| e.event.is_active());
occurrences.sort_by_key(|o| o.occurrence_time);
let mut busy_duration = Duration::zero();
let mut current_end: Option<DateTime<Tz>> = None;
for occurrence in occurrences.iter() {
let event_start = occurrence.occurrence_time.max(start);
let event_end = occurrence.end_time().min(end);
if event_end <= event_start {
continue;
}
match current_end {
None => {
current_end = Some(event_end);
busy_duration += event_end.signed_duration_since(event_start);
}
Some(prev_end) => {
if event_start >= prev_end {
busy_duration += event_end.signed_duration_since(event_start);
current_end = Some(event_end);
} else if event_end > prev_end {
busy_duration += event_end.signed_duration_since(prev_end);
current_end = Some(event_end);
}
}
}
}
let free_duration = total_duration - busy_duration;
let occupancy_percentage =
(busy_duration.num_seconds() as f64 / total_duration.num_seconds() as f64) * 100.0;
let gaps = find_gaps(calendar, start, end, Duration::minutes(0))?;
let overlaps = find_overlaps(calendar, start, end)?;
Ok(ScheduleDensity {
total_duration,
busy_duration,
free_duration,
occupancy_percentage,
event_count: occurrences.len(),
gap_count: gaps.len(),
overlap_count: overlaps.len(),
})
}
pub fn find_longest_gap(
calendar: &Calendar,
start: DateTime<Tz>,
end: DateTime<Tz>,
) -> Result<Option<TimeGap>> {
let gaps = find_gaps(calendar, start, end, Duration::minutes(0))?;
Ok(gaps.into_iter().max_by_key(|g| g.duration))
}
pub fn find_available_slots(
calendar: &Calendar,
start: DateTime<Tz>,
end: DateTime<Tz>,
required_duration: Duration,
) -> Result<Vec<TimeGap>> {
find_gaps(calendar, start, end, required_duration)
}
pub fn is_slot_available(
calendar: &Calendar,
slot_start: DateTime<Tz>,
slot_end: DateTime<Tz>,
) -> Result<bool> {
if slot_start >= slot_end {
return Err(crate::error::EventixError::ValidationError(
"Slot start time must be before end time".to_string(),
));
}
for event in calendar.get_events() {
if !event.is_active() {
continue;
}
let duration = event.duration();
if duration <= Duration::zero() {
continue;
}
let query_start = slot_start - duration;
let occurrences = event.occurrences_between(query_start, slot_end, 100_000)?;
for occurrence in occurrences {
let event_end = occurrence + duration;
if occurrence < slot_end && slot_start < event_end {
return Ok(false);
}
}
}
Ok(true)
}
pub fn suggest_alternatives(
calendar: &Calendar,
requested_start: DateTime<Tz>,
duration: Duration,
search_window: Duration,
) -> Result<Vec<DateTime<Tz>>> {
if duration <= Duration::zero() {
return Err(crate::error::EventixError::ValidationError(
"Duration must be greater than zero".to_string(),
));
}
if search_window <= Duration::zero() {
return Err(crate::error::EventixError::ValidationError(
"Search window must be greater than zero".to_string(),
));
}
let search_start = requested_start - search_window;
let search_end = requested_start + search_window;
let gaps = find_gaps(calendar, search_start, search_end, duration)?;
let mut suggestions = Vec::new();
for gap in gaps {
if gap.duration >= duration {
suggestions.push(gap.start);
let mut slot_start = gap.start + Duration::hours(1);
while slot_start + duration <= gap.end {
suggestions.push(slot_start);
slot_start += Duration::hours(1);
}
}
}
suggestions.sort();
Ok(suggestions)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::len_zero)]
use super::*;
use crate::timezone::parse_datetime_with_tz;
use crate::Calendar;
use crate::Event;
fn create_test_calendar() -> Result<Calendar> {
let mut cal = Calendar::new("Test Calendar");
let event1 = Event::builder()
.title("Morning Meeting")
.start("2025-11-01 09:00:00", "UTC")
.duration_hours(1)
.build()?;
let event2 = Event::builder()
.title("Lunch")
.start("2025-11-01 12:00:00", "UTC")
.duration_hours(1)
.build()?;
let event3 = Event::builder()
.title("Afternoon Meeting")
.start("2025-11-01 15:00:00", "UTC")
.duration_hours(2)
.build()?;
cal.add_event(event1);
cal.add_event(event2);
cal.add_event(event3);
Ok(cal)
}
#[test]
fn test_find_gaps() {
let cal = create_test_calendar().unwrap();
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let start = parse_datetime_with_tz("2025-11-01 08:00:00", tz).unwrap();
let end = parse_datetime_with_tz("2025-11-01 18:00:00", tz).unwrap();
let gaps = find_gaps(&cal, start, end, Duration::minutes(30)).unwrap();
assert!(gaps.len() >= 3);
}
#[test]
fn test_find_overlaps_no_conflict() {
let cal = create_test_calendar().unwrap();
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let start = parse_datetime_with_tz("2025-11-01 08:00:00", tz).unwrap();
let end = parse_datetime_with_tz("2025-11-01 18:00:00", tz).unwrap();
let overlaps = find_overlaps(&cal, start, end).unwrap();
assert_eq!(overlaps.len(), 0);
}
#[test]
fn test_find_overlaps_with_conflict() {
let mut cal = Calendar::new("Test");
let event1 = Event::builder()
.title("Meeting 1")
.start("2025-11-01 09:00:00", "UTC")
.duration_hours(2)
.build()
.unwrap();
let event2 = Event::builder()
.title("Meeting 2")
.start("2025-11-01 10:00:00", "UTC")
.duration_hours(1)
.build()
.unwrap();
cal.add_event(event1);
cal.add_event(event2);
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let start = parse_datetime_with_tz("2025-11-01 08:00:00", tz).unwrap();
let end = parse_datetime_with_tz("2025-11-01 18:00:00", tz).unwrap();
let overlaps = find_overlaps(&cal, start, end).unwrap();
assert_eq!(overlaps.len(), 1);
assert_eq!(overlaps[0].duration_minutes(), 60);
}
#[test]
fn test_calculate_density() {
let cal = create_test_calendar().unwrap();
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let start = parse_datetime_with_tz("2025-11-01 08:00:00", tz).unwrap();
let end = parse_datetime_with_tz("2025-11-01 18:00:00", tz).unwrap();
let density = calculate_density(&cal, start, end).unwrap();
assert_eq!(density.event_count, 3);
assert!(density.occupancy_percentage > 0.0);
assert!(density.occupancy_percentage < 100.0);
assert_eq!(density.overlap_count, 0);
}
#[test]
fn test_is_slot_available() {
let cal = create_test_calendar().unwrap();
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let slot_start = parse_datetime_with_tz("2025-11-01 10:00:00", tz).unwrap();
let slot_end = parse_datetime_with_tz("2025-11-01 11:00:00", tz).unwrap();
assert!(is_slot_available(&cal, slot_start, slot_end).unwrap());
let conflict_start = parse_datetime_with_tz("2025-11-01 09:30:00", tz).unwrap();
let conflict_end = parse_datetime_with_tz("2025-11-01 10:30:00", tz).unwrap();
assert!(!is_slot_available(&cal, conflict_start, conflict_end).unwrap());
}
#[test]
fn test_find_longest_gap() {
let cal = create_test_calendar().unwrap();
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let start = parse_datetime_with_tz("2025-11-01 08:00:00", tz).unwrap();
let end = parse_datetime_with_tz("2025-11-01 18:00:00", tz).unwrap();
let longest = find_longest_gap(&cal, start, end).unwrap();
assert!(longest.is_some());
let gap = longest.unwrap();
assert!(gap.duration_minutes() >= 120); }
#[test]
fn test_find_available_slots() {
let cal = create_test_calendar().unwrap();
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let start = parse_datetime_with_tz("2025-11-01 08:00:00", tz).unwrap();
let end = parse_datetime_with_tz("2025-11-01 18:00:00", tz).unwrap();
let slots = find_available_slots(&cal, start, end, Duration::hours(1)).unwrap();
assert!(slots.len() > 0);
for slot in slots {
assert!(slot.duration >= Duration::hours(1));
}
}
#[test]
fn test_suggest_alternatives() {
let cal = create_test_calendar().unwrap();
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let requested = parse_datetime_with_tz("2025-11-01 09:30:00", tz).unwrap();
let alternatives =
suggest_alternatives(&cal, requested, Duration::hours(1), Duration::hours(4)).unwrap();
assert!(alternatives.len() > 0);
}
#[test]
fn test_schedule_density_busy() {
let mut cal = Calendar::new("Busy");
for hour in 9..17 {
let event = Event::builder()
.title(format!("Meeting {}", hour))
.start(&format!("2025-11-01 {:02}:00:00", hour), "UTC")
.duration_minutes(45)
.build()
.unwrap();
cal.add_event(event);
}
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let start = parse_datetime_with_tz("2025-11-01 09:00:00", tz).unwrap();
let end = parse_datetime_with_tz("2025-11-01 17:00:00", tz).unwrap();
let density = calculate_density(&cal, start, end).unwrap();
assert!(density.is_busy());
assert!(density.occupancy_percentage > 60.0);
}
#[test]
fn test_calculate_density_with_overlapping_events() {
let mut cal = Calendar::new("Overlapping");
cal.add_event(
Event::builder()
.title("Event A")
.start("2025-11-01 09:00:00", "UTC")
.duration_hours(2)
.build()
.unwrap(),
);
cal.add_event(
Event::builder()
.title("Event B")
.start("2025-11-01 10:00:00", "UTC")
.duration_hours(2)
.build()
.unwrap(),
);
let tz = crate::timezone::parse_timezone("UTC").unwrap();
let start = parse_datetime_with_tz("2025-11-01 09:00:00", tz).unwrap();
let end = parse_datetime_with_tz("2025-11-01 12:00:00", tz).unwrap();
let density = calculate_density(&cal, start, end).unwrap();
assert_eq!(density.busy_duration.num_hours(), 3);
assert_eq!(density.free_duration.num_seconds(), 0);
assert!((density.occupancy_percentage - 100.0).abs() < 0.1);
assert_eq!(density.overlap_count, 1);
}
}