Skip to main content

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}