truth_engine/availability.rs
1//! Multi-stream availability merging with privacy-preserving output.
2//!
3//! Accepts N event streams (from different calendars/providers), merges them into
4//! unified busy/free blocks within a time window. Supports privacy levels to control
5//! how much source information is exposed.
6//!
7//! This module is the core of the "Unified Availability Graph" — it computes the
8//! single source of truth for a user's availability across all their calendars.
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::expander::ExpandedEvent;
14use crate::freebusy::{self, FreeSlot};
15
16/// A named event stream from a single calendar source.
17#[derive(Debug, Clone)]
18pub struct EventStream {
19 /// Opaque identifier for this stream (e.g., "work-google", "personal-icloud").
20 pub stream_id: String,
21 /// The events in this stream (already expanded from RRULEs if applicable).
22 pub events: Vec<ExpandedEvent>,
23}
24
25/// Privacy level for availability output.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
27pub enum PrivacyLevel {
28 /// Show time ranges and source count per busy block.
29 Full,
30 /// Show only busy/free time ranges — no source details leak through.
31 /// `source_count` is set to 0 for all busy blocks.
32 #[default]
33 Opaque,
34}
35
36/// A merged busy block in the unified availability view.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct BusyBlock {
39 /// Start of the busy period.
40 pub start: DateTime<Utc>,
41 /// End of the busy period.
42 pub end: DateTime<Utc>,
43 /// Number of source streams that contributed events to this block.
44 /// Set to 0 when privacy is `Opaque`.
45 pub source_count: usize,
46}
47
48/// Unified availability result after merging N event streams.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct UnifiedAvailability {
51 /// Merged busy blocks (sorted by start, non-overlapping).
52 pub busy: Vec<BusyBlock>,
53 /// Free slots (gaps between busy blocks within the window).
54 pub free: Vec<FreeSlot>,
55 /// The analysis window start.
56 pub window_start: DateTime<Utc>,
57 /// The analysis window end.
58 pub window_end: DateTime<Utc>,
59 /// Privacy level applied to this result.
60 pub privacy: PrivacyLevel,
61}
62
63/// Merge N event streams into unified availability within a time window.
64///
65/// All events from all streams are flattened, clipped to the window, and merged
66/// into non-overlapping busy blocks. Free slots are the gaps between busy blocks.
67///
68/// When `privacy` is `Opaque`, `source_count` is set to 0 on all busy blocks —
69/// no information about how many calendars contributed leaks through.
70///
71/// # Arguments
72///
73/// * `streams` — The event streams to merge (from different calendars/providers).
74/// * `window_start` — Start of the time window to analyze.
75/// * `window_end` — End of the time window to analyze.
76/// * `privacy` — Controls whether source count is included in busy blocks.
77pub fn merge_availability(
78 streams: &[EventStream],
79 window_start: DateTime<Utc>,
80 window_end: DateTime<Utc>,
81 privacy: PrivacyLevel,
82) -> UnifiedAvailability {
83 if streams.is_empty() || window_start >= window_end {
84 let free = if window_start < window_end {
85 vec![FreeSlot {
86 start: window_start,
87 end: window_end,
88 duration_minutes: (window_end - window_start).num_minutes(),
89 }]
90 } else {
91 vec![]
92 };
93 return UnifiedAvailability {
94 busy: vec![],
95 free,
96 window_start,
97 window_end,
98 privacy,
99 };
100 }
101
102 // Flatten all events from all streams into a single list.
103 let all_events: Vec<ExpandedEvent> = streams
104 .iter()
105 .flat_map(|s| s.events.iter().cloned())
106 .collect();
107
108 // Compute merged busy periods using the existing freebusy algorithm.
109 let merged_intervals = freebusy::merge_busy_periods(&all_events, window_start, window_end);
110
111 // Build busy blocks with source count tracking.
112 let busy: Vec<BusyBlock> = if privacy == PrivacyLevel::Full {
113 // For Full privacy, compute source counts via sweep-line.
114 compute_busy_blocks_with_sources(streams, &merged_intervals, window_start, window_end)
115 } else {
116 // For Opaque privacy, source_count is always 0.
117 merged_intervals
118 .iter()
119 .map(|(start, end)| BusyBlock {
120 start: *start,
121 end: *end,
122 source_count: 0,
123 })
124 .collect()
125 };
126
127 // Compute free slots from the merged intervals.
128 let free = freebusy::find_free_slots(&all_events, window_start, window_end);
129
130 UnifiedAvailability {
131 busy,
132 free,
133 window_start,
134 window_end,
135 privacy,
136 }
137}
138
139/// Find the first free slot of at least `min_duration_minutes` across N merged
140/// event streams.
141///
142/// This is a convenience function that merges all streams and returns the first
143/// slot meeting the minimum duration requirement.
144pub fn find_first_free_across(
145 streams: &[EventStream],
146 window_start: DateTime<Utc>,
147 window_end: DateTime<Utc>,
148 min_duration_minutes: i64,
149) -> Option<FreeSlot> {
150 let all_events: Vec<ExpandedEvent> = streams
151 .iter()
152 .flat_map(|s| s.events.iter().cloned())
153 .collect();
154
155 freebusy::find_first_free_slot(&all_events, window_start, window_end, min_duration_minutes)
156}
157
158/// Compute busy blocks with per-block source counts.
159///
160/// For each merged interval, count how many distinct streams contributed at least
161/// one event that overlaps with that interval.
162fn compute_busy_blocks_with_sources(
163 streams: &[EventStream],
164 merged_intervals: &[(DateTime<Utc>, DateTime<Utc>)],
165 window_start: DateTime<Utc>,
166 window_end: DateTime<Utc>,
167) -> Vec<BusyBlock> {
168 merged_intervals
169 .iter()
170 .map(|(interval_start, interval_end)| {
171 // Count how many streams have at least one event overlapping this interval.
172 let source_count = streams
173 .iter()
174 .filter(|stream| {
175 stream.events.iter().any(|event| {
176 // Clip event to window first.
177 let ev_start = event.start.max(window_start);
178 let ev_end = event.end.min(window_end);
179 // Check overlap with the merged interval.
180 ev_start < *interval_end && ev_end > *interval_start
181 })
182 })
183 .count();
184 BusyBlock {
185 start: *interval_start,
186 end: *interval_end,
187 source_count,
188 }
189 })
190 .collect()
191}