use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[cfg(target_os = "macos")]
use std::process::Command;
use super::{Availability, IntegrationError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Calendar {
pub id: String,
pub title: String,
pub source: Option<String>,
pub color: Option<String>,
pub writable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub id: String,
pub calendar_id: String,
pub title: String,
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
#[serde(default)]
pub all_day: bool,
pub location: Option<String>,
pub notes: Option<String>,
#[serde(default)]
pub attendees: Vec<Attendee>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attendee {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(default)]
pub is_current_user: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarListing {
#[serde(flatten)]
pub availability: Availability,
pub calendars: Vec<Calendar>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventListing {
#[serde(flatten)]
pub availability: Availability,
pub events: Vec<Event>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventCreateInput {
pub calendar_id: String,
pub title: String,
pub start: DateTime<Utc>,
pub end: DateTime<Utc>,
#[serde(default)]
pub all_day: bool,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventUpdateInput {
pub event_id: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub start: Option<DateTime<Utc>>,
#[serde(default)]
pub end: Option<DateTime<Utc>>,
#[serde(default)]
pub all_day: Option<bool>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventMutationResult {
pub ok: bool,
#[serde(default)]
pub event: Option<Event>,
#[serde(default)]
pub reason: Option<String>,
}
pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
backend::list_calendars()
}
pub fn authorization_status() -> String {
backend::authorization_status()
}
pub fn list_events(
start: DateTime<Utc>,
end: DateTime<Utc>,
calendar_ids: &[String],
) -> Result<EventListing, IntegrationError> {
backend::list_events(start, end, calendar_ids)
}
pub fn create_event(input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
backend::create_event(input)
}
pub fn update_event(input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
backend::update_event(input)
}
pub fn delete_event(event_id: &str) -> Result<EventMutationResult, IntegrationError> {
backend::delete_event(event_id)
}
#[cfg(target_os = "macos")]
mod backend {
use super::*;
const HELPER_VERSION: &str = "v6";
const SCRIPT: &str = r##"
import EventKit
import Foundation
import Dispatch
struct Availability: Codable {
let available: Bool
let backend: String
let reason: String?
}
struct CalendarOut: Codable {
let id: String
let title: String
let source: String?
let color: String?
let writable: Bool
}
struct AttendeeOut: Codable {
let name: String?
let email: String?
let status: String?
let role: String?
let is_current_user: Bool
}
struct EventOut: Codable {
let id: String
let calendar_id: String
let title: String
let start: Date
let end: Date
let all_day: Bool
let location: String?
let notes: String?
let attendees: [AttendeeOut]
let status: String
}
struct CalendarListing: Codable {
let available: Bool
let backend: String
let reason: String?
let calendars: [CalendarOut]
}
struct EventListing: Codable {
let available: Bool
let backend: String
let reason: String?
let events: [EventOut]
}
struct EventCreateInput: Codable {
let calendar_id: String
let title: String
let start: Date
let end: Date
let all_day: Bool?
let notes: String?
let location: String?
let url: String?
}
struct EventUpdateInput: Codable {
let event_id: String
let title: String?
let start: Date?
let end: Date?
let all_day: Bool?
let notes: String?
let location: String?
let url: String?
}
struct EventMutationOut: Codable {
let ok: Bool
let event: EventOut?
let reason: String?
}
func emit<T: Encodable>(_ value: T) {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try! encoder.encode(value)
FileHandle.standardOutput.write(data)
}
func attendeeStatus(_ s: EKParticipantStatus) -> String {
switch s {
case .accepted: return "accepted"
case .declined: return "declined"
case .tentative: return "tentative"
case .pending: return "pending"
case .delegated: return "delegated"
case .completed: return "completed"
case .inProcess: return "in_process"
default: return "unknown"
}
}
func attendeeRole(_ r: EKParticipantRole) -> String {
switch r {
case .required: return "required"
case .optional: return "optional"
case .chair: return "chair"
case .nonParticipant: return "non_participant"
default: return "unknown"
}
}
func attendeeOut(_ p: EKParticipant) -> AttendeeOut {
// EventKit exposes the address as a `mailto:` URL; surface the bare email.
var email: String? = nil
let urlStr = p.url.absoluteString
if urlStr.lowercased().hasPrefix("mailto:") {
email = String(urlStr.dropFirst("mailto:".count))
}
return AttendeeOut(
name: p.name,
email: email,
status: attendeeStatus(p.participantStatus),
role: attendeeRole(p.participantRole),
is_current_user: p.isCurrentUser
)
}
func eventStatusString(_ s: EKEventStatus) -> String {
switch s {
case .confirmed: return "confirmed"
case .tentative: return "tentative"
case .canceled: return "canceled"
default: return "none"
}
}
func eventOut(_ event: EKEvent) -> EventOut {
return EventOut(
id: event.eventIdentifier ?? "\(event.calendarItemIdentifier)-\(event.startDate.timeIntervalSince1970)",
calendar_id: event.calendar.calendarIdentifier,
title: event.title ?? "",
start: event.startDate,
end: event.endDate,
all_day: event.isAllDay,
location: event.location,
notes: event.notes,
attendees: (event.attendees ?? []).map { attendeeOut($0) },
status: eventStatusString(event.status)
)
}
func unavailable<T: Encodable>(_ reason: String, empty: T) {
emit(empty)
}
let internetDateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
let fractionalDateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
func decodeDate(_ decoder: Decoder) throws -> Date {
let container = try decoder.singleValueContainer()
let raw = try container.decode(String.self)
if let date = fractionalDateFormatter.date(from: raw) ?? internetDateFormatter.date(from: raw) {
return date
}
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Invalid RFC3339 date: \(raw)"
)
}
func ensureAccess(_ store: EKEventStore) -> String? {
let status = EKEventStore.authorizationStatus(for: .event)
switch status {
case .authorized:
return nil
case .notDetermined:
let semaphore = DispatchSemaphore(value: 0)
var granted = false
if #available(macOS 14.0, *) {
store.requestFullAccessToEvents { ok, _ in
granted = ok
semaphore.signal()
}
} else {
store.requestAccess(to: .event) { ok, _ in
granted = ok
semaphore.signal()
}
}
_ = semaphore.wait(timeout: .now() + 60)
return granted ? nil : "Calendar permission was not granted"
case .restricted:
return "Calendar permission is restricted by system policy"
case .denied:
return "Calendar permission is denied"
case .writeOnly:
return "Calendar permission is write-only; read access is required"
case .fullAccess:
return nil
@unknown default:
return "Calendar permission is unavailable"
}
}
func hexColor(_ cgColor: CGColor?) -> String? {
guard let cgColor = cgColor, let components = cgColor.components else { return nil }
let r: CGFloat
let g: CGFloat
let b: CGFloat
if components.count >= 3 {
r = components[0]
g = components[1]
b = components[2]
} else if components.count >= 1 {
r = components[0]
g = components[0]
b = components[0]
} else {
return nil
}
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))
}
let args = CommandLine.arguments
let mode = args.count > 1 ? args[1] : "calendars"
// Non-prompting authorization-status query (car-releases#71). Reports the
// CURRENT EKEventStore.authorizationStatus — never instantiates a store or
// triggers a TCC prompt, unlike ensureAccess below — so `permissionStatus`
// can be an honest gate. Handles the macOS 14 split (.fullAccess/.writeOnly
// replaced .authorized).
if mode == "auth-status" {
struct AuthStatusOut: Codable { let status: String }
let label: String
switch EKEventStore.authorizationStatus(for: .event) {
case .authorized, .fullAccess: label = "granted"
case .writeOnly: label = "write_only"
case .denied: label = "denied"
case .restricted: label = "restricted"
case .notDetermined: label = "not_determined"
@unknown default: label = "unknown"
}
emit(AuthStatusOut(status: label))
exit(0)
}
let store = EKEventStore()
if let reason = ensureAccess(store) {
if mode == "events" {
emit(EventListing(available: false, backend: "eventkit", reason: reason, events: []))
} else if mode == "create" || mode == "update" || mode == "delete" {
emit(EventMutationOut(ok: false, event: nil, reason: reason))
} else {
emit(CalendarListing(available: false, backend: "eventkit", reason: reason, calendars: []))
}
exit(0)
}
if mode == "events" {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let fallback = ISO8601DateFormatter()
guard args.count >= 4,
let start = formatter.date(from: args[2]) ?? fallback.date(from: args[2]),
let end = formatter.date(from: args[3]) ?? fallback.date(from: args[3]) else {
emit(EventListing(available: false, backend: "eventkit", reason: "Invalid RFC3339 date range", events: []))
exit(0)
}
let requested = Set(args.dropFirst(4))
let calendars = store.calendars(for: .event).filter { requested.isEmpty || requested.contains($0.calendarIdentifier) }
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars)
let events = store.events(matching: predicate).map { eventOut($0) }
emit(EventListing(available: true, backend: "eventkit", reason: nil, events: events))
} else if mode == "create" {
guard args.count >= 3, let payload = args[2].data(using: .utf8) else {
emit(EventMutationOut(ok: false, event: nil, reason: "create requires JSON payload as args[2]"))
exit(0)
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom(decodeDate)
guard let input = try? decoder.decode(EventCreateInput.self, from: payload) else {
emit(EventMutationOut(ok: false, event: nil, reason: "invalid_create_input_json"))
exit(0)
}
guard let calendar = store.calendar(withIdentifier: input.calendar_id) else {
emit(EventMutationOut(ok: false, event: nil, reason: "calendar_not_found"))
exit(0)
}
guard calendar.allowsContentModifications else {
emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
exit(0)
}
let event = EKEvent(eventStore: store)
event.calendar = calendar
event.title = input.title
event.startDate = input.start
event.endDate = input.end
event.isAllDay = input.all_day ?? false
if let notes = input.notes, !notes.isEmpty { event.notes = notes }
if let location = input.location, !location.isEmpty { event.location = location }
if let urlRaw = input.url, !urlRaw.isEmpty, let parsed = URL(string: urlRaw) { event.url = parsed }
do {
try store.save(event, span: .thisEvent)
emit(EventMutationOut(ok: true, event: eventOut(event), reason: nil))
} catch {
emit(EventMutationOut(ok: false, event: nil, reason: "save_failed: \(error.localizedDescription)"))
}
} else if mode == "update" {
guard args.count >= 3, let payload = args[2].data(using: .utf8) else {
emit(EventMutationOut(ok: false, event: nil, reason: "update requires JSON payload as args[2]"))
exit(0)
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom(decodeDate)
guard let input = try? decoder.decode(EventUpdateInput.self, from: payload) else {
emit(EventMutationOut(ok: false, event: nil, reason: "invalid_update_input_json"))
exit(0)
}
guard let event = store.event(withIdentifier: input.event_id) else {
emit(EventMutationOut(ok: false, event: nil, reason: "event_not_found"))
exit(0)
}
guard event.calendar.allowsContentModifications else {
emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
exit(0)
}
if let title = input.title { event.title = title }
if let start = input.start { event.startDate = start }
if let end = input.end { event.endDate = end }
if let allDay = input.all_day { event.isAllDay = allDay }
// Empty-string for notes/location/url means "clear the field". The
// None case (field absent in JSON) leaves the existing value alone.
if let notes = input.notes { event.notes = notes.isEmpty ? nil : notes }
if let location = input.location { event.location = location.isEmpty ? nil : location }
if let urlRaw = input.url {
event.url = urlRaw.isEmpty ? nil : URL(string: urlRaw)
}
do {
try store.save(event, span: .thisEvent)
emit(EventMutationOut(ok: true, event: eventOut(event), reason: nil))
} catch {
emit(EventMutationOut(ok: false, event: nil, reason: "save_failed: \(error.localizedDescription)"))
}
} else if mode == "delete" {
guard args.count >= 3 else {
emit(EventMutationOut(ok: false, event: nil, reason: "delete requires event_id as args[2]"))
exit(0)
}
let eventId = args[2]
guard let event = store.event(withIdentifier: eventId) else {
emit(EventMutationOut(ok: false, event: nil, reason: "event_not_found"))
exit(0)
}
guard event.calendar.allowsContentModifications else {
emit(EventMutationOut(ok: false, event: nil, reason: "calendar_is_read_only"))
exit(0)
}
do {
try store.remove(event, span: .thisEvent)
emit(EventMutationOut(ok: true, event: nil, reason: nil))
} catch {
emit(EventMutationOut(ok: false, event: nil, reason: "delete_failed: \(error.localizedDescription)"))
}
} else {
let calendars = store.calendars(for: .event).map { cal in
CalendarOut(
id: cal.calendarIdentifier,
title: cal.title,
source: cal.source?.title,
color: hexColor(cal.cgColor),
writable: cal.allowsContentModifications
)
}
emit(CalendarListing(available: true, backend: "eventkit", reason: nil, calendars: calendars))
}
"##;
pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
run_swift(&["calendars"])
}
#[derive(serde::Deserialize)]
struct AuthStatus {
status: String,
}
pub fn authorization_status() -> String {
match run_swift::<AuthStatus>(&["auth-status"]) {
Ok(a) => a.status,
Err(e) => {
tracing::debug!(error = %e, "calendar auth-status helper failed; reporting unknown");
"unknown".to_string()
}
}
}
pub fn list_events(
start: DateTime<Utc>,
end: DateTime<Utc>,
calendar_ids: &[String],
) -> Result<EventListing, IntegrationError> {
let start = start.to_rfc3339();
let end = end.to_rfc3339();
let mut args = vec!["events", start.as_str(), end.as_str()];
args.extend(calendar_ids.iter().map(String::as_str));
run_swift(&args)
}
pub fn create_event(input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
let payload = serde_json::to_string(&input)
.map_err(|e| IntegrationError::Backend(format!("encode create input: {e}")))?;
run_swift(&["create", &payload])
}
pub fn update_event(input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
let payload = serde_json::to_string(&input)
.map_err(|e| IntegrationError::Backend(format!("encode update input: {e}")))?;
run_swift(&["update", &payload])
}
pub fn delete_event(event_id: &str) -> Result<EventMutationResult, IntegrationError> {
run_swift(&["delete", event_id])
}
fn run_swift<T: serde::de::DeserializeOwned>(args: &[&str]) -> Result<T, IntegrationError> {
let helper = ensure_helper()?;
let output = Command::new(helper)
.env(
"SWIFT_MODULE_CACHE_PATH",
std::env::temp_dir().join("car-swift-module-cache"),
)
.env(
"CLANG_MODULE_CACHE_PATH",
std::env::temp_dir().join("car-clang-module-cache"),
)
.args(args)
.output()
.map_err(|e| IntegrationError::Backend(format!("swift: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(IntegrationError::Backend(format!(
"eventkit swift failed: {stderr}"
)));
}
serde_json::from_slice(&output.stdout)
.map_err(|e| IntegrationError::Backend(format!("eventkit json: {e}")))
}
fn source_fingerprint(s: &str) -> String {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
s.hash(&mut h);
format!("{:016x}", h.finish())
}
fn helper_cache_key() -> String {
format!("{HELPER_VERSION}-{}", source_fingerprint(SCRIPT))
}
#[cfg(test)]
mod helper_cache_tests {
use super::{helper_cache_key, source_fingerprint, HELPER_VERSION};
#[test]
fn fingerprint_is_deterministic_and_input_sensitive() {
assert_eq!(source_fingerprint("abc"), source_fingerprint("abc"));
assert_ne!(
source_fingerprint("mode == create"),
source_fingerprint("mode == delete")
);
assert_eq!(source_fingerprint("abc").len(), 16);
}
#[test]
fn cache_key_carries_version_prefix_and_source_hash() {
let key = helper_cache_key();
assert!(key.starts_with(&format!("{HELPER_VERSION}-")), "key: {key}");
assert_eq!(key.len(), HELPER_VERSION.len() + 1 + 16);
}
}
fn ensure_helper() -> Result<std::path::PathBuf, IntegrationError> {
let dir = helper_cache_dir();
let key = helper_cache_key();
let app = dir.join(format!("CAR EventKit Helper {key}.app"));
let contents = app.join("Contents");
let macos = contents.join("MacOS");
let helper = macos.join("CAR EventKit Helper");
if helper.exists() {
return Ok(helper);
}
std::fs::create_dir_all(&macos)
.map_err(|e| IntegrationError::Backend(format!("helper cache: {e}")))?;
let source = dir.join(format!("car-eventkit-helper-{key}.swift"));
let plist = contents.join("Info.plist");
let entitlements = dir.join(format!("car-eventkit-helper-{key}.entitlements"));
std::fs::write(&source, SCRIPT)
.map_err(|e| IntegrationError::Backend(format!("eventkit helper source: {e}")))?;
std::fs::write(
&plist,
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>ai.parslee.car.eventkit-helper</string>
<key>CFBundleExecutable</key>
<string>CAR EventKit Helper</string>
<key>CFBundleName</key>
<string>CAR EventKit Helper</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.9.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSCalendarsUsageDescription</key>
<string>CAR reads calendars when an agent uses the calendar capability.</string>
<key>NSCalendarsFullAccessUsageDescription</key>
<string>CAR reads calendars when an agent uses the calendar capability.</string>
</dict>
</plist>
"#,
)
.map_err(|e| IntegrationError::Backend(format!("eventkit helper plist: {e}")))?;
std::fs::write(
&entitlements,
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.personal-information.calendars</key>
<true/>
</dict>
</plist>
"#,
)
.map_err(|e| IntegrationError::Backend(format!("eventkit helper entitlements: {e}")))?;
let status = Command::new("/usr/bin/swiftc")
.env(
"SWIFT_MODULE_CACHE_PATH",
std::env::temp_dir().join("car-swift-module-cache"),
)
.env(
"CLANG_MODULE_CACHE_PATH",
std::env::temp_dir().join("car-clang-module-cache"),
)
.arg(&source)
.arg("-o")
.arg(&helper)
.arg("-Xlinker")
.arg("-sectcreate")
.arg("-Xlinker")
.arg("__TEXT")
.arg("-Xlinker")
.arg("__info_plist")
.arg("-Xlinker")
.arg(&plist)
.status()
.map_err(|e| IntegrationError::Backend(format!("swiftc: {e}")))?;
if !status.success() {
return Err(IntegrationError::Backend(format!(
"eventkit helper compile failed with status {status}"
)));
}
sign_helper(&app, &entitlements)?;
Ok(helper)
}
fn helper_cache_dir() -> std::path::PathBuf {
if let Some(path) = std::env::var_os("CAR_NATIVE_HELPER_DIR") {
return std::path::PathBuf::from(path);
}
if let Some(home) = std::env::var_os("HOME") {
return std::path::PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("CAR")
.join("NativeHelpers");
}
std::env::temp_dir().join("car-native-helpers")
}
fn sign_helper(
app: &std::path::Path,
entitlements: &std::path::Path,
) -> Result<(), IntegrationError> {
let status = Command::new("/usr/bin/codesign")
.arg("--force")
.arg("--sign")
.arg("-")
.arg("--entitlements")
.arg(entitlements)
.arg(app)
.status()
.map_err(|e| IntegrationError::Backend(format!("codesign: {e}")))?;
if !status.success() {
return Err(IntegrationError::Backend(format!(
"eventkit helper codesign failed with status {status}"
)));
}
Ok(())
}
}
#[cfg(not(target_os = "macos"))]
mod backend {
use super::*;
pub fn authorization_status() -> String {
"not_applicable".to_string()
}
pub fn list_calendars() -> Result<CalendarListing, IntegrationError> {
Ok(CalendarListing {
availability: current_backend_pending(),
calendars: vec![],
})
}
pub fn list_events(
_start: DateTime<Utc>,
_end: DateTime<Utc>,
_calendar_ids: &[String],
) -> Result<EventListing, IntegrationError> {
Ok(EventListing {
availability: current_backend_pending(),
events: vec![],
})
}
pub fn create_event(_input: EventCreateInput) -> Result<EventMutationResult, IntegrationError> {
Ok(EventMutationResult {
ok: false,
event: None,
reason: Some("calendar event creation is not yet implemented on this platform".into()),
})
}
pub fn update_event(_input: EventUpdateInput) -> Result<EventMutationResult, IntegrationError> {
Ok(EventMutationResult {
ok: false,
event: None,
reason: Some("calendar event updates are not yet implemented on this platform".into()),
})
}
pub fn delete_event(_event_id: &str) -> Result<EventMutationResult, IntegrationError> {
Ok(EventMutationResult {
ok: false,
event: None,
reason: Some("calendar event deletion is not yet implemented on this platform".into()),
})
}
fn current_backend_pending() -> Availability {
#[cfg(target_os = "windows")]
{
Availability::pending(
"msgraph",
"MS Graph / Outlook MAPI backends not yet wired. API shape \
is stable; downstream apps can code against it now.",
)
}
#[cfg(target_os = "linux")]
{
Availability::pending(
"eds",
"Evolution Data Server + CalDAV backends not yet wired. \
API shape is stable; downstream apps can code against it now.",
)
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
Availability::pending("none", "Unsupported OS — no calendar backend modeled.")
}
}
}
#[cfg(test)]
mod event_shape_tests {
use super::*;
#[test]
fn enriched_attendees_and_status_deserialize() {
let json = r#"{
"available": true,
"backend": "eventkit",
"events": [{
"id": "evt-1",
"calendar_id": "cal-1",
"title": "Design sync",
"start": "2026-06-21T15:00:00Z",
"end": "2026-06-21T16:00:00Z",
"all_day": false,
"location": null,
"notes": null,
"status": "confirmed",
"attendees": [
{"name": "Matt Liotta", "email": "matt@parslee.ai", "status": "accepted", "role": "chair", "is_current_user": true},
{"name": "Dan Capri", "email": "dan@example.com", "status": "tentative", "role": "required", "is_current_user": false}
]
}]
}"#;
let listing: EventListing = serde_json::from_str(json).expect("deserialize");
let e = &listing.events[0];
assert_eq!(e.status.as_deref(), Some("confirmed"));
assert_eq!(e.attendees.len(), 2);
assert_eq!(e.attendees[0].status.as_deref(), Some("accepted"));
assert!(e.attendees[0].is_current_user);
assert_eq!(e.attendees[0].email.as_deref(), Some("matt@parslee.ai"));
assert_eq!(e.attendees[0].role.as_deref(), Some("chair"));
assert_eq!(e.attendees[1].status.as_deref(), Some("tentative"));
assert!(!e.attendees[1].is_current_user);
}
#[test]
fn authorization_status_returns_a_known_label() {
let s = authorization_status();
assert!(
matches!(
s.as_str(),
"granted" | "write_only" | "denied" | "restricted"
| "not_determined" | "not_applicable" | "unknown"
),
"unexpected status label: {s}"
);
eprintln!("calendar authorization_status() = {s}");
}
#[test]
fn legacy_event_without_enrichment_still_deserializes() {
let json = r#"{"available":true,"backend":"none","events":[{
"id":"e","calendar_id":"c","title":"t",
"start":"2026-06-21T15:00:00Z","end":"2026-06-21T16:00:00Z"
}]}"#;
let listing: EventListing = serde_json::from_str(json).expect("deserialize");
assert!(listing.events[0].attendees.is_empty());
assert_eq!(listing.events[0].status, None);
}
}