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    #[serde(default)]
36    pub attendees: Vec<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CalendarListing {
41    #[serde(flatten)]
42    pub availability: Availability,
43    pub calendars: Vec<Calendar>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct EventListing {
48    #[serde(flatten)]
49    pub availability: Availability,
50    pub events: Vec<Event>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct EventCreateInput {
55    pub calendar_id: String,
56    pub title: String,
57    pub start: DateTime<Utc>,
58    pub end: DateTime<Utc>,
59    #[serde(default)]
60    pub all_day: bool,
61    #[serde(default)]
62    pub notes: Option<String>,
63    #[serde(default)]
64    pub location: Option<String>,
65    #[serde(default)]
66    pub url: Option<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct EventUpdateInput {
71    pub event_id: String,
72    #[serde(default)]
73    pub title: Option<String>,
74    #[serde(default)]
75    pub start: Option<DateTime<Utc>>,
76    #[serde(default)]
77    pub end: Option<DateTime<Utc>>,
78    #[serde(default)]
79    pub all_day: Option<bool>,
80    #[serde(default)]
81    pub notes: Option<String>,
82    #[serde(default)]
83    pub location: Option<String>,
84    #[serde(default)]
85    pub url: Option<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct EventMutationResult {
90    pub ok: bool,
91    #[serde(default)]
92    pub event: Option<Event>,
93    #[serde(default)]
94    pub reason: Option<String>,
95}
96
97pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
98    backend::list_calendars()
99}
100
101/// List events between `start` and `end`. When no calendar IDs are
102/// supplied, backends include all accessible calendars.
103pub fn list_events(
104    start: DateTime<Utc>,
105    end: DateTime<Utc>,
106    calendar_ids: &[String],
107) -> Result<EventListing, IntegrationError> {
108    backend::list_events(start, end, calendar_ids)
109}
110
111/// Create a new event on the calendar identified by `input.calendar_id`.
112/// The calendar must allow content modifications. Returns the created
113/// event with its host-assigned id, or `ok: false` with a reason.
114pub fn create_event(input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
115    backend::create_event(input)
116}
117
118/// Update fields on an existing event. Any `None` field on
119/// `EventUpdateInput` leaves the event's existing value unchanged
120/// (except for `notes`/`location`/`url`, where an empty-string `Some`
121/// value clears the field — matches the semantics callers had via the
122/// previous shell-out helper).
123pub fn update_event(input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
124    backend::update_event(input)
125}
126
127/// Delete an event by its host-assigned id. Returns `ok: true` with the
128/// id echoed when the event was removed, or `ok: false` with a reason
129/// when it wasn't (event_not_found, write access denied, etc.).
130pub fn delete_event(event_id: &str) -> Result<EventMutationResult, IntegrationError> {
131    backend::delete_event(event_id)
132}
133
134#[cfg(target_os = "macos")]
135mod backend {
136    use super::*;
137
138    // Bump on any SCRIPT edit. ensure_helper()'s on-disk cache is keyed on
139    // this string so stale binaries are superseded automatically — without
140    // it, the helper compiled on first use is reused forever even after
141    // bug fixes ship in the Rust source. Mirrors the contacts helper's
142    // convention (#200).
143    const HELPER_VERSION: &str = "v4";
144
145    const SCRIPT: &str = r##"
146import EventKit
147import Foundation
148import Dispatch
149
150struct Availability: Codable {
151    let available: Bool
152    let backend: String
153    let reason: String?
154}
155
156struct CalendarOut: Codable {
157    let id: String
158    let title: String
159    let source: String?
160    let color: String?
161    let writable: Bool
162}
163
164struct EventOut: Codable {
165    let id: String
166    let calendar_id: String
167    let title: String
168    let start: Date
169    let end: Date
170    let all_day: Bool
171    let location: String?
172    let notes: String?
173    let attendees: [String]
174}
175
176struct CalendarListing: Codable {
177    let available: Bool
178    let backend: String
179    let reason: String?
180    let calendars: [CalendarOut]
181}
182
183struct EventListing: Codable {
184    let available: Bool
185    let backend: String
186    let reason: String?
187    let events: [EventOut]
188}
189
190struct EventCreateInput: Codable {
191    let calendar_id: String
192    let title: String
193    let start: Date
194    let end: Date
195    let all_day: Bool?
196    let notes: String?
197    let location: String?
198    let url: String?
199}
200
201struct EventUpdateInput: Codable {
202    let event_id: String
203    let title: String?
204    let start: Date?
205    let end: Date?
206    let all_day: Bool?
207    let notes: String?
208    let location: String?
209    let url: String?
210}
211
212struct EventMutationOut: Codable {
213    let ok: Bool
214    let event: EventOut?
215    let reason: String?
216}
217
218func emit<T: Encodable>(_ value: T) {
219    let encoder = JSONEncoder()
220    encoder.dateEncodingStrategy = .iso8601
221    let data = try! encoder.encode(value)
222    FileHandle.standardOutput.write(data)
223}
224
225func eventOut(_ event: EKEvent) -> EventOut {
226    return EventOut(
227        id: event.eventIdentifier ?? "\(event.calendarItemIdentifier)-\(event.startDate.timeIntervalSince1970)",
228        calendar_id: event.calendar.calendarIdentifier,
229        title: event.title ?? "",
230        start: event.startDate,
231        end: event.endDate,
232        all_day: event.isAllDay,
233        location: event.location,
234        notes: event.notes,
235        attendees: (event.attendees ?? []).compactMap { $0.name ?? $0.url.absoluteString }
236    )
237}
238
239func unavailable<T: Encodable>(_ reason: String, empty: T) {
240    emit(empty)
241}
242
243let internetDateFormatter: ISO8601DateFormatter = {
244    let formatter = ISO8601DateFormatter()
245    formatter.formatOptions = [.withInternetDateTime]
246    return formatter
247}()
248
249let fractionalDateFormatter: ISO8601DateFormatter = {
250    let formatter = ISO8601DateFormatter()
251    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
252    return formatter
253}()
254
255func decodeDate(_ decoder: Decoder) throws -> Date {
256    let container = try decoder.singleValueContainer()
257    let raw = try container.decode(String.self)
258    if let date = fractionalDateFormatter.date(from: raw) ?? internetDateFormatter.date(from: raw) {
259        return date
260    }
261    throw DecodingError.dataCorruptedError(
262        in: container,
263        debugDescription: "Invalid RFC3339 date: \(raw)"
264    )
265}
266
267func ensureAccess(_ store: EKEventStore) -> String? {
268    let status = EKEventStore.authorizationStatus(for: .event)
269    switch status {
270    case .authorized:
271        return nil
272    case .notDetermined:
273        let semaphore = DispatchSemaphore(value: 0)
274        var granted = false
275        if #available(macOS 14.0, *) {
276            store.requestFullAccessToEvents { ok, _ in
277                granted = ok
278                semaphore.signal()
279            }
280        } else {
281            store.requestAccess(to: .event) { ok, _ in
282                granted = ok
283                semaphore.signal()
284            }
285        }
286        _ = semaphore.wait(timeout: .now() + 60)
287        return granted ? nil : "Calendar permission was not granted"
288    case .restricted:
289        return "Calendar permission is restricted by system policy"
290    case .denied:
291        return "Calendar permission is denied"
292    case .writeOnly:
293        return "Calendar permission is write-only; read access is required"
294    case .fullAccess:
295        return nil
296    @unknown default:
297        return "Calendar permission is unavailable"
298    }
299}
300
301func hexColor(_ cgColor: CGColor?) -> String? {
302    guard let cgColor = cgColor, let components = cgColor.components else { return nil }
303    let r: CGFloat
304    let g: CGFloat
305    let b: CGFloat
306    if components.count >= 3 {
307        r = components[0]
308        g = components[1]
309        b = components[2]
310    } else if components.count >= 1 {
311        r = components[0]
312        g = components[0]
313        b = components[0]
314    } else {
315        return nil
316    }
317    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))
318}
319
320let args = CommandLine.arguments
321let mode = args.count > 1 ? args[1] : "calendars"
322let store = EKEventStore()
323if let reason = ensureAccess(store) {
324    if mode == "events" {
325        emit(EventListing(available: false, backend: "eventkit", reason: reason, events: []))
326    } else if mode == "create" || mode == "update" || mode == "delete" {
327        emit(EventMutationOut(ok: false, event: nil, reason: reason))
328    } else {
329        emit(CalendarListing(available: false, backend: "eventkit", reason: reason, calendars: []))
330    }
331    exit(0)
332}
333
334if mode == "events" {
335    let formatter = ISO8601DateFormatter()
336    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
337    let fallback = ISO8601DateFormatter()
338    guard args.count >= 4,
339          let start = formatter.date(from: args[2]) ?? fallback.date(from: args[2]),
340          let end = formatter.date(from: args[3]) ?? fallback.date(from: args[3]) else {
341        emit(EventListing(available: false, backend: "eventkit", reason: "Invalid RFC3339 date range", events: []))
342        exit(0)
343    }
344    let requested = Set(args.dropFirst(4))
345    let calendars = store.calendars(for: .event).filter { requested.isEmpty || requested.contains($0.calendarIdentifier) }
346    let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
347    let events = store.events(matching: predicate).map { eventOut($0) }
348    emit(EventListing(available: true, backend: "eventkit", reason: nil, events: events))
349} else if mode == "create" {
350    guard args.count >= 3, let payload = args[2].data(using: .utf8) else {
351        emit(EventMutationOut(ok: false, event: nil, reason: "create requires JSON payload as args[2]"))
352        exit(0)
353    }
354    let decoder = JSONDecoder()
355    decoder.dateDecodingStrategy = .custom(decodeDate)
356    guard let input = try? decoder.decode(EventCreateInput.self, from: payload) else {
357        emit(EventMutationOut(ok: false, event: nil, reason: "invalid_create_input_json"))
358        exit(0)
359    }
360    guard let calendar = store.calendar(withIdentifier: input.calendar_id) else {
361        emit(EventMutationOut(ok: false, event: nil, reason: "calendar_not_found"))
362        exit(0)
363    }
364    guard calendar.allowsContentModifications else {
365        emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
366        exit(0)
367    }
368    let event = EKEvent(eventStore: store)
369    event.calendar = calendar
370    event.title = input.title
371    event.startDate = input.start
372    event.endDate = input.end
373    event.isAllDay = input.all_day ?? false
374    if let notes = input.notes, !notes.isEmpty { event.notes = notes }
375    if let location = input.location, !location.isEmpty { event.location = location }
376    if let urlRaw = input.url, !urlRaw.isEmpty, let parsed = URL(string: urlRaw) { event.url = parsed }
377    do {
378        try store.save(event, span: .thisEvent)
379        emit(EventMutationOut(ok: true, event: eventOut(event), reason: nil))
380    } catch {
381        emit(EventMutationOut(ok: false, event: nil, reason: "save_failed: \(error.localizedDescription)"))
382    }
383} else if mode == "update" {
384    guard args.count >= 3, let payload = args[2].data(using: .utf8) else {
385        emit(EventMutationOut(ok: false, event: nil, reason: "update requires JSON payload as args[2]"))
386        exit(0)
387    }
388    let decoder = JSONDecoder()
389    decoder.dateDecodingStrategy = .custom(decodeDate)
390    guard let input = try? decoder.decode(EventUpdateInput.self, from: payload) else {
391        emit(EventMutationOut(ok: false, event: nil, reason: "invalid_update_input_json"))
392        exit(0)
393    }
394    guard let event = store.event(withIdentifier: input.event_id) else {
395        emit(EventMutationOut(ok: false, event: nil, reason: "event_not_found"))
396        exit(0)
397    }
398    guard event.calendar.allowsContentModifications else {
399        emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
400        exit(0)
401    }
402    if let title = input.title { event.title = title }
403    if let start = input.start { event.startDate = start }
404    if let end = input.end { event.endDate = end }
405    if let allDay = input.all_day { event.isAllDay = allDay }
406    // Empty-string for notes/location/url means "clear the field". The
407    // None case (field absent in JSON) leaves the existing value alone.
408    if let notes = input.notes { event.notes = notes.isEmpty ? nil : notes }
409    if let location = input.location { event.location = location.isEmpty ? nil : location }
410    if let urlRaw = input.url {
411        event.url = urlRaw.isEmpty ? nil : URL(string: urlRaw)
412    }
413    do {
414        try store.save(event, span: .thisEvent)
415        emit(EventMutationOut(ok: true, event: eventOut(event), reason: nil))
416    } catch {
417        emit(EventMutationOut(ok: false, event: nil, reason: "save_failed: \(error.localizedDescription)"))
418    }
419} else if mode == "delete" {
420    guard args.count >= 3 else {
421        emit(EventMutationOut(ok: false, event: nil, reason: "delete requires event_id as args[2]"))
422        exit(0)
423    }
424    let eventId = args[2]
425    guard let event = store.event(withIdentifier: eventId) else {
426        emit(EventMutationOut(ok: false, event: nil, reason: "event_not_found"))
427        exit(0)
428    }
429    guard event.calendar.allowsContentModifications else {
430        emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
431        exit(0)
432    }
433    do {
434        try store.remove(event, span: .thisEvent)
435        emit(EventMutationOut(ok: true, event: nil, reason: nil))
436    } catch {
437        emit(EventMutationOut(ok: false, event: nil, reason: "delete_failed: \(error.localizedDescription)"))
438    }
439} else {
440    let calendars = store.calendars(for: .event).map { cal in
441        CalendarOut(
442            id: cal.calendarIdentifier,
443            title: cal.title,
444            source: cal.source?.title,
445            color: hexColor(cal.cgColor),
446            writable: cal.allowsContentModifications
447        )
448    }
449    emit(CalendarListing(available: true, backend: "eventkit", reason: nil, calendars: calendars))
450}
451"##;
452
453    pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
454        run_swift(&["calendars"])
455    }
456
457    pub fn list_events(
458        start: DateTime<Utc>,
459        end: DateTime<Utc>,
460        calendar_ids: &[String],
461    ) -> Result<EventListing, IntegrationError> {
462        let start = start.to_rfc3339();
463        let end = end.to_rfc3339();
464        let mut args = vec!["events", start.as_str(), end.as_str()];
465        args.extend(calendar_ids.iter().map(String::as_str));
466        run_swift(&args)
467    }
468
469    pub fn create_event(input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
470        let payload = serde_json::to_string(&input)
471            .map_err(|e| IntegrationError::Backend(format!("encode create input: {e}")))?;
472        run_swift(&["create", &payload])
473    }
474
475    pub fn update_event(input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
476        let payload = serde_json::to_string(&input)
477            .map_err(|e| IntegrationError::Backend(format!("encode update input: {e}")))?;
478        run_swift(&["update", &payload])
479    }
480
481    pub fn delete_event(event_id: &str) -> Result<EventMutationResult, IntegrationError> {
482        run_swift(&["delete", event_id])
483    }
484
485    fn run_swift<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
486        let helper = ensure_helper()?;
487        let output = Command::new(helper)
488            .env(
489                "SWIFT_MODULE_CACHE_PATH",
490                std::env::temp_dir().join("car-swift-module-cache"),
491            )
492            .env(
493                "CLANG_MODULE_CACHE_PATH",
494                std::env::temp_dir().join("car-clang-module-cache"),
495            )
496            .args(args)
497            .output()
498            .map_err(|e| IntegrationError::Backend(format!("swift: {e}")))?;
499
500        if !output.status.success() {
501            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
502            return Err(IntegrationError::Backend(format!(
503                "eventkit swift failed: {stderr}"
504            )));
505        }
506
507        serde_json::from_slice(&output.stdout)
508            .map_err(|e| IntegrationError::Backend(format!("eventkit json: {e}")))
509    }
510
511    fn ensure_helper() -> Result<std::path::PathBuf, IntegrationError> {
512        let dir = helper_cache_dir();
513        let app = dir.join(format!("CAR EventKit Helper {HELPER_VERSION}.app"));
514        let contents = app.join("Contents");
515        let macos = contents.join("MacOS");
516        let helper = macos.join("CAR EventKit Helper");
517        if helper.exists() {
518            return Ok(helper);
519        }
520
521        std::fs::create_dir_all(&macos)
522            .map_err(|e| IntegrationError::Backend(format!("helper cache: {e}")))?;
523        let source = dir.join(format!("car-eventkit-helper-{HELPER_VERSION}.swift"));
524        let plist = contents.join("Info.plist");
525        let entitlements = dir.join(format!("car-eventkit-helper-{HELPER_VERSION}.entitlements"));
526        std::fs::write(&source, SCRIPT)
527            .map_err(|e| IntegrationError::Backend(format!("eventkit helper source: {e}")))?;
528        std::fs::write(
529            &plist,
530            r#"<?xml version="1.0" encoding="UTF-8"?>
531<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
532<plist version="1.0">
533<dict>
534  <key>CFBundleIdentifier</key>
535  <string>ai.parslee.car.eventkit-helper</string>
536  <key>CFBundleExecutable</key>
537  <string>CAR EventKit Helper</string>
538  <key>CFBundleName</key>
539  <string>CAR EventKit Helper</string>
540  <key>CFBundlePackageType</key>
541  <string>APPL</string>
542  <key>CFBundleShortVersionString</key>
543  <string>0.9.0</string>
544  <key>CFBundleVersion</key>
545  <string>1</string>
546  <key>NSCalendarsUsageDescription</key>
547  <string>CAR reads calendars when an agent uses the calendar capability.</string>
548  <key>NSCalendarsFullAccessUsageDescription</key>
549  <string>CAR reads calendars when an agent uses the calendar capability.</string>
550</dict>
551</plist>
552"#,
553        )
554        .map_err(|e| IntegrationError::Backend(format!("eventkit helper plist: {e}")))?;
555        std::fs::write(
556            &entitlements,
557            r#"<?xml version="1.0" encoding="UTF-8"?>
558<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
559<plist version="1.0">
560<dict>
561  <key>com.apple.security.personal-information.calendars</key>
562  <true/>
563</dict>
564</plist>
565"#,
566        )
567        .map_err(|e| IntegrationError::Backend(format!("eventkit helper entitlements: {e}")))?;
568
569        let status = Command::new("/usr/bin/swiftc")
570            .env(
571                "SWIFT_MODULE_CACHE_PATH",
572                std::env::temp_dir().join("car-swift-module-cache"),
573            )
574            .env(
575                "CLANG_MODULE_CACHE_PATH",
576                std::env::temp_dir().join("car-clang-module-cache"),
577            )
578            .arg(&source)
579            .arg("-o")
580            .arg(&helper)
581            .arg("-Xlinker")
582            .arg("-sectcreate")
583            .arg("-Xlinker")
584            .arg("__TEXT")
585            .arg("-Xlinker")
586            .arg("__info_plist")
587            .arg("-Xlinker")
588            .arg(&plist)
589            .status()
590            .map_err(|e| IntegrationError::Backend(format!("swiftc: {e}")))?;
591
592        if !status.success() {
593            return Err(IntegrationError::Backend(format!(
594                "eventkit helper compile failed with status {status}"
595            )));
596        }
597
598        sign_helper(&app, &entitlements)?;
599        Ok(helper)
600    }
601
602    fn helper_cache_dir() -> std::path::PathBuf {
603        if let Some(path) = std::env::var_os("CAR_NATIVE_HELPER_DIR") {
604            return std::path::PathBuf::from(path);
605        }
606        if let Some(home) = std::env::var_os("HOME") {
607            return std::path::PathBuf::from(home)
608                .join("Library")
609                .join("Application Support")
610                .join("CAR")
611                .join("NativeHelpers");
612        }
613        std::env::temp_dir().join("car-native-helpers")
614    }
615
616    fn sign_helper(
617        app: &std::path::Path,
618        entitlements: &std::path::Path,
619    ) -> Result<(), IntegrationError> {
620        let status = Command::new("/usr/bin/codesign")
621            .arg("--force")
622            .arg("--sign")
623            .arg("-")
624            .arg("--entitlements")
625            .arg(entitlements)
626            .arg(app)
627            .status()
628            .map_err(|e| IntegrationError::Backend(format!("codesign: {e}")))?;
629        if !status.success() {
630            return Err(IntegrationError::Backend(format!(
631                "eventkit helper codesign failed with status {status}"
632            )));
633        }
634        Ok(())
635    }
636}
637
638#[cfg(not(target_os = "macos"))]
639mod backend {
640    use super::*;
641
642    pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
643        Ok(CalendarListing {
644            availability: current_backend_pending(),
645            calendars: vec![],
646        })
647    }
648
649    pub fn list_events(
650        _start: DateTime<Utc>,
651        _end: DateTime<Utc>,
652        _calendar_ids: &[String],
653    ) -> Result<EventListing, IntegrationError> {
654        Ok(EventListing {
655            availability: current_backend_pending(),
656            events: vec![],
657        })
658    }
659
660    pub fn create_event(_input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
661        Ok(EventMutationResult {
662            ok: false,
663            event: None,
664            reason: Some("calendar event creation is not yet implemented on this platform".into()),
665        })
666    }
667
668    pub fn update_event(_input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
669        Ok(EventMutationResult {
670            ok: false,
671            event: None,
672            reason: Some("calendar event updates are not yet implemented on this platform".into()),
673        })
674    }
675
676    pub fn delete_event(_event_id: &str) -> Result<EventMutationResult, IntegrationError> {
677        Ok(EventMutationResult {
678            ok: false,
679            event: None,
680            reason: Some("calendar event deletion is not yet implemented on this platform".into()),
681        })
682    }
683
684    fn current_backend_pending() -> Availability {
685        #[cfg(target_os = "windows")]
686        {
687            Availability::pending(
688                "msgraph",
689                "MS Graph / Outlook MAPI backends not yet wired. API shape \
690             is stable; downstream apps can code against it now.",
691            )
692        }
693        #[cfg(target_os = "linux")]
694        {
695            Availability::pending(
696                "eds",
697                "Evolution Data Server + CalDAV backends not yet wired. \
698             API shape is stable; downstream apps can code against it now.",
699            )
700        }
701        #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
702        {
703            Availability::pending("none", "Unsupported OS — no calendar backend modeled.")
704        }
705    }
706}