//! Event tools: the read surface (`list_events`, `get_event`) and the write //! surface (`create_event`, `bulk_import_events`, `update_event`, `delete_event`). //! //! # Series and instances //! //! A recurring event is stored once, as a series carrying a rule. The instances //! it implies are not rows; `expand_recurrence_in_tz` materializes them on //! demand for a window, with synthetic ids that live only in that reply. //! //! `list_events` therefore has two modes, and `expand` picks between them: //! //! - **off (default)**: one row per stored series. "Weekly standup" is one row //! with `recurrence.display` reading "Every week on Mon". This is the mode for //! entering and auditing data, where 52 identical rows are noise and the thing //! you actually want to know is whether the series already exists. //! - **on**: the calendar laid out, every occurrence in the window as its own //! row marked `is_recurring_instance`. //! //! Series-by-default is the important half. A session filling in a calendar //! needs to see what rules exist, not their consequences. //! //! # Why the window is not just a SQL range //! //! `EventRepository::list_between` matches stored rows overlapping the window, //! which is the wrong answer for a series: a standup that started two years ago //! and still recurs every Monday has no row inside next month, so a plain range //! query reports it absent. Both modes therefore union the range query with //! `list_recurring`, keeping the series whose rule actually reaches the window. //! Without that, a session would ask "do I already have a standup?", be told no, //! and enter a second one. use std::collections::HashSet; use std::sync::Arc; use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; use goingson_core::repository::EventRepository; use goingson_core::tz::system_tz; use goingson_core::{ Event, EventId, NewEvent, ProjectId, TzKind, UpdateEvent, Validate, expand_recurrence_in_tz, snap_all_day_span, }; use kberg::{Error, Result, Tool, ToolCallResult, ToolKind}; use serde_json::{Value, json}; use super::contact::{ContactCache, contact_arg, contact_fields}; use super::{project_id_arg, project_id_field, recurrence_field}; use crate::caps; use crate::context::Ctx; use crate::convert::{ MAX_LIMIT, TZ_KINDS, event_row, event_summary_row, parse_block_type, parse_enum, parse_event_id, parse_instant, parse_limit, parse_offset, parse_project_id, parse_recurrence, parse_reminders, req_str, }; /// Default `external_source` a bulk import stamps on the rows it creates, so a /// re-run can find its own work. Callers doing more than one kind of import can /// pass their own. const DEFAULT_IMPORT_SOURCE: &str = "go-mcp"; /// Window an unparameterized `list_events` covers, forward from now. Long enough /// to answer "what does my next month look like", short enough that expanding a /// daily series over it stays well inside the 500-occurrence expansion cap. const DEFAULT_WINDOW_DAYS: i64 = 30; fn fail(tool: &str, e: impl std::fmt::Display) -> Error { Error::ToolFailed { tool: tool.to_string(), message: e.to_string(), } } /// The "no such event" error, shared by every tool that takes an id. /// /// It names the synthetic-id case: an id lifted from an expanded `list_events` /// row is the single most likely reason a lookup misses, and "not found" alone /// would send a caller hunting for a row that was never supposed to exist. fn not_found(tool: &str, id: EventId) -> Error { Error::ToolFailed { tool: tool.to_string(), message: format!( "no event with id {id} (a synthetic recurrence-instance id will not resolve; \ use the instance's `recurrence_parent_id`)" ), } } pub struct ListEvents(pub Arc); #[async_trait] impl Tool for ListEvents { fn name(&self) -> &'static str { "list_events" } fn description(&self) -> &'static str { "List calendar events in a time window. `from`/`to` are RFC 3339 timestamps or bare YYYY-MM-DD dates (local midnight); they default to now and 30 days out. By default each recurring event appears once, as the stored series, with its rule under `recurrence`. Pass `expand: true` to get every occurrence in the window instead, each marked `is_recurring_instance` with a synthetic id that only `list_events` understands, never `get_event` or the write tools. Optional filters: `project_id`, `project` (name, a convenience for reading only), `recurring_only`. Paged: `limit` (default 50, max 200) and `offset`. Long descriptions are clipped and the row marked `truncated`; use `get_event` for the full text." } fn kind(&self) -> ToolKind { ToolKind::Read } fn small_model_safe(&self) -> bool { true } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "from": { "type": "string", "description": "RFC 3339 timestamp or YYYY-MM-DD (local midnight). Defaults to now." }, "to": { "type": "string", "description": "RFC 3339 timestamp or YYYY-MM-DD (local midnight). Defaults to 30 days after `from`." }, "project_id": { "type": "string", "description": "Project id (UUID) from list_projects." }, "project": { "type": "string", "description": "Project name. Reads only, and a rename moves it; prefer project_id." }, "recurring_only": { "type": "boolean" }, "expand": { "type": "boolean", "description": "Materialize recurring occurrences in the window instead of listing series." }, "limit": { "type": "integer", "minimum": 1, "maximum": MAX_LIMIT }, "offset": { "type": "integer", "minimum": 0 } } }) } async fn call(&self, args: Value) -> Result { let limit = parse_limit(self.name(), args.get("limit"))?; let offset = parse_offset(self.name(), args.get("offset"))?; let expand = args .get("expand") .and_then(Value::as_bool) .unwrap_or_default(); let recurring_only = args .get("recurring_only") .and_then(Value::as_bool) .unwrap_or_default(); let tz = system_tz(); let from = parse_instant(self.name(), "from", args.get("from"))?.unwrap_or_else(Utc::now); let to = parse_instant(self.name(), "to", args.get("to"))? .unwrap_or_else(|| from + Duration::days(DEFAULT_WINDOW_DAYS)); if to < from { return Err(Error::InvalidArgs { tool: self.name().to_string(), message: format!( "`to` ({}) is before `from` ({})", to.to_rfc3339(), from.to_rfc3339() ), }); } let repo = self.0.events(); let mut events = repo .list_between(self.0.user_id, from, to) .map_err(|e| fail(self.name(), e))?; // Series whose stored row already landed in the range query. Pushing one // again below would double it in the reply. let in_range: HashSet = events.iter().map(|e| e.id).collect(); let recurring = repo .list_recurring(self.0.user_id) .map_err(|e| fail(self.name(), e))?; for series in recurring { // The series' own zone, not the reader's: a Local event expands the // same way whoever is looking, and only a Relative one follows `tz`. let instances = expand_recurrence_in_tz(&series, from, to, series.tz_for(tz)); let parent_in_window = parent_overlaps(&series, from, to); if expand { events.extend(instances); if parent_in_window && !in_range.contains(&series.id) { events.push(series); } } else if (parent_in_window || !instances.is_empty()) && !in_range.contains(&series.id) { // The series reaches the window even though its own row sits // outside it. This is the case a plain range query loses. events.push(series); } } events.sort_by_key(|e| e.start_time); let project = args.get("project").and_then(Value::as_str); let project_id = match args.get("project_id").and_then(Value::as_str) { Some(raw) if !raw.trim().is_empty() => Some(parse_project_id(self.name(), raw)?), _ => None, }; let matched: Vec<&Event> = events .iter() .filter(|e| project_id.is_none_or(|want| e.project_id == Some(want))) .filter(|e| project.is_none_or(|p| e.project_name.as_deref() == Some(p))) .filter(|e| !recurring_only || e.has_recurrence()) .collect(); let total = matched.len(); let rows: Vec = matched .into_iter() .skip(offset) .take(limit) .map(|e| event_summary_row(e, tz)) .collect(); let mut reply = json!({ "count": rows.len(), "total": total, "offset": offset, "from": from.to_rfc3339(), "to": to.to_rfc3339(), "expanded": expand, "events": rows, }); // Only present when a page remains, so its absence is the stop condition. let next = offset.saturating_add(rows_len(&reply)); if next < total { reply["next_offset"] = json!(next); } Ok(ToolCallResult::text(serde_json::to_string(&reply).unwrap())) } } /// Row count of a built reply, for the paging cursor. fn rows_len(reply: &Value) -> usize { reply["count"].as_u64().unwrap_or_default() as usize } /// Whether a series' own stored occurrence overlaps the window. An event with no /// end time is treated as an hour long, matching the expansion logic's default. fn parent_overlaps(e: &Event, from: chrono::DateTime, to: chrono::DateTime) -> bool { let effective_end = e.end_time.unwrap_or(e.start_time + Duration::hours(1)); effective_end >= from && e.start_time <= to } pub struct GetEvent(pub Arc); #[async_trait] impl Tool for GetEvent { fn name(&self) -> &'static str { "get_event" } fn description(&self) -> &'static str { "Fetch one event by id, with its full description and recurrence rule. Takes a stored event id: the synthetic ids on `list_events` expanded instances are not rows and will not resolve, use their `recurrence_parent_id` instead." } fn kind(&self) -> ToolKind { ToolKind::Read } fn small_model_safe(&self) -> bool { true } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] }) } async fn call(&self, args: Value) -> Result { let id = parse_event_id(self.name(), req_str(self.name(), &args, "id")?)?; let event = self .0 .events() .get_by_id(id, self.0.user_id) .map_err(|e| fail(self.name(), e))? .ok_or_else(|| not_found(self.name(), id))?; let row = event_row(&event, system_tz()); Ok(ToolCallResult::text(serde_json::to_string(&row).unwrap())) } } // writes /// Resolve the start/end pair a write tool was given. /// /// All-day canonicalization runs here, through the same `snap_all_day_span` the /// desktop command layer uses, so an event authored over MCP has the shape /// (`local midnight`, whole days) that `Event::is_all_day_in` detects. Authoring /// it any other way would produce a row the app renders as a timed event. fn resolve_span(tool: &str, args: &Value) -> Result<(DateTime, Option>)> { let start = parse_instant(tool, "start", args.get("start"))?.ok_or_else(|| Error::InvalidArgs { tool: tool.to_string(), message: "missing `start` (RFC 3339 timestamp or YYYY-MM-DD)".to_string(), })?; let end = parse_instant(tool, "end", args.get("end"))?; let all_day = args .get("all_day") .and_then(Value::as_bool) .unwrap_or_default(); finish_span(tool, start, end, all_day) } /// Shared tail of [`resolve_span`] and [`overlay_span`]: snap when all-day, /// otherwise hold the caller to `end > start`. fn finish_span( tool: &str, start: DateTime, end: Option>, all_day: bool, ) -> Result<(DateTime, Option>)> { if all_day { let (start, end) = snap_all_day_span(start, end, &system_tz()); return Ok((start, Some(end))); } if let Some(end) = end && end <= start { return Err(Error::InvalidArgs { tool: tool.to_string(), message: format!( "`end` ({}) must be after `start` ({})", end.to_rfc3339(), start.to_rfc3339() ), }); } Ok((start, end)) } /// Read `tz_kind` (and the `timezone` it may require) off one wire object. /// /// A `local` event without a zone name is refused rather than quietly demoted: /// the whole point of the kind is that the zone is recorded, and falling back to /// the writer's zone would bake the headless peer's environment into the row. fn parse_tz_kind(tool: &str, item: &Value) -> Result<(TzKind, Option)> { let kind = match item.get("tz_kind").and_then(Value::as_str) { None => TzKind::Absolute, Some(raw) => parse_enum(tool, "tz_kind", raw, TZ_KINDS)?, }; let timezone = item .get("timezone") .and_then(Value::as_str) .map(str::trim) .filter(|s| !s.is_empty()); if kind == TzKind::Local { let name = timezone.ok_or_else(|| Error::InvalidArgs { tool: tool.to_string(), message: "`tz_kind: local` requires an IANA `timezone` (e.g. America/Denver)".into(), })?; // Reject an unknown name here rather than letting `event_tz` silently // fall back to the reader's zone every time the row is read. name.parse::() .map_err(|_| Error::InvalidArgs { tool: tool.to_string(), message: format!("`{name}` is not a known IANA timezone"), })?; return Ok((kind, Some(name.to_string()))); } Ok((kind, None)) } /// Build a validated [`NewEvent`] from one wire object, shared by `create_event` /// and each item of `bulk_import_events`. /// /// `linked_task_id` is always `None`: a time-block's task link is set by the /// app when it blocks time, never by a caller filling in a calendar. async fn new_event_from( ctx: &Ctx, tool: &str, item: &Value, seen_projects: &mut HashSet, contacts: &mut ContactCache, ) -> Result { let title = req_str(tool, item, "title")?.to_string(); let (start_time, end_time) = resolve_span(tool, item)?; let (recurrence, recurrence_rule) = parse_recurrence(tool, item.get("recurrence"))?; let block_type = parse_block_type(tool, item.get("block_type"))?; let (tz_kind, timezone) = parse_tz_kind(tool, item)?; let project_id = project_id_arg(ctx, tool, item, seen_projects).await?; let contact_id = contact_arg(ctx, tool, item, contacts).await?; let mut event = NewEvent { user_id: Some(ctx.user_id), project_id, contact_id, title, description: item .get("description") .and_then(Value::as_str) .unwrap_or_default() .to_string(), start_time, end_time, location: item .get("location") .and_then(Value::as_str) .filter(|l| !l.trim().is_empty()) .map(str::to_string), linked_task_id: None, recurrence, recurrence_rule, block_type, reminder_offsets_seconds: parse_reminders(item.get("reminders")), tz_kind, timezone, // Derived from the resolved instants below rather than taken from the // wire: a session sends one time, not a UTC/civil pair it has to keep // consistent itself. start_local: None, end_local: None, }; if tz_kind.is_civil() { let tz = goingson_core::tz::event_tz(tz_kind, event.timezone.as_deref(), system_tz()); event.start_local = Some(start_time.with_timezone(&tz).naive_local()); event.end_local = end_time.map(|e| e.with_timezone(&tz).naive_local()); } // The same validation the desktop command layer runs. go-mcp is a peer // writer, not a back door: it must not be able to store a row the app // itself would have refused. event.validate().map_err(|e| Error::InvalidArgs { tool: tool.to_string(), message: e.to_string(), })?; Ok(event) } /// The JSON schema fragment shared by `create_event` and each import item. fn event_fields() -> Value { let mut fields = json!({ "title": { "type": "string" }, "start": { "type": "string", "description": "RFC 3339 timestamp or YYYY-MM-DD (local midnight)." }, "end": { "type": "string" }, "all_day": { "type": "boolean", "description": "Snap the span to whole local days." }, "description": { "type": "string" }, "project_id": project_id_field(), "location": { "type": "string" }, "recurrence": recurrence_field(), "tz_kind": { "type": "string", "description": "relative (a wall clock that follows the user, e.g. a daily routine) | local (a wall clock anchored to `timezone`, correct across that zone's DST) | absolute (a fixed instant). Defaults to absolute." }, "timezone": { "type": "string", "description": "IANA zone name, required when tz_kind is `local` (e.g. America/Denver)." }, "block_type": { "type": "string", "description": "free_time | personal | vacation | focus" }, "reminders": { "type": "array", "items": { "type": "integer" }, "description": "Seconds before start to fire a reminder, e.g. [0, 900]. Max 8." } }); // Merged rather than inlined so the contact wording lives once, next to the // resolution it describes. for (key, value) in contact_fields().as_object().expect("object literal") { fields[key] = value.clone(); } fields } pub struct CreateEvent(pub Arc); #[async_trait] impl Tool for CreateEvent { fn name(&self) -> &'static str { "create_event" } fn description(&self) -> &'static str { "Create one calendar event. `title` and `start` are required; `start`/`end` take RFC 3339 or YYYY-MM-DD (local midnight). Pass `all_day: true` to snap the span to whole local days. `project_id` (from list_projects) files it under a project; omit it for an unfiled event. `contact_id` (from list_contacts) or `contact` (a name) attaches it to a person; an ambiguous name is refused rather than guessed, and no contact is created. `recurrence` is a word (None|Daily|Weekly|Monthly) or a rich rule object; a recurring event is stored once as a series, not as one row per occurrence. Use `bulk_import_events` for more than a few." } fn kind(&self) -> ToolKind { ToolKind::Write(caps::event_create()) } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": event_fields(), "required": ["title", "start"] }) } async fn call(&self, args: Value) -> Result { let mut seen = HashSet::new(); let mut contacts = ContactCache::default(); let event = new_event_from(&self.0, self.name(), &args, &mut seen, &mut contacts).await?; let created = self .0 .events() .create(self.0.user_id, event) .map_err(|e| fail(self.name(), e))?; Ok(ToolCallResult::text( serde_json::to_string(&json!({ "id": created.id.to_string() })).unwrap(), )) } } pub struct BulkImportEvents(pub Arc); #[async_trait] impl Tool for BulkImportEvents { fn name(&self) -> &'static str { "bulk_import_events" } fn description(&self) -> &'static str { "Create many calendar events in one call. Each item takes the same fields as `create_event`, plus `source_ref`, a stable provenance key that defaults to the title. An item whose `(source, source_ref)` pair already exists is skipped, not rewritten, so re-running an import is safe and never clobbers an edit made in the app since; change one with `update_event`. `source` names the import (default `go-mcp`). Returns created/skipped counts and the new event ids." } fn kind(&self) -> ToolKind { ToolKind::Write(caps::event_bulk_import()) } fn input_schema(&self) -> Value { let mut item_fields = event_fields(); item_fields["source_ref"] = json!({ "type": "string", "description": "Stable idempotency key for this event. Defaults to the title." }); json!({ "type": "object", "properties": { "events": { "type": "array", "items": { "type": "object", "properties": item_fields, "required": ["title", "start"] } }, "source": { "type": "string", "description": "Names the import; default `go-mcp`." } }, "required": ["events"] }) } async fn call(&self, args: Value) -> Result { let items = args .get("events") .and_then(Value::as_array) .ok_or_else(|| Error::InvalidArgs { tool: self.name().to_string(), message: "missing array field `events`".into(), })?; let source = args .get("source") .and_then(Value::as_str) .map(str::trim) .filter(|s| !s.is_empty()) .unwrap_or(DEFAULT_IMPORT_SOURCE); let repo = self.0.events(); let mut seen_projects: HashSet = HashSet::new(); let mut contacts = ContactCache::default(); let mut seen: HashSet = HashSet::new(); let mut created_ids = Vec::new(); let mut skipped = 0usize; for (idx, item) in items.iter().enumerate() { // Resolved before the event is built, so a duplicate costs nothing // and cannot create a project as a side effect of being skipped. let source_ref = item .get("source_ref") .or_else(|| item.get("title")) .and_then(Value::as_str) .map(str::trim) .filter(|s| !s.is_empty()) .map(str::to_string); if let Some(key) = &source_ref { if !seen.insert(key.clone()) { skipped += 1; continue; } let existing = repo .find_by_external_id(source, key, self.0.user_id) .map_err(|e| fail(self.name(), e))?; if existing.is_some() { skipped += 1; continue; } } let event = match new_event_from( &self.0, self.name(), item, &mut seen_projects, &mut contacts, ) .await { Ok(event) => event, // Report the offending item by index; a 200-item payload with // one bad date is otherwise a guessing game. Err(Error::InvalidArgs { message, .. }) => { return Err(Error::InvalidArgs { tool: self.name().to_string(), message: format!("events[{idx}]: {message}"), }); } Err(other) => return Err(other), }; let created = repo .create(self.0.user_id, event) .map_err(|e| fail(self.name(), e))?; if let Some(key) = &source_ref { repo.set_external_ref(created.id, self.0.user_id, source, key) .map_err(|e| fail(self.name(), e))?; } created_ids.push(created.id.to_string()); } Ok(ToolCallResult::text( serde_json::to_string(&json!({ "created": created_ids.len(), "skipped": skipped, "source": source, "event_ids": created_ids, })) .unwrap(), )) } } /// Build a full [`UpdateEvent`] mirroring an event's current state, so a caller /// can overlay just the fields it wants to change. fn update_from_event(e: &Event) -> UpdateEvent { UpdateEvent { project_id: e.project_id, contact_id: e.contact_id, title: e.title.clone(), description: e.description.clone(), start_time: e.start_time, end_time: e.end_time, location: e.location.clone(), linked_task_id: e.linked_task_id, recurrence: e.recurrence.clone(), recurrence_rule: e.recurrence_rule.clone(), block_type: e.block_type.clone(), reminder_offsets_seconds: e.reminder_offsets_seconds.clone(), tz_kind: e.tz_kind, timezone: e.timezone.clone(), start_local: e.start_local, end_local: e.end_local, } } /// Resolve the span of an update, merging what the caller passed over what the /// event already has. /// /// A `start` with no `end` is a *move*, so the end shifts with it and the event /// keeps its duration. Carrying the stored end over unchanged would invert the /// span the moment a caller moved an event past its own end, which the desktop /// form never does (it always posts both) but a field-at-a-time overlay will. /// /// `all_day` defaults to whether the event currently *is* all-day, so moving one /// to another date keeps it all-day rather than silently collapsing it to a /// midnight-to-midnight timed event. `snap_all_day_span` is idempotent, so /// re-snapping an already-snapped span does not grow it by a day. fn overlay_span( tool: &str, args: &Value, current: &Event, ) -> Result<(DateTime, Option>)> { let start = parse_instant(tool, "start", args.get("start"))?.unwrap_or(current.start_time); let end = match args.get("end") { // Present but empty or null clears the end time; absent moves it. Some(value) => parse_instant(tool, "end", Some(value))?, None => current .end_time .map(|end| end + (start - current.start_time)), }; let all_day = args .get("all_day") .and_then(Value::as_bool) .unwrap_or_else(|| current.is_all_day_in(¤t.tz_for(system_tz()))); finish_span(tool, start, end, all_day) } pub struct UpdateEventTool(pub Arc); #[async_trait] impl Tool for UpdateEventTool { fn name(&self) -> &'static str { "update_event" } fn description(&self) -> &'static str { "Update fields of an existing event. Only the fields you pass change; the rest keep their current values. Accepts the same fields as `create_event`; passing `project_id` or `contact_id` empty detaches the project or the person. Passing `recurrence` rewrites the rule for the whole series, not one occurrence: an expanded instance is not a row and cannot be edited on its own. Events synced from an external calendar are read-only and are refused." } fn kind(&self) -> ToolKind { ToolKind::Write(caps::event_update()) } fn input_schema(&self) -> Value { let mut fields = event_fields(); fields["id"] = json!({ "type": "string" }); json!({ "type": "object", "properties": fields, "required": ["id"] }) } async fn call(&self, args: Value) -> Result { let id = parse_event_id(self.name(), req_str(self.name(), &args, "id")?)?; let repo = self.0.events(); let current = repo .get_by_id(id, self.0.user_id) .map_err(|e| fail(self.name(), e))? .ok_or_else(|| not_found(self.name(), id))?; if current.is_read_only { return Err(Error::ToolFailed { tool: self.name().to_string(), message: format!( "event {id} is read-only (synced from `{}`); edit it in the source calendar", current .external_source .as_deref() .unwrap_or("an external calendar") ), }); } let mut patch = update_from_event(¤t); if let Some(title) = args.get("title").and_then(Value::as_str) { patch.title = title.to_string(); } if let Some(description) = args.get("description").and_then(Value::as_str) { patch.description = description.to_string(); } if args.get("location").is_some() { patch.location = args .get("location") .and_then(Value::as_str) .filter(|l| !l.trim().is_empty()) .map(str::to_string); } if args.get("recurrence").is_some() { let (recurrence, rule) = parse_recurrence(self.name(), args.get("recurrence"))?; patch.recurrence = recurrence; patch.recurrence_rule = rule; } if args.get("block_type").is_some() { patch.block_type = parse_block_type(self.name(), args.get("block_type"))?; } if args.get("reminders").is_some() { patch.reminder_offsets_seconds = parse_reminders(args.get("reminders")); } // Present-but-empty (or null) clears the project, matching update_task. if args.get("project_id").is_some() { let mut seen = HashSet::new(); patch.project_id = project_id_arg(&self.0, self.name(), &args, &mut seen).await?; } // Same rule for the person: present-but-empty detaches. Either field // being present is the signal, since `contact` is the other way to say // who this is. if args.get("contact_id").is_some() || args.get("contact").is_some() { let mut contacts = ContactCache::default(); patch.contact_id = contact_arg(&self.0, self.name(), &args, &mut contacts).await?; } let (start_time, end_time) = overlay_span(self.name(), &args, ¤t)?; patch.start_time = start_time; patch.end_time = end_time; patch.validate().map_err(|e| Error::InvalidArgs { tool: self.name().to_string(), message: e.to_string(), })?; let updated = repo .update(id, self.0.user_id, patch) .map_err(|e| fail(self.name(), e))? .ok_or_else(|| not_found(self.name(), id))?; Ok(ToolCallResult::text( serde_json::to_string(&event_row(&updated, system_tz())).unwrap(), )) } } pub struct DeleteEvent(pub Arc); #[async_trait] impl Tool for DeleteEvent { fn name(&self) -> &'static str { "delete_event" } fn description(&self) -> &'static str { "Delete one event by id. Deleting a recurring series deletes every occurrence it implies, since the occurrences are not rows. There is no undo and no archive; prefer `update_event` unless the event should not exist at all." } fn kind(&self) -> ToolKind { ToolKind::Write(caps::event_delete()) } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] }) } async fn call(&self, args: Value) -> Result { let id = parse_event_id(self.name(), req_str(self.name(), &args, "id")?)?; let repo = self.0.events(); // Read first, so the reply can name what went and a synthetic id is // refused with the same message `get_event` gives. let current = repo .get_by_id(id, self.0.user_id) .map_err(|e| fail(self.name(), e))? .ok_or_else(|| not_found(self.name(), id))?; let deleted = repo .delete(id, self.0.user_id) .map_err(|e| fail(self.name(), e))?; Ok(ToolCallResult::text( serde_json::to_string(&json!({ "deleted": deleted, "title": current.title, "was_recurring": current.has_recurrence(), })) .unwrap(), )) } }