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    #[cfg(test)]
674    mod helper_cache_tests {
675        use super::{helper_cache_key, source_fingerprint, HELPER_VERSION};
676
677        #[test]
678        fn fingerprint_is_deterministic_and_input_sensitive() {
679            // Same source → same key (cache hit); any change → new key (forces
680            // a recompile). This is what prevents the car#64 stale-helper class.
681            assert_eq!(source_fingerprint("abc"), source_fingerprint("abc"));
682            assert_ne!(
683                source_fingerprint("mode == create"),
684                source_fingerprint("mode == delete")
685            );
686            assert_eq!(source_fingerprint("abc").len(), 16);
687        }
688
689        #[test]
690        fn cache_key_carries_version_prefix_and_source_hash() {
691            let key = helper_cache_key();
692            assert!(key.starts_with(&format!("{HELPER_VERSION}-")), "key: {key}");
693            // version prefix + '-' + 16 hex chars
694            assert_eq!(key.len(), HELPER_VERSION.len() + 1 + 16);
695        }
696    }
697
698    fn ensure_helper() -> Result<std::path::PathBuf, IntegrationError> {
699        let dir = helper_cache_dir();
700        let key = helper_cache_key();
701        let app = dir.join(format!("CAR EventKit Helper {key}.app"));
702        let contents = app.join("Contents");
703        let macos = contents.join("MacOS");
704        let helper = macos.join("CAR EventKit Helper");
705        if helper.exists() {
706            return Ok(helper);
707        }
708
709        std::fs::create_dir_all(&macos)
710            .map_err(|e| IntegrationError::Backend(format!("helper cache: {e}")))?;
711        let source = dir.join(format!("car-eventkit-helper-{key}.swift"));
712        let plist = contents.join("Info.plist");
713        let entitlements = dir.join(format!("car-eventkit-helper-{key}.entitlements"));
714        std::fs::write(&source, SCRIPT)
715            .map_err(|e| IntegrationError::Backend(format!("eventkit helper source: {e}")))?;
716        std::fs::write(
717            &plist,
718            r#"<?xml version="1.0" encoding="UTF-8"?>
719<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
720<plist version="1.0">
721<dict>
722  <key>CFBundleIdentifier</key>
723  <string>ai.parslee.car.eventkit-helper</string>
724  <key>CFBundleExecutable</key>
725  <string>CAR EventKit Helper</string>
726  <key>CFBundleName</key>
727  <string>CAR EventKit Helper</string>
728  <key>CFBundlePackageType</key>
729  <string>APPL</string>
730  <key>CFBundleShortVersionString</key>
731  <string>0.9.0</string>
732  <key>CFBundleVersion</key>
733  <string>1</string>
734  <key>NSCalendarsUsageDescription</key>
735  <string>CAR reads calendars when an agent uses the calendar capability.</string>
736  <key>NSCalendarsFullAccessUsageDescription</key>
737  <string>CAR reads calendars when an agent uses the calendar capability.</string>
738</dict>
739</plist>
740"#,
741        )
742        .map_err(|e| IntegrationError::Backend(format!("eventkit helper plist: {e}")))?;
743        std::fs::write(
744            &entitlements,
745            r#"<?xml version="1.0" encoding="UTF-8"?>
746<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
747<plist version="1.0">
748<dict>
749  <key>com.apple.security.personal-information.calendars</key>
750  <true/>
751</dict>
752</plist>
753"#,
754        )
755        .map_err(|e| IntegrationError::Backend(format!("eventkit helper entitlements: {e}")))?;
756
757        let status = Command::new("/usr/bin/swiftc")
758            .env(
759                "SWIFT_MODULE_CACHE_PATH",
760                std::env::temp_dir().join("car-swift-module-cache"),
761            )
762            .env(
763                "CLANG_MODULE_CACHE_PATH",
764                std::env::temp_dir().join("car-clang-module-cache"),
765            )
766            .arg(&source)
767            .arg("-o")
768            .arg(&helper)
769            .arg("-Xlinker")
770            .arg("-sectcreate")
771            .arg("-Xlinker")
772            .arg("__TEXT")
773            .arg("-Xlinker")
774            .arg("__info_plist")
775            .arg("-Xlinker")
776            .arg(&plist)
777            .status()
778            .map_err(|e| IntegrationError::Backend(format!("swiftc: {e}")))?;
779
780        if !status.success() {
781            return Err(IntegrationError::Backend(format!(
782                "eventkit helper compile failed with status {status}"
783            )));
784        }
785
786        sign_helper(&app, &entitlements)?;
787        Ok(helper)
788    }
789
790    fn helper_cache_dir() -> std::path::PathBuf {
791        if let Some(path) = std::env::var_os("CAR_NATIVE_HELPER_DIR") {
792            return std::path::PathBuf::from(path);
793        }
794        if let Some(home) = std::env::var_os("HOME") {
795            return std::path::PathBuf::from(home)
796                .join("Library")
797                .join("Application Support")
798                .join("CAR")
799                .join("NativeHelpers");
800        }
801        std::env::temp_dir().join("car-native-helpers")
802    }
803
804    fn sign_helper(
805        app: &std::path::Path,
806        entitlements: &std::path::Path,
807    ) -> Result<(), IntegrationError> {
808        let status = Command::new("/usr/bin/codesign")
809            .arg("--force")
810            .arg("--sign")
811            .arg("-")
812            .arg("--entitlements")
813            .arg(entitlements)
814            .arg(app)
815            .status()
816            .map_err(|e| IntegrationError::Backend(format!("codesign: {e}")))?;
817        if !status.success() {
818            return Err(IntegrationError::Backend(format!(
819                "eventkit helper codesign failed with status {status}"
820            )));
821        }
822        Ok(())
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        Ok(CalendarListing {
837            availability: current_backend_pending(),
838            calendars: vec![],
839        })
840    }
841
842    pub fn list_events(
843        _start: DateTime<Utc>,
844        _end: DateTime<Utc>,
845        _calendar_ids: &[String],
846    ) -> Result<EventListing, IntegrationError> {
847        Ok(EventListing {
848            availability: current_backend_pending(),
849            events: vec![],
850        })
851    }
852
853    pub fn create_event(_input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
854        Ok(EventMutationResult {
855            ok: false,
856            event: None,
857            reason: Some("calendar event creation is not yet implemented on this platform".into()),
858        })
859    }
860
861    pub fn update_event(_input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
862        Ok(EventMutationResult {
863            ok: false,
864            event: None,
865            reason: Some("calendar event updates are not yet implemented on this platform".into()),
866        })
867    }
868
869    pub fn delete_event(_event_id: &str) -> Result<EventMutationResult, IntegrationError> {
870        Ok(EventMutationResult {
871            ok: false,
872            event: None,
873            reason: Some("calendar event deletion is not yet implemented on this platform".into()),
874        })
875    }
876
877    fn current_backend_pending() -> Availability {
878        #[cfg(target_os = "windows")]
879        {
880            Availability::pending(
881                "msgraph",
882                "MS Graph / Outlook MAPI backends not yet wired. API shape \
883             is stable; downstream apps can code against it now.",
884            )
885        }
886        #[cfg(target_os = "linux")]
887        {
888            Availability::pending(
889                "eds",
890                "Evolution Data Server + CalDAV backends not yet wired. \
891             API shape is stable; downstream apps can code against it now.",
892            )
893        }
894        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
895        {
896            Availability::pending("none", "Unsupported OS — no calendar backend modeled.")
897        }
898    }
899}
900
901#[cfg(test)]
902mod event_shape_tests {
903    use super::*;
904
905    #[test]
906    fn enriched_attendees_and_status_deserialize() {
907        // Exactly the JSON shape the macOS EventKit helper (SCRIPT) now emits for
908        // one event. Deserializing it into `EventListing` is the contract test
909        // between the Swift `EventOut`/`AttendeeOut` and the Rust types (#68) —
910        // a non-tentative/tentative distinction must survive the boundary.
911        let json = r#"{
912            "available": true,
913            "backend": "eventkit",
914            "events": [{
915                "id": "evt-1",
916                "calendar_id": "cal-1",
917                "title": "Design sync",
918                "start": "2026-06-21T15:00:00Z",
919                "end": "2026-06-21T16:00:00Z",
920                "all_day": false,
921                "location": null,
922                "notes": null,
923                "status": "confirmed",
924                "attendees": [
925                    {"name": "Matt Liotta", "email": "matt@parslee.ai", "status": "accepted", "role": "chair", "is_current_user": true},
926                    {"name": "Dan Capri", "email": "dan@example.com", "status": "tentative", "role": "required", "is_current_user": false}
927                ]
928            }]
929        }"#;
930        let listing: EventListing = serde_json::from_str(json).expect("deserialize");
931        let e = &listing.events[0];
932        assert_eq!(e.status.as_deref(), Some("confirmed"));
933        assert_eq!(e.attendees.len(), 2);
934        // The current user firmly accepted...
935        assert_eq!(e.attendees[0].status.as_deref(), Some("accepted"));
936        assert!(e.attendees[0].is_current_user);
937        assert_eq!(e.attendees[0].email.as_deref(), Some("matt@parslee.ai"));
938        assert_eq!(e.attendees[0].role.as_deref(), Some("chair"));
939        // ...while another attendee is only tentative — the distinction #68 needs.
940        assert_eq!(e.attendees[1].status.as_deref(), Some("tentative"));
941        assert!(!e.attendees[1].is_current_user);
942    }
943
944    #[test]
945    fn authorization_status_returns_a_known_label() {
946        // Real, non-prompting query (the macOS EventKit helper or the off-macOS
947        // stub). Must be a known label, never empty/garbage — this is the gate
948        // permissionStatus("calendar") reports (car-releases#71).
949        let s = authorization_status();
950        assert!(
951            matches!(
952                s.as_str(),
953                "granted" | "write_only" | "denied" | "restricted"
954                    | "not_determined" | "not_applicable" | "unknown"
955            ),
956            "unexpected status label: {s}"
957        );
958        eprintln!("calendar authorization_status() = {s}");
959    }
960
961    #[test]
962    fn legacy_event_without_enrichment_still_deserializes() {
963        // Back-compat: an event JSON missing the new fields (older cached helper,
964        // or a non-macOS backend) defaults to empty attendees + no status.
965        let json = r#"{"available":true,"backend":"none","events":[{
966            "id":"e","calendar_id":"c","title":"t",
967            "start":"2026-06-21T15:00:00Z","end":"2026-06-21T16:00:00Z"
968        }]}"#;
969        let listing: EventListing = serde_json::from_str(json).expect("deserialize");
970        assert!(listing.events[0].attendees.is_empty());
971        assert_eq!(listing.events[0].status, None);
972    }
973}