use std::sync::Arc;
use async_trait::async_trait;
use car_engine::ToolExecutor;
use serde_json::{json, Value};
const MUTATION_TIER: &str = "full_access";
const CALENDAR_PERMISSION_FIX: &str =
"Open System Settings > Privacy & Security > Calendars, allow CAR/CarHost full access, then retry.";
trait CalendarBackend: Send + Sync {
fn permission_status(&self) -> Result<String, String>;
fn calendars(&self) -> Result<Value, String>;
fn events(
&self,
start: chrono::DateTime<chrono::Utc>,
end: chrono::DateTime<chrono::Utc>,
calendar_ids: &[String],
) -> Result<Value, String>;
fn create_event(&self, input: &Value) -> Result<Value, String>;
fn update_event(&self, input: &Value) -> Result<Value, String>;
fn delete_event(&self, event_id: &str) -> Result<Value, String>;
}
struct EventKitBackend;
impl CalendarBackend for EventKitBackend {
fn permission_status(&self) -> Result<String, String> {
let status = car_ffi_common::permissions::status("calendar", None)?;
status
.get("status")
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| "Calendar permission probe returned no status".to_string())
}
fn calendars(&self) -> Result<Value, String> {
car_ffi_common::integrations::calendar_list()
}
fn events(
&self,
start: chrono::DateTime<chrono::Utc>,
end: chrono::DateTime<chrono::Utc>,
calendar_ids: &[String],
) -> Result<Value, String> {
car_ffi_common::integrations::calendar_events(start, end, calendar_ids)
}
fn create_event(&self, input: &Value) -> Result<Value, String> {
car_ffi_common::integrations::calendar_create_event(&input.to_string())
}
fn update_event(&self, input: &Value) -> Result<Value, String> {
car_ffi_common::integrations::calendar_update_event(&input.to_string())
}
fn delete_event(&self, event_id: &str) -> Result<Value, String> {
car_ffi_common::integrations::calendar_delete_event(event_id)
}
}
pub struct CalendarTools {
backend: Arc<dyn CalendarBackend>,
macos: bool,
}
impl CalendarTools {
pub fn new() -> Self {
Self {
backend: Arc::new(EventKitBackend),
macos: cfg!(target_os = "macos"),
}
}
#[cfg(test)]
fn with_backend(backend: Arc<dyn CalendarBackend>, macos: bool) -> Self {
Self { backend, macos }
}
pub fn tool_defs(&self) -> Vec<Value> {
if self.macos
&& self
.backend
.permission_status()
.is_ok_and(|status| status == "granted")
{
calendar_tool_defs()
} else {
Vec::new()
}
}
fn require_permission(&self) -> Result<(), String> {
if !self.macos {
return Err("local Calendar.app tools are available only on macOS".to_string());
}
let status = self
.backend
.permission_status()
.unwrap_or_else(|_| "unknown".to_string());
if status == "granted" {
return Ok(());
}
Err(format!(
"Calendar access is {status}; local calendar tools cannot run. {CALENDAR_PERMISSION_FIX}"
))
}
fn events(&self, params: &Value) -> Result<Value, String> {
let start = parse_rfc3339(params, "start")?;
let end = parse_rfc3339(params, "end")?;
if end <= start {
return Err("calendar_events requires `end` after `start`".to_string());
}
let calendar_ids = optional_strings(params, "calendar_ids")?;
self.backend.events(start, end, &calendar_ids)
}
fn create_event(&self, params: &Value) -> Result<Value, String> {
let mut input = params.clone();
let object = input
.as_object_mut()
.ok_or_else(|| "calendar_create_event parameters must be an object".to_string())?;
let needs_calendar = object
.get("calendar_id")
.and_then(Value::as_str)
.map(str::trim)
.is_none_or(str::is_empty);
if needs_calendar {
let listing = self.backend.calendars()?;
let calendar_id = listing
.get("calendars")
.and_then(Value::as_array)
.and_then(|calendars| {
calendars.iter().find_map(|calendar| {
(calendar.get("writable").and_then(Value::as_bool) == Some(true))
.then(|| calendar.get("id").and_then(Value::as_str))
.flatten()
})
})
.ok_or_else(|| {
"no writable local calendar is available; create or enable one in Calendar.app"
.to_string()
})?;
object.insert("calendar_id".to_string(), json!(calendar_id));
}
let mut result = self.backend.create_event(&input)?;
ensure_event_identifier(&mut result, "calendar_create_event")?;
Ok(result)
}
fn update_event(&self, params: &Value) -> Result<Value, String> {
let mut result = self.backend.update_event(params)?;
ensure_event_identifier(&mut result, "calendar_update_event")?;
Ok(result)
}
fn delete_event(&self, params: &Value) -> Result<Value, String> {
let event_id = required_string(params, "event_id")?;
let mut result = self.backend.delete_event(event_id)?;
if result.get("ok").and_then(Value::as_bool) == Some(true) {
let object = result.as_object_mut().ok_or_else(|| {
"calendar_delete_event backend returned a non-object result".to_string()
})?;
object.insert("event_id".to_string(), json!(event_id));
}
Ok(result)
}
}
impl Default for CalendarTools {
fn default() -> Self {
Self::new()
}
}
pub(super) fn calendar_tool_defs() -> Vec<Value> {
vec![
json!({
"name": "calendar_events",
"description": "Read events from the current Mac user's local Calendar.app through EventKit. This is the private on-device path: it does not use Parslee or require a Microsoft 365 connection. Pass an RFC3339 range; results include stable event and calendar ids for later updates or deletion. After answering a guided schedule check, end with a concrete offer to add or move an event; do not make the change until the user accepts the approval-gated action.",
"parameters": {
"type": "object",
"properties": {
"start": { "type": "string", "description": "Inclusive range start as RFC3339 with an offset." },
"end": { "type": "string", "description": "Exclusive range end as RFC3339 with an offset." },
"calendar_ids": { "type": "array", "items": { "type": "string" }, "description": "Optional calendar ids to include; omit for every accessible calendar." }
},
"required": ["start", "end"],
"additionalProperties": false
}
}),
json!({
"name": "calendar_create_event",
"description": "Create an event in the current Mac user's local Calendar.app through EventKit. This changes on-device calendar data and requires approval unless the session has full access. Omit calendar_id to use the first writable local calendar. Returns the created event with its stable id.",
"parameters": {
"type": "object",
"properties": {
"calendar_id": { "type": "string", "description": "Writable local calendar id. Optional; omit to choose the first writable calendar." },
"title": { "type": "string" },
"start": { "type": "string", "description": "RFC3339 start time with an offset." },
"end": { "type": "string", "description": "RFC3339 end time with an offset." },
"all_day": { "type": "boolean" },
"notes": { "type": "string" },
"location": { "type": "string" },
"url": { "type": "string" }
},
"required": ["title", "start", "end"],
"additionalProperties": false
},
"mutating": true,
"tier": MUTATION_TIER
}),
json!({
"name": "calendar_update_event",
"description": "Update an event in the current Mac user's local Calendar.app through EventKit. Use the stable event_id returned by calendar_events or calendar_create_event. This changes on-device calendar data and requires approval unless the session has full access.",
"parameters": {
"type": "object",
"properties": {
"event_id": { "type": "string" },
"title": { "type": "string" },
"start": { "type": "string", "description": "RFC3339 start time with an offset." },
"end": { "type": "string", "description": "RFC3339 end time with an offset." },
"all_day": { "type": "boolean" },
"notes": { "type": "string", "description": "Empty string clears the field." },
"location": { "type": "string", "description": "Empty string clears the field." },
"url": { "type": "string", "description": "Empty string clears the field." }
},
"required": ["event_id"],
"additionalProperties": false
},
"mutating": true,
"tier": MUTATION_TIER
}),
json!({
"name": "calendar_delete_event",
"description": "Delete an event from the current Mac user's local Calendar.app through EventKit. Use the stable event_id returned by calendar_events. This changes on-device calendar data and requires approval unless the session has full access; success echoes event_id.",
"parameters": {
"type": "object",
"properties": { "event_id": { "type": "string" } },
"required": ["event_id"],
"additionalProperties": false
},
"mutating": true,
"tier": MUTATION_TIER
}),
]
}
#[async_trait]
impl ToolExecutor for CalendarTools {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
match tool {
"calendar_events" => {
self.require_permission()?;
self.events(params)
}
"calendar_create_event" => {
self.require_permission()?;
self.create_event(params)
}
"calendar_update_event" => {
self.require_permission()?;
self.update_event(params)
}
"calendar_delete_event" => {
self.require_permission()?;
self.delete_event(params)
}
other => Err(format!("unknown tool: '{other}'")),
}
}
}
fn parse_rfc3339(params: &Value, field: &str) -> Result<chrono::DateTime<chrono::Utc>, String> {
let raw = required_string(params, field)?;
chrono::DateTime::parse_from_rfc3339(raw)
.map(|value| value.with_timezone(&chrono::Utc))
.map_err(|error| format!("calendar {field} must be RFC3339: {error}"))
}
fn required_string<'a>(params: &'a Value, field: &str) -> Result<&'a str, String> {
params
.get(field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| format!("calendar tool requires non-empty `{field}`"))
}
fn optional_strings(params: &Value, field: &str) -> Result<Vec<String>, String> {
match params.get(field) {
None => Ok(Vec::new()),
Some(Value::Array(values)) => values
.iter()
.map(|value| {
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| format!("calendar `{field}` must contain non-empty strings"))
})
.collect(),
Some(_) => Err(format!("calendar `{field}` must be an array of strings")),
}
}
fn ensure_event_identifier(result: &mut Value, tool: &str) -> Result<(), String> {
if result.get("ok").and_then(Value::as_bool) != Some(true) {
return Ok(());
}
if result
.get("event")
.and_then(|event| event.get("id"))
.and_then(Value::as_str)
.is_some_and(|id| !id.is_empty())
{
return Ok(());
}
Err(format!(
"{tool} succeeded without the stable event id required for follow-up actions"
))
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
struct MockCalendarBackend {
status: Result<String, String>,
calls: Mutex<Vec<(String, Value)>>,
}
impl MockCalendarBackend {
fn granted() -> Arc<Self> {
Arc::new(Self {
status: Ok("granted".to_string()),
calls: Mutex::new(Vec::new()),
})
}
fn with_status(status: &str) -> Arc<Self> {
Arc::new(Self {
status: Ok(status.to_string()),
calls: Mutex::new(Vec::new()),
})
}
}
impl CalendarBackend for MockCalendarBackend {
fn permission_status(&self) -> Result<String, String> {
self.status.clone()
}
fn calendars(&self) -> Result<Value, String> {
Ok(json!({
"available": true,
"backend": "mock_eventkit",
"calendars": [
{"id": "read-only", "title": "Holidays", "writable": false},
{"id": "cal-local", "title": "Personal", "writable": true}
]
}))
}
fn events(
&self,
start: chrono::DateTime<chrono::Utc>,
end: chrono::DateTime<chrono::Utc>,
calendar_ids: &[String],
) -> Result<Value, String> {
self.calls.lock().unwrap().push((
"events".to_string(),
json!({"start": start, "end": end, "calendar_ids": calendar_ids}),
));
Ok(json!({
"available": true,
"backend": "mock_eventkit",
"events": [{"id": "event-7", "calendar_id": "cal-local", "title": "Review"}]
}))
}
fn create_event(&self, input: &Value) -> Result<Value, String> {
self.calls
.lock()
.unwrap()
.push(("create".to_string(), input.clone()));
Ok(
json!({"ok": true, "event": {"id": "event-created", "calendar_id": input["calendar_id"]}}),
)
}
fn update_event(&self, input: &Value) -> Result<Value, String> {
self.calls
.lock()
.unwrap()
.push(("update".to_string(), input.clone()));
Ok(json!({"ok": true, "event": {"id": input["event_id"]}}))
}
fn delete_event(&self, event_id: &str) -> Result<Value, String> {
self.calls
.lock()
.unwrap()
.push(("delete".to_string(), json!({"event_id": event_id})));
Ok(json!({"ok": true}))
}
}
#[test]
fn schemas_assign_read_and_approval_tiers() {
let defs = calendar_tool_defs();
let names: Vec<&str> = defs.iter().filter_map(|def| def["name"].as_str()).collect();
assert_eq!(
names,
[
"calendar_events",
"calendar_create_event",
"calendar_update_event",
"calendar_delete_event"
]
);
assert!(defs[0].get("tier").is_none(), "reads are ungated");
assert!(defs[0].get("mutating").is_none());
assert!(defs[0]["description"]
.as_str()
.is_some_and(|description| description.contains("offer to add or move an event")));
for def in &defs[1..] {
assert_eq!(def["tier"], MUTATION_TIER);
assert_eq!(def["mutating"], true);
}
assert!(defs.iter().all(|def| def["description"]
.as_str()
.is_some_and(|description| description.contains("local Calendar.app"))));
}
#[test]
fn calendar_mutations_gate_until_the_session_has_full_access() {
let defs = calendar_tool_defs();
let gated = super::super::tier_gated_tool_names(
&defs,
car_policy::permission::PermissionTier::ReadOnly,
);
assert_eq!(
gated,
[
"calendar_create_event",
"calendar_update_event",
"calendar_delete_event"
]
);
assert!(super::super::tier_gated_tool_names(
&defs,
car_policy::permission::PermissionTier::FullAccess,
)
.is_empty());
}
#[test]
fn advertisement_requires_macos_and_granted_permission() {
let granted: Arc<dyn CalendarBackend> = MockCalendarBackend::granted();
assert_eq!(
CalendarTools::with_backend(granted, true).tool_defs().len(),
4
);
let denied: Arc<dyn CalendarBackend> = MockCalendarBackend::with_status("denied");
assert!(CalendarTools::with_backend(denied, true)
.tool_defs()
.is_empty());
let off_platform: Arc<dyn CalendarBackend> = MockCalendarBackend::granted();
assert!(CalendarTools::with_backend(off_platform, false)
.tool_defs()
.is_empty());
}
#[tokio::test]
async fn unavailable_permission_returns_actionable_error_for_stale_calls() {
let backend: Arc<dyn CalendarBackend> = MockCalendarBackend::with_status("denied");
let tools = CalendarTools::with_backend(backend, true);
let error = tools
.execute(
"calendar_events",
&json!({"start": "2026-09-18T09:00:00Z", "end": "2026-09-18T10:00:00Z"}),
)
.await
.unwrap_err();
assert!(error.contains("Calendar access is denied"), "{error}");
assert!(error.contains("System Settings"), "{error}");
assert!(error.contains("Calendars"), "{error}");
}
#[tokio::test]
async fn read_uses_backend_and_preserves_stable_event_id() {
let backend = MockCalendarBackend::granted();
let tools = CalendarTools::with_backend(backend.clone(), true);
let result = tools
.execute(
"calendar_events",
&json!({
"start": "2026-09-18T09:00:00Z",
"end": "2026-09-18T10:00:00Z",
"calendar_ids": ["cal-local"]
}),
)
.await
.unwrap();
assert_eq!(result["events"][0]["id"], "event-7");
assert_eq!(backend.calls.lock().unwrap()[0].0, "events");
}
#[tokio::test]
async fn create_selects_writable_calendar_and_returns_event_id() {
let backend = MockCalendarBackend::granted();
let tools = CalendarTools::with_backend(backend.clone(), true);
let result = tools
.execute(
"calendar_create_event",
&json!({
"title": "Planning",
"start": "2026-09-18T09:00:00Z",
"end": "2026-09-18T09:30:00Z"
}),
)
.await
.unwrap();
assert_eq!(result["event"]["id"], "event-created");
let calls = backend.calls.lock().unwrap();
assert_eq!(calls[0].0, "create");
assert_eq!(calls[0].1["calendar_id"], "cal-local");
}
#[tokio::test]
async fn update_and_delete_echo_stable_identifiers() {
let backend = MockCalendarBackend::granted();
let tools = CalendarTools::with_backend(backend, true);
let updated = tools
.execute(
"calendar_update_event",
&json!({"event_id": "event-7", "title": "Renamed"}),
)
.await
.unwrap();
assert_eq!(updated["event"]["id"], "event-7");
let deleted = tools
.execute("calendar_delete_event", &json!({"event_id": "event-7"}))
.await
.unwrap();
assert_eq!(deleted["event_id"], "event-7");
}
#[tokio::test]
async fn unknown_tool_falls_through() {
let backend: Arc<dyn CalendarBackend> = MockCalendarBackend::granted();
let error = CalendarTools::with_backend(backend, true)
.execute("mail_inbox", &json!({}))
.await
.unwrap_err();
assert!(error.starts_with("unknown tool"), "{error}");
}
}