Skip to main content

car_integrations/calendar/
mod.rs

1//! Calendar capability — list calendars, list upcoming events.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5#[cfg(target_os = "macos")]
6use std::process::Command;
7
8use super::{Availability, IntegrationError};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Calendar {
12    /// Stable identifier within the current host.
13    pub id: String,
14    /// Display name as shown in the user's Calendar app.
15    pub title: String,
16    /// Source label (account / provider) where the OS exposes it.
17    pub source: Option<String>,
18    /// Calendar color as `#RRGGBB` hex, when available.
19    pub color: Option<String>,
20    /// Whether the user can create/update events on this calendar.
21    pub writable: bool,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Event {
26    pub id: String,
27    pub calendar_id: String,
28    pub title: String,
29    pub start: DateTime<Utc>,
30    pub end: DateTime<Utc>,
31    #[serde(default)]
32    pub all_day: bool,
33    pub location: Option<String>,
34    pub notes: Option<String>,
35    /// Participants with their RSVP status — enriched from EventKit
36    /// (`EKParticipant`) rather than a bare display name, so a consumer can tell
37    /// a firm commitment from a tentative "maybe" (e.g. conflict detection that
38    /// must not flag two overlapping events as a hard clash when the user is only
39    /// tentative on one). See #68.
40    #[serde(default)]
41    pub attendees: Vec<Attendee>,
42    /// Overall event status — `confirmed` | `tentative` | `canceled` | `none`
43    /// (`EKEvent.status`). `None` when the backend doesn't report it.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub status: Option<String>,
46}
47
48/// One calendar event participant, from `EKParticipant`.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct Attendee {
51    /// Display name, when known.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub name: Option<String>,
54    /// Email, parsed from the participant's `mailto:` URL when present.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub email: Option<String>,
57    /// RSVP status — `accepted` | `declined` | `tentative` | `pending` |
58    /// `delegated` | `completed` | `in_process` | `unknown`
59    /// (`EKParticipant.participantStatus`).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub status: Option<String>,
62    /// Role — `required` | `optional` | `chair` | `non_participant` | `unknown`
63    /// (`EKParticipant.participantRole`).
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub role: Option<String>,
66    /// Whether this participant is the current user (`EKParticipant.isCurrentUser`).
67    #[serde(default)]
68    pub is_current_user: bool,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct CalendarListing {
73    #[serde(flatten)]
74    pub availability: Availability,
75    pub calendars: Vec<Calendar>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct EventListing {
80    #[serde(flatten)]
81    pub availability: Availability,
82    pub events: Vec<Event>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct EventCreateInput {
87    pub calendar_id: String,
88    pub title: String,
89    pub start: DateTime<Utc>,
90    pub end: DateTime<Utc>,
91    #[serde(default)]
92    pub all_day: bool,
93    #[serde(default)]
94    pub notes: Option<String>,
95    #[serde(default)]
96    pub location: Option<String>,
97    #[serde(default)]
98    pub url: Option<String>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct EventUpdateInput {
103    pub event_id: String,
104    #[serde(default)]
105    pub title: Option<String>,
106    #[serde(default)]
107    pub start: Option<DateTime<Utc>>,
108    #[serde(default)]
109    pub end: Option<DateTime<Utc>>,
110    #[serde(default)]
111    pub all_day: Option<bool>,
112    #[serde(default)]
113    pub notes: Option<String>,
114    #[serde(default)]
115    pub location: Option<String>,
116    #[serde(default)]
117    pub url: Option<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct EventMutationResult {
122    pub ok: bool,
123    #[serde(default)]
124    pub event: Option<Event>,
125    #[serde(default)]
126    pub reason: Option<String>,
127}
128
129pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
130    backend::list_calendars()
131}
132
133/// Current calendar authorization status — a non-prompting query of the real
134/// OS permission (`EKEventStore.authorizationStatus` on macOS). Returns one of
135/// `granted` | `write_only` | `denied` | `restricted` | `not_determined` |
136/// `not_applicable` | `unknown`, so the permissions surface can report an honest
137/// gate instead of a stub (car-releases#71). Never triggers a TCC prompt.
138pub fn authorization_status() -> String {
139    backend::authorization_status()
140}
141
142/// List events between `start` and `end`. When no calendar IDs are
143/// supplied, backends include all accessible calendars.
144pub fn list_events(
145    start: DateTime<Utc>,
146    end: DateTime<Utc>,
147    calendar_ids: &[String],
148) -> Result<EventListing, IntegrationError> {
149    backend::list_events(start, end, calendar_ids)
150}
151
152/// Create a new event on the calendar identified by `input.calendar_id`.
153/// The calendar must allow content modifications. Returns the created
154/// event with its host-assigned id, or `ok: false` with a reason.
155pub fn create_event(input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
156    backend::create_event(input)
157}
158
159/// Update fields on an existing event. Any `None` field on
160/// `EventUpdateInput` leaves the event's existing value unchanged
161/// (except for `notes`/`location`/`url`, where an empty-string `Some`
162/// value clears the field — matches the semantics callers had via the
163/// previous shell-out helper).
164pub fn update_event(input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
165    backend::update_event(input)
166}
167
168/// Delete an event by its host-assigned id. Returns `ok: true` with the
169/// id echoed when the event was removed, or `ok: false` with a reason
170/// when it wasn't (event_not_found, write access denied, etc.).
171pub fn delete_event(event_id: &str) -> Result<EventMutationResult, IntegrationError> {
172    backend::delete_event(event_id)
173}
174
175#[cfg(target_os = "macos")]
176mod backend {
177    use super::*;
178
179    // The on-disk cache key is `HELPER_VERSION + source_fingerprint(SCRIPT)`
180    // (see helper_cache_key), so a SCRIPT change already auto-invalidates the
181    // cached helper — the fingerprint, added after car#64's stale-binary bug,
182    // is the load-bearing guard. This version string is the human-readable
183    // marker of intentional helper revisions; bump it on each SCRIPT edit as
184    // belt-and-suspenders. v5: enriched attendees (status/role/email/
185    // is_current_user) + event status (#68). Mirrors the contacts helper (#200).
186    // v6: non-prompting `auth-status` command (car-releases#71).
187    const HELPER_VERSION: &str = "v6";
188
189    const SCRIPT: &str = r##"
190import EventKit
191import Foundation
192import Dispatch
193
194struct Availability: Codable {
195    let available: Bool
196    let backend: String
197    let reason: String?
198}
199
200struct CalendarOut: Codable {
201    let id: String
202    let title: String
203    let source: String?
204    let color: String?
205    let writable: Bool
206}
207
208struct AttendeeOut: Codable {
209    let name: String?
210    let email: String?
211    let status: String?
212    let role: String?
213    let is_current_user: Bool
214}
215
216struct EventOut: Codable {
217    let id: String
218    let calendar_id: String
219    let title: String
220    let start: Date
221    let end: Date
222    let all_day: Bool
223    let location: String?
224    let notes: String?
225    let attendees: [AttendeeOut]
226    let status: String
227}
228
229struct CalendarListing: Codable {
230    let available: Bool
231    let backend: String
232    let reason: String?
233    let calendars: [CalendarOut]
234}
235
236struct EventListing: Codable {
237    let available: Bool
238    let backend: String
239    let reason: String?
240    let events: [EventOut]
241}
242
243struct EventCreateInput: Codable {
244    let calendar_id: String
245    let title: String
246    let start: Date
247    let end: Date
248    let all_day: Bool?
249    let notes: String?
250    let location: String?
251    let url: String?
252}
253
254struct EventUpdateInput: Codable {
255    let event_id: String
256    let title: String?
257    let start: Date?
258    let end: Date?
259    let all_day: Bool?
260    let notes: String?
261    let location: String?
262    let url: String?
263}
264
265struct EventMutationOut: Codable {
266    let ok: Bool
267    let event: EventOut?
268    let reason: String?
269}
270
271func emit<T: Encodable>(_ value: T) {
272    let encoder = JSONEncoder()
273    encoder.dateEncodingStrategy = .iso8601
274    let data = try! encoder.encode(value)
275    FileHandle.standardOutput.write(data)
276}
277
278func attendeeStatus(_ s: EKParticipantStatus) -> String {
279    switch s {
280    case .accepted: return "accepted"
281    case .declined: return "declined"
282    case .tentative: return "tentative"
283    case .pending: return "pending"
284    case .delegated: return "delegated"
285    case .completed: return "completed"
286    case .inProcess: return "in_process"
287    default: return "unknown"
288    }
289}
290
291func attendeeRole(_ r: EKParticipantRole) -> String {
292    switch r {
293    case .required: return "required"
294    case .optional: return "optional"
295    case .chair: return "chair"
296    case .nonParticipant: return "non_participant"
297    default: return "unknown"
298    }
299}
300
301func attendeeOut(_ p: EKParticipant) -> AttendeeOut {
302    // EventKit exposes the address as a `mailto:` URL; surface the bare email.
303    var email: String? = nil
304    let urlStr = p.url.absoluteString
305    if urlStr.lowercased().hasPrefix("mailto:") {
306        email = String(urlStr.dropFirst("mailto:".count))
307    }
308    return AttendeeOut(
309        name: p.name,
310        email: email,
311        status: attendeeStatus(p.participantStatus),
312        role: attendeeRole(p.participantRole),
313        is_current_user: p.isCurrentUser
314    )
315}
316
317func eventStatusString(_ s: EKEventStatus) -> String {
318    switch s {
319    case .confirmed: return "confirmed"
320    case .tentative: return "tentative"
321    case .canceled: return "canceled"
322    default: return "none"
323    }
324}
325
326func eventOut(_ event: EKEvent) -> EventOut {
327    return EventOut(
328        id: event.eventIdentifier ?? "\(event.calendarItemIdentifier)-\(event.startDate.timeIntervalSince1970)",
329        calendar_id: event.calendar.calendarIdentifier,
330        title: event.title ?? "",
331        start: event.startDate,
332        end: event.endDate,
333        all_day: event.isAllDay,
334        location: event.location,
335        notes: event.notes,
336        attendees: (event.attendees ?? []).map { attendeeOut($0) },
337        status: eventStatusString(event.status)
338    )
339}
340
341func unavailable<T: Encodable>(_ reason: String, empty: T) {
342    emit(empty)
343}
344
345let internetDateFormatter: ISO8601DateFormatter = {
346    let formatter = ISO8601DateFormatter()
347    formatter.formatOptions = [.withInternetDateTime]
348    return formatter
349}()
350
351let fractionalDateFormatter: ISO8601DateFormatter = {
352    let formatter = ISO8601DateFormatter()
353    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
354    return formatter
355}()
356
357func decodeDate(_ decoder: Decoder) throws -> Date {
358    let container = try decoder.singleValueContainer()
359    let raw = try container.decode(String.self)
360    if let date = fractionalDateFormatter.date(from: raw) ?? internetDateFormatter.date(from: raw) {
361        return date
362    }
363    throw DecodingError.dataCorruptedError(
364        in: container,
365        debugDescription: "Invalid RFC3339 date: \(raw)"
366    )
367}
368
369func ensureAccess(_ store: EKEventStore) -> String? {
370    let status = EKEventStore.authorizationStatus(for: .event)
371    switch status {
372    case .authorized:
373        return nil
374    case .notDetermined:
375        let semaphore = DispatchSemaphore(value: 0)
376        var granted = false
377        if #available(macOS 14.0, *) {
378            store.requestFullAccessToEvents { ok, _ in
379                granted = ok
380                semaphore.signal()
381            }
382        } else {
383            store.requestAccess(to: .event) { ok, _ in
384                granted = ok
385                semaphore.signal()
386            }
387        }
388        _ = semaphore.wait(timeout: .now() + 60)
389        return granted ? nil : "Calendar permission was not granted"
390    case .restricted:
391        return "Calendar permission is restricted by system policy"
392    case .denied:
393        return "Calendar permission is denied"
394    case .writeOnly:
395        return "Calendar permission is write-only; read access is required"
396    case .fullAccess:
397        return nil
398    @unknown default:
399        return "Calendar permission is unavailable"
400    }
401}
402
403func hexColor(_ cgColor: CGColor?) -> String? {
404    guard let cgColor = cgColor, let components = cgColor.components else { return nil }
405    let r: CGFloat
406    let g: CGFloat
407    let b: CGFloat
408    if components.count >= 3 {
409        r = components[0]
410        g = components[1]
411        b = components[2]
412    } else if components.count >= 1 {
413        r = components[0]
414        g = components[0]
415        b = components[0]
416    } else {
417        return nil
418    }
419    return String(format: "#%02X%02X%02X", Int(max(0, min(1, r)) * 255), Int(max(0, min(1, g)) * 255), Int(max(0, min(1, b)) * 255))
420}
421
422let args = CommandLine.arguments
423let mode = args.count > 1 ? args[1] : "calendars"
424
425// Non-prompting authorization-status query (car-releases#71). Reports the
426// CURRENT EKEventStore.authorizationStatus — never instantiates a store or
427// triggers a TCC prompt, unlike ensureAccess below — so `permissionStatus`
428// can be an honest gate. Handles the macOS 14 split (.fullAccess/.writeOnly
429// replaced .authorized).
430if mode == "auth-status" {
431    struct AuthStatusOut: Codable { let status: String }
432    let label: String
433    switch EKEventStore.authorizationStatus(for: .event) {
434    case .authorized, .fullAccess: label = "granted"
435    case .writeOnly: label = "write_only"
436    case .denied: label = "denied"
437    case .restricted: label = "restricted"
438    case .notDetermined: label = "not_determined"
439    @unknown default: label = "unknown"
440    }
441    emit(AuthStatusOut(status: label))
442    exit(0)
443}
444
445let store = EKEventStore()
446if let reason = ensureAccess(store) {
447    if mode == "events" {
448        emit(EventListing(available: false, backend: "eventkit", reason: reason, events: []))
449    } else if mode == "create" || mode == "update" || mode == "delete" {
450        emit(EventMutationOut(ok: false, event: nil, reason: reason))
451    } else {
452        emit(CalendarListing(available: false, backend: "eventkit", reason: reason, calendars: []))
453    }
454    exit(0)
455}
456
457if mode == "events" {
458    let formatter = ISO8601DateFormatter()
459    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
460    let fallback = ISO8601DateFormatter()
461    guard args.count >= 4,
462          let start = formatter.date(from: args[2]) ?? fallback.date(from: args[2]),
463          let end = formatter.date(from: args[3]) ?? fallback.date(from: args[3]) else {
464        emit(EventListing(available: false, backend: "eventkit", reason: "Invalid RFC3339 date range", events: []))
465        exit(0)
466    }
467    let requested = Set(args.dropFirst(4))
468    let calendars = store.calendars(for: .event).filter { requested.isEmpty || requested.contains($0.calendarIdentifier) }
469    let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
470    let events = store.events(matching: predicate).map { eventOut($0) }
471    emit(EventListing(available: true, backend: "eventkit", reason: nil, events: events))
472} else if mode == "create" {
473    guard args.count >= 3, let payload = args[2].data(using: .utf8) else {
474        emit(EventMutationOut(ok: false, event: nil, reason: "create requires JSON payload as args[2]"))
475        exit(0)
476    }
477    let decoder = JSONDecoder()
478    decoder.dateDecodingStrategy = .custom(decodeDate)
479    guard let input = try? decoder.decode(EventCreateInput.self, from: payload) else {
480        emit(EventMutationOut(ok: false, event: nil, reason: "invalid_create_input_json"))
481        exit(0)
482    }
483    guard let calendar = store.calendar(withIdentifier: input.calendar_id) else {
484        emit(EventMutationOut(ok: false, event: nil, reason: "calendar_not_found"))
485        exit(0)
486    }
487    guard calendar.allowsContentModifications else {
488        emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
489        exit(0)
490    }
491    let event = EKEvent(eventStore: store)
492    event.calendar = calendar
493    event.title = input.title
494    event.startDate = input.start
495    event.endDate = input.end
496    event.isAllDay = input.all_day ?? false
497    if let notes = input.notes, !notes.isEmpty { event.notes = notes }
498    if let location = input.location, !location.isEmpty { event.location = location }
499    if let urlRaw = input.url, !urlRaw.isEmpty, let parsed = URL(string: urlRaw) { event.url = parsed }
500    do {
501        try store.save(event, span: .thisEvent)
502        emit(EventMutationOut(ok: true, event: eventOut(event), reason: nil))
503    } catch {
504        emit(EventMutationOut(ok: false, event: nil, reason: "save_failed: \(error.localizedDescription)"))
505    }
506} else if mode == "update" {
507    guard args.count >= 3, let payload = args[2].data(using: .utf8) else {
508        emit(EventMutationOut(ok: false, event: nil, reason: "update requires JSON payload as args[2]"))
509        exit(0)
510    }
511    let decoder = JSONDecoder()
512    decoder.dateDecodingStrategy = .custom(decodeDate)
513    guard let input = try? decoder.decode(EventUpdateInput.self, from: payload) else {
514        emit(EventMutationOut(ok: false, event: nil, reason: "invalid_update_input_json"))
515        exit(0)
516    }
517    guard let event = store.event(withIdentifier: input.event_id) else {
518        emit(EventMutationOut(ok: false, event: nil, reason: "event_not_found"))
519        exit(0)
520    }
521    guard event.calendar.allowsContentModifications else {
522        emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
523        exit(0)
524    }
525    if let title = input.title { event.title = title }
526    if let start = input.start { event.startDate = start }
527    if let end = input.end { event.endDate = end }
528    if let allDay = input.all_day { event.isAllDay = allDay }
529    // Empty-string for notes/location/url means "clear the field". The
530    // None case (field absent in JSON) leaves the existing value alone.
531    if let notes = input.notes { event.notes = notes.isEmpty ? nil : notes }
532    if let location = input.location { event.location = location.isEmpty ? nil : location }
533    if let urlRaw = input.url {
534        event.url = urlRaw.isEmpty ? nil : URL(string: urlRaw)
535    }
536    do {
537        try store.save(event, span: .thisEvent)
538        emit(EventMutationOut(ok: true, event: eventOut(event), reason: nil))
539    } catch {
540        emit(EventMutationOut(ok: false, event: nil, reason: "save_failed: \(error.localizedDescription)"))
541    }
542} else if mode == "delete" {
543    guard args.count >= 3 else {
544        emit(EventMutationOut(ok: false, event: nil, reason: "delete requires event_id as args[2]"))
545        exit(0)
546    }
547    let eventId = args[2]
548    guard let event = store.event(withIdentifier: eventId) else {
549        emit(EventMutationOut(ok: false, event: nil, reason: "event_not_found"))
550        exit(0)
551    }
552    guard event.calendar.allowsContentModifications else {
553        emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
554        exit(0)
555    }
556    do {
557        try store.remove(event, span: .thisEvent)
558        emit(EventMutationOut(ok: true, event: nil, reason: nil))
559    } catch {
560        emit(EventMutationOut(ok: false, event: nil, reason: "delete_failed: \(error.localizedDescription)"))
561    }
562} else {
563    let calendars = store.calendars(for: .event).map { cal in
564        CalendarOut(
565            id: cal.calendarIdentifier,
566            title: cal.title,
567            source: cal.source?.title,
568            color: hexColor(cal.cgColor),
569            writable: cal.allowsContentModifications
570        )
571    }
572    emit(CalendarListing(available: true, backend: "eventkit", reason: nil, calendars: calendars))
573}
574"##;
575
576    pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
577        run_swift(&["calendars"])
578    }
579
580    #[derive(serde::Deserialize)]
581    struct AuthStatus {
582        status: String,
583    }
584
585    pub fn authorization_status() -> String {
586        // Non-prompting; on any helper failure report "unknown" rather than
587        // fabricating a grant/denial. Log the discarded error so a cold-start
588        // compile failure (swiftc missing, signing/entitlement breakage) is
589        // observable instead of an invisible "unknown".
590        match run_swift::<AuthStatus>(&["auth-status"]) {
591            Ok(a) => a.status,
592            Err(e) => {
593                tracing::debug!(error = %e, "calendar auth-status helper failed; reporting unknown");
594                "unknown".to_string()
595            }
596        }
597    }
598
599    pub fn list_events(
600        start: DateTime<Utc>,
601        end: DateTime<Utc>,
602        calendar_ids: &[String],
603    ) -> Result<EventListing, IntegrationError> {
604        let start = start.to_rfc3339();
605        let end = end.to_rfc3339();
606        let mut args = vec!["events", start.as_str(), end.as_str()];
607        args.extend(calendar_ids.iter().map(String::as_str));
608        run_swift(&args)
609    }
610
611    pub fn create_event(input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
612        let payload = serde_json::to_string(&input)
613            .map_err(|e| IntegrationError::Backend(format!("encode create input: {e}")))?;
614        run_swift(&["create", &payload])
615    }
616
617    pub fn update_event(input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
618        let payload = serde_json::to_string(&input)
619            .map_err(|e| IntegrationError::Backend(format!("encode update input: {e}")))?;
620        run_swift(&["update", &payload])
621    }
622
623    pub fn delete_event(event_id: &str) -> Result<EventMutationResult, IntegrationError> {
624        run_swift(&["delete", event_id])
625    }
626
627    fn run_swift<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
628        let helper = ensure_helper()?;
629        let output = Command::new(helper)
630            .env(
631                "SWIFT_MODULE_CACHE_PATH",
632                std::env::temp_dir().join("car-swift-module-cache"),
633            )
634            .env(
635                "CLANG_MODULE_CACHE_PATH",
636                std::env::temp_dir().join("car-clang-module-cache"),
637            )
638            .args(args)
639            .output()
640            .map_err(|e| IntegrationError::Backend(format!("swift: {e}")))?;
641
642        if !output.status.success() {
643            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
644            return Err(IntegrationError::Backend(format!(
645                "eventkit swift failed: {stderr}"
646            )));
647        }
648
649        serde_json::from_slice(&output.stdout)
650            .map_err(|e| IntegrationError::Backend(format!("eventkit json: {e}")))
651    }
652
653    /// Cache key for the compiled helper. Combines the manual `HELPER_VERSION`
654    /// with a content hash of the embedded Swift `SCRIPT`, so ANY change to the
655    /// source auto-invalidates the cache. A manual bump alone was the car#64
656    /// regression: the Swift gained `create`/`update`/`delete` modes but the
657    /// cache key stayed `v4`, so upgraders kept reusing a stale pre-mutation
658    /// `v4.app` (every mutation fell through to the calendar-listing `else`,
659    /// failing Rust deserialization with `missing field \`ok\``). DefaultHasher
660    /// is deterministic for the same bytes; a hash change across a toolchain
661    /// bump just triggers one harmless recompile.
662    fn source_fingerprint(s: &str) -> String {
663        use std::hash::{Hash, Hasher};
664        let mut h = std::collections::hash_map::DefaultHasher::new();
665        s.hash(&mut h);
666        format!("{:016x}", h.finish())
667    }
668
669    fn helper_cache_key() -> String {
670        format!("{HELPER_VERSION}-{}", source_fingerprint(SCRIPT))
671    }
672
673    fn ensure_helper() -> Result<std::path::PathBuf, IntegrationError> {
674        let dir = helper_cache_dir();
675        let key = helper_cache_key();
676        let app = dir.join(format!("CAR EventKit Helper {key}.app"));
677        let contents = app.join("Contents");
678        let macos = contents.join("MacOS");
679        let helper = macos.join("CAR EventKit Helper");
680        if helper.exists() {
681            return Ok(helper);
682        }
683
684        std::fs::create_dir_all(&macos)
685            .map_err(|e| IntegrationError::Backend(format!("helper cache: {e}")))?;
686        let source = dir.join(format!("car-eventkit-helper-{key}.swift"));
687        let plist = contents.join("Info.plist");
688        let entitlements = dir.join(format!("car-eventkit-helper-{key}.entitlements"));
689        std::fs::write(&source, SCRIPT)
690            .map_err(|e| IntegrationError::Backend(format!("eventkit helper source: {e}")))?;
691        std::fs::write(
692            &plist,
693            r#"<?xml version="1.0" encoding="UTF-8"?>
694<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
695<plist version="1.0">
696<dict>
697  <key>CFBundleIdentifier</key>
698  <string>ai.parslee.car.eventkit-helper</string>
699  <key>CFBundleExecutable</key>
700  <string>CAR EventKit Helper</string>
701  <key>CFBundleName</key>
702  <string>CAR EventKit Helper</string>
703  <key>CFBundlePackageType</key>
704  <string>APPL</string>
705  <key>CFBundleShortVersionString</key>
706  <string>0.9.0</string>
707  <key>CFBundleVersion</key>
708  <string>1</string>
709  <key>NSCalendarsUsageDescription</key>
710  <string>CAR reads calendars when an agent uses the calendar capability.</string>
711  <key>NSCalendarsFullAccessUsageDescription</key>
712  <string>CAR reads calendars when an agent uses the calendar capability.</string>
713</dict>
714</plist>
715"#,
716        )
717        .map_err(|e| IntegrationError::Backend(format!("eventkit helper plist: {e}")))?;
718        std::fs::write(
719            &entitlements,
720            r#"<?xml version="1.0" encoding="UTF-8"?>
721<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
722<plist version="1.0">
723<dict>
724  <key>com.apple.security.personal-information.calendars</key>
725  <true/>
726</dict>
727</plist>
728"#,
729        )
730        .map_err(|e| IntegrationError::Backend(format!("eventkit helper entitlements: {e}")))?;
731
732        let status = Command::new("/usr/bin/swiftc")
733            .env(
734                "SWIFT_MODULE_CACHE_PATH",
735                std::env::temp_dir().join("car-swift-module-cache"),
736            )
737            .env(
738                "CLANG_MODULE_CACHE_PATH",
739                std::env::temp_dir().join("car-clang-module-cache"),
740            )
741            .arg(&source)
742            .arg("-o")
743            .arg(&helper)
744            .arg("-Xlinker")
745            .arg("-sectcreate")
746            .arg("-Xlinker")
747            .arg("__TEXT")
748            .arg("-Xlinker")
749            .arg("__info_plist")
750            .arg("-Xlinker")
751            .arg(&plist)
752            .status()
753            .map_err(|e| IntegrationError::Backend(format!("swiftc: {e}")))?;
754
755        if !status.success() {
756            return Err(IntegrationError::Backend(format!(
757                "eventkit helper compile failed with status {status}"
758            )));
759        }
760
761        sign_helper(&app, &entitlements)?;
762        Ok(helper)
763    }
764
765    fn helper_cache_dir() -> std::path::PathBuf {
766        if let Some(path) = std::env::var_os("CAR_NATIVE_HELPER_DIR") {
767            return std::path::PathBuf::from(path);
768        }
769        if let Some(home) = std::env::var_os("HOME") {
770            return std::path::PathBuf::from(home)
771                .join("Library")
772                .join("Application Support")
773                .join("CAR")
774                .join("NativeHelpers");
775        }
776        std::env::temp_dir().join("car-native-helpers")
777    }
778
779    fn sign_helper(
780        app: &std::path::Path,
781        entitlements: &std::path::Path,
782    ) -> Result<(), IntegrationError> {
783        let status = Command::new("/usr/bin/codesign")
784            .arg("--force")
785            .arg("--sign")
786            .arg("-")
787            .arg("--entitlements")
788            .arg(entitlements)
789            .arg(app)
790            .status()
791            .map_err(|e| IntegrationError::Backend(format!("codesign: {e}")))?;
792        if !status.success() {
793            return Err(IntegrationError::Backend(format!(
794                "eventkit helper codesign failed with status {status}"
795            )));
796        }
797        Ok(())
798    }
799
800    #[cfg(test)]
801    mod helper_cache_tests {
802        use super::{helper_cache_key, source_fingerprint, HELPER_VERSION};
803
804        #[test]
805        fn fingerprint_is_deterministic_and_input_sensitive() {
806            // Same source → same key (cache hit); any change → new key (forces
807            // a recompile). This is what prevents the car#64 stale-helper class.
808            assert_eq!(source_fingerprint("abc"), source_fingerprint("abc"));
809            assert_ne!(
810                source_fingerprint("mode == create"),
811                source_fingerprint("mode == delete")
812            );
813            assert_eq!(source_fingerprint("abc").len(), 16);
814        }
815
816        #[test]
817        fn cache_key_carries_version_prefix_and_source_hash() {
818            let key = helper_cache_key();
819            assert!(key.starts_with(&format!("{HELPER_VERSION}-")), "key: {key}");
820            // version prefix + '-' + 16 hex chars
821            assert_eq!(key.len(), HELPER_VERSION.len() + 1 + 16);
822        }
823    }
824}
825
826#[cfg(not(target_os = "macos"))]
827mod backend {
828    use super::*;
829
830    pub fn authorization_status() -> String {
831        // No OS calendar permission model off macOS — there's no TCC gate.
832        "not_applicable".to_string()
833    }
834
835    pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
836        // Native Windows read (WinRT AppointmentManager) is the local answer —
837        // but only when it actually has one. A reachable local store with no
838        // registered calendars must not hide a configured Graph calendar
839        // (car#683). See `crate::local_first`.
840        #[cfg(target_os = "windows")]
841        if let Some(listing) =
842            crate::local_first::LocalRead::classify(win_read::list_calendars(), |listing| {
843                !listing.calendars.is_empty()
844            })
845            .final_answer(crate::msgraph::is_configured())
846        {
847            return Ok(listing);
848        }
849        if crate::msgraph::is_configured() {
850            // Graph's default calendar; writable via the read-write scope (car#531).
851            return Ok(CalendarListing {
852                availability: Availability::available("msgraph"),
853                calendars: vec![Calendar {
854                    id: "graph".into(),
855                    title: "Calendar".into(),
856                    source: Some("microsoft".into()),
857                    color: None,
858                    writable: true,
859                }],
860            });
861        }
862        Ok(CalendarListing {
863            availability: current_backend_pending(),
864            calendars: vec![],
865        })
866    }
867
868    pub fn list_events(
869        start: DateTime<Utc>,
870        end: DateTime<Utc>,
871        _calendar_ids: &[String],
872    ) -> Result<EventListing, IntegrationError> {
873        // Native Windows read (WinRT AppointmentManager) is the local answer —
874        // but only when it actually has one. A work calendar registered in the
875        // user's M365 tenant but not in the local AppointmentManager store
876        // returns zero events here, and short-circuiting on that showed an
877        // empty calendar reported as `available` while the user's real events
878        // sat in the configured Graph backend, unqueried (car#683). An empty
879        // range therefore falls through. That costs one extra Graph call on
880        // genuinely empty ranges — the right trade for not silently hiding a
881        // configured account. See `crate::local_first`.
882        #[cfg(target_os = "windows")]
883        if let Some(listing) = crate::local_first::LocalRead::classify(
884            win_read::list_events(start, end, _calendar_ids),
885            |listing| !listing.events.is_empty(),
886        )
887        .final_answer(crate::msgraph::is_configured())
888        {
889            return Ok(listing);
890        }
891        // Microsoft Graph backend (car#520) when configured; else pending.
892        if crate::msgraph::is_configured() {
893            return Ok(match crate::msgraph::events(start, end) {
894                Ok(events) => EventListing {
895                    availability: Availability::available("msgraph"),
896                    events,
897                },
898                Err(e) => EventListing {
899                    availability: Availability::pending("msgraph", e.to_string()),
900                    events: vec![],
901                },
902            });
903        }
904        Ok(EventListing {
905            availability: current_backend_pending(),
906            events: vec![],
907        })
908    }
909
910    pub fn create_event(input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
911        // Microsoft Graph write (car#531) when configured; else unimplemented.
912        if crate::msgraph::is_configured() {
913            return Ok(match crate::msgraph::create_event(&input) {
914                Ok(event) => EventMutationResult {
915                    ok: true,
916                    event: Some(event),
917                    reason: None,
918                },
919                Err(e) => EventMutationResult {
920                    ok: false,
921                    event: None,
922                    reason: Some(e.to_string()),
923                },
924            });
925        }
926        Ok(EventMutationResult {
927            ok: false,
928            event: None,
929            reason: Some(
930                "calendar event creation needs the Microsoft Graph backend \
931                 (set CAR_MSGRAPH_CLIENT_ID); no local backend on this platform"
932                    .into(),
933            ),
934        })
935    }
936
937    pub fn update_event(input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
938        if crate::msgraph::is_configured() {
939            return Ok(match crate::msgraph::update_event(&input) {
940                Ok(event) => EventMutationResult {
941                    ok: true,
942                    event: Some(event),
943                    reason: None,
944                },
945                Err(e) => EventMutationResult {
946                    ok: false,
947                    event: None,
948                    reason: Some(e.to_string()),
949                },
950            });
951        }
952        Ok(EventMutationResult {
953            ok: false,
954            event: None,
955            reason: Some(
956                "calendar event updates need the Microsoft Graph backend \
957                 (set CAR_MSGRAPH_CLIENT_ID); no local backend on this platform"
958                    .into(),
959            ),
960        })
961    }
962
963    pub fn delete_event(event_id: &str) -> Result<EventMutationResult, IntegrationError> {
964        if crate::msgraph::is_configured() {
965            return Ok(match crate::msgraph::delete_event(event_id) {
966                Ok(()) => EventMutationResult {
967                    ok: true,
968                    event: None,
969                    reason: None,
970                },
971                Err(e) => EventMutationResult {
972                    ok: false,
973                    event: None,
974                    reason: Some(e.to_string()),
975                },
976            });
977        }
978        Ok(EventMutationResult {
979            ok: false,
980            event: None,
981            reason: Some(
982                "calendar event deletion needs the Microsoft Graph backend \
983                 (set CAR_MSGRAPH_CLIENT_ID); no local backend on this platform"
984                    .into(),
985            ),
986        })
987    }
988
989    fn current_backend_pending() -> Availability {
990        #[cfg(target_os = "windows")]
991        {
992            Availability::pending(
993                "msgraph",
994                "Set CAR_MSGRAPH_CLIENT_ID (Azure AD app) to enable the Microsoft \
995             Graph calendar backend (car#520/#531 — read + event \
996             create/update/delete).",
997            )
998        }
999        #[cfg(target_os = "linux")]
1000        {
1001            Availability::pending(
1002                "eds",
1003                "Evolution Data Server + CalDAV backends not yet wired. \
1004             API shape is stable; downstream apps can code against it now.",
1005            )
1006        }
1007        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
1008        {
1009            Availability::pending("none", "Unsupported OS — no calendar backend modeled.")
1010        }
1011    }
1012
1013    /// Native Windows Calendar READ via WinRT `AppointmentManager` — works from
1014    /// an unpackaged Win32 process (verified: `AllCalendarsReadOnly` returns the
1015    /// real aggregate store with no package identity). Read-only: unpackaged
1016    /// event writes are unreliable, so create/update/delete stay on Graph.
1017    #[cfg(target_os = "windows")]
1018    mod win_read {
1019        use super::*;
1020        use windows::ApplicationModel::Appointments::{
1021            AppointmentManager, AppointmentStore, AppointmentStoreAccessType,
1022        };
1023        use windows::Foundation::{DateTime as WinDateTime, TimeSpan};
1024
1025        // 100ns ticks between 1601-01-01 (WinRT epoch) and 1970-01-01 (Unix epoch).
1026        const TICKS_1601_TO_1970: i64 = 116_444_736_000_000_000;
1027
1028        fn open_store() -> Result<AppointmentStore, IntegrationError> {
1029            AppointmentManager::RequestStoreAsync(AppointmentStoreAccessType::AllCalendarsReadOnly)
1030                .and_then(|o| o.get())
1031                .map_err(|e| IntegrationError::Backend(format!("windows appointment store: {e}")))
1032        }
1033
1034        fn to_chrono(dt: WinDateTime) -> Option<DateTime<Utc>> {
1035            let unix_ticks = dt.UniversalTime - TICKS_1601_TO_1970;
1036            let secs = unix_ticks.div_euclid(10_000_000);
1037            let nanos = (unix_ticks.rem_euclid(10_000_000) * 100) as u32;
1038            DateTime::from_timestamp(secs, nanos)
1039        }
1040
1041        fn to_winrt(dt: DateTime<Utc>) -> WinDateTime {
1042            let ticks = dt.timestamp() * 10_000_000
1043                + (dt.timestamp_subsec_nanos() as i64 / 100)
1044                + TICKS_1601_TO_1970;
1045            WinDateTime {
1046                UniversalTime: ticks,
1047            }
1048        }
1049
1050        fn hstr(v: windows::core::Result<windows::core::HSTRING>) -> windows::core::Result<String> {
1051            v.map(|s| s.to_string_lossy())
1052        }
1053
1054        pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
1055            let store = open_store()?;
1056            let cals = store
1057                .FindAppointmentCalendarsAsync()
1058                .and_then(|o| o.get())
1059                .map_err(|e| IntegrationError::Backend(format!("appointment calendars: {e}")))?;
1060            let mut calendars = Vec::new();
1061            for cal in cals {
1062                calendars.push(Calendar {
1063                    id: hstr(cal.LocalId()).unwrap_or_default(),
1064                    title: hstr(cal.DisplayName()).unwrap_or_default(),
1065                    source: Some("windows".into()),
1066                    color: None,
1067                    writable: cal.CanCreateOrUpdateAppointments().unwrap_or(false),
1068                });
1069            }
1070            Ok(CalendarListing {
1071                availability: Availability::available("windows_calendar"),
1072                calendars,
1073            })
1074        }
1075
1076        pub fn list_events(
1077            start: DateTime<Utc>,
1078            end: DateTime<Utc>,
1079            calendar_ids: &[String],
1080        ) -> Result<EventListing, IntegrationError> {
1081            let store = open_store()?;
1082            let span = TimeSpan {
1083                Duration: ((end - start).num_nanoseconds().unwrap_or(0) / 100).max(0),
1084            };
1085            let appts = store
1086                .FindAppointmentsAsync(to_winrt(start), span)
1087                .and_then(|o| o.get())
1088                .map_err(|e| IntegrationError::Backend(format!("find appointments: {e}")))?;
1089            let want: std::collections::HashSet<&str> =
1090                calendar_ids.iter().map(String::as_str).collect();
1091            let mut events = Vec::new();
1092            for a in appts {
1093                let calendar_id = hstr(a.CalendarId()).unwrap_or_default();
1094                if !want.is_empty() && !want.contains(calendar_id.as_str()) {
1095                    continue;
1096                }
1097                let ev_start = a.StartTime().ok().and_then(to_chrono).unwrap_or(start);
1098                let dur_ticks = a.Duration().map(|t| t.Duration).unwrap_or(0);
1099                let ev_end = ev_start + chrono::Duration::nanoseconds(dur_ticks * 100);
1100                events.push(Event {
1101                    id: hstr(a.LocalId()).unwrap_or_default(),
1102                    calendar_id,
1103                    title: hstr(a.Subject()).unwrap_or_default(),
1104                    start: ev_start,
1105                    end: ev_end,
1106                    all_day: a.AllDay().unwrap_or(false),
1107                    location: hstr(a.Location()).ok().filter(|s| !s.is_empty()),
1108                    notes: hstr(a.Details()).ok().filter(|s| !s.is_empty()),
1109                    // Invitees are available on the appointment but not surfaced in
1110                    // the read-list v1 (writes/RSVP stay on Graph).
1111                    attendees: Vec::new(),
1112                    status: None,
1113                });
1114            }
1115            Ok(EventListing {
1116                availability: Availability::available("windows_calendar"),
1117                events,
1118            })
1119        }
1120    }
1121}
1122
1123#[cfg(test)]
1124mod event_shape_tests {
1125    use super::*;
1126
1127    #[test]
1128    fn enriched_attendees_and_status_deserialize() {
1129        // Exactly the JSON shape the macOS EventKit helper (SCRIPT) now emits for
1130        // one event. Deserializing it into `EventListing` is the contract test
1131        // between the Swift `EventOut`/`AttendeeOut` and the Rust types (#68) —
1132        // a non-tentative/tentative distinction must survive the boundary.
1133        let json = r#"{
1134            "available": true,
1135            "backend": "eventkit",
1136            "events": [{
1137                "id": "evt-1",
1138                "calendar_id": "cal-1",
1139                "title": "Design sync",
1140                "start": "2026-06-21T15:00:00Z",
1141                "end": "2026-06-21T16:00:00Z",
1142                "all_day": false,
1143                "location": null,
1144                "notes": null,
1145                "status": "confirmed",
1146                "attendees": [
1147                    {"name": "Matt Liotta", "email": "matt@parslee.ai", "status": "accepted", "role": "chair", "is_current_user": true},
1148                    {"name": "Dan Capri", "email": "dan@example.com", "status": "tentative", "role": "required", "is_current_user": false}
1149                ]
1150            }]
1151        }"#;
1152        let listing: EventListing = serde_json::from_str(json).expect("deserialize");
1153        let e = &listing.events[0];
1154        assert_eq!(e.status.as_deref(), Some("confirmed"));
1155        assert_eq!(e.attendees.len(), 2);
1156        // The current user firmly accepted...
1157        assert_eq!(e.attendees[0].status.as_deref(), Some("accepted"));
1158        assert!(e.attendees[0].is_current_user);
1159        assert_eq!(e.attendees[0].email.as_deref(), Some("matt@parslee.ai"));
1160        assert_eq!(e.attendees[0].role.as_deref(), Some("chair"));
1161        // ...while another attendee is only tentative — the distinction #68 needs.
1162        assert_eq!(e.attendees[1].status.as_deref(), Some("tentative"));
1163        assert!(!e.attendees[1].is_current_user);
1164    }
1165
1166    #[test]
1167    fn authorization_status_returns_a_known_label() {
1168        // Real, non-prompting query (the macOS EventKit helper or the off-macOS
1169        // stub). Must be a known label, never empty/garbage — this is the gate
1170        // permissionStatus("calendar") reports (car-releases#71).
1171        let s = authorization_status();
1172        assert!(
1173            matches!(
1174                s.as_str(),
1175                "granted"
1176                    | "write_only"
1177                    | "denied"
1178                    | "restricted"
1179                    | "not_determined"
1180                    | "not_applicable"
1181                    | "unknown"
1182            ),
1183            "unexpected status label: {s}"
1184        );
1185        eprintln!("calendar authorization_status() = {s}");
1186    }
1187
1188    #[test]
1189    fn legacy_event_without_enrichment_still_deserializes() {
1190        // Back-compat: an event JSON missing the new fields (older cached helper,
1191        // or a non-macOS backend) defaults to empty attendees + no status.
1192        let json = r#"{"available":true,"backend":"none","events":[{
1193            "id":"e","calendar_id":"c","title":"t",
1194            "start":"2026-06-21T15:00:00Z","end":"2026-06-21T16:00:00Z"
1195        }]}"#;
1196        let listing: EventListing = serde_json::from_str(json).expect("deserialize");
1197        assert!(listing.events[0].attendees.is_empty());
1198        assert_eq!(listing.events[0].status, None);
1199    }
1200}