1use async_trait::async_trait;
17use std::sync::Arc;
18
19use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
20use serde_json::{Value, json};
21
22use crate::kernel_handle::KernelHandle;
23use oxios_calendar::{CalendarEngine, EventDraft, EventPatch, Repeat};
24
25pub struct CalendarTool {
42 engine: Arc<CalendarEngine>,
43}
44
45impl CalendarTool {
46 pub fn try_from_kernel(kernel: &KernelHandle) -> Option<Self> {
50 kernel.calendar.as_ref().map(|api| Self {
51 engine: api.engine.clone(),
52 })
53 }
54
55 pub fn from_kernel(kernel: &KernelHandle) -> Self {
60 Self::try_from_kernel(kernel).expect("CalendarTool requires calendar to be configured")
61 }
62}
63
64impl std::fmt::Debug for CalendarTool {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 f.debug_struct("CalendarTool").finish()
67 }
68}
69
70fn parse_dt(s: &str) -> Result<chrono::DateTime<chrono::Utc>, String> {
72 chrono::DateTime::parse_from_rfc3339(s)
73 .map(|dt| dt.with_timezone(&chrono::Utc))
74 .map_err(|e| {
75 format!(
76 "Invalid datetime '{s}': {e}. Use ISO 8601 / RFC 3339 format, e.g. \"2026-06-07T09:00:00Z\""
77 )
78 })
79}
80
81fn opt_str<'a>(params: &'a Value, key: &str) -> Option<&'a str> {
83 params.get(key).and_then(|v| v.as_str())
84}
85
86fn opt_bool(params: &Value, key: &str) -> Option<bool> {
88 params.get(key).and_then(|v| v.as_bool())
89}
90
91fn opt_reminder_minutes(params: &Value) -> Option<Vec<u32>> {
93 params
94 .get("reminder_minutes")
95 .and_then(|v| v.as_array())
96 .map(|arr| {
97 arr.iter()
98 .filter_map(|v| v.as_u64().map(|n| n as u32))
99 .collect()
100 })
101}
102
103fn opt_repeat(params: &Value) -> Option<Repeat> {
105 let obj = params.get("repeat")?.as_object()?;
106 let frequency = obj
107 .get("frequency")
108 .and_then(|v| v.as_str())
109 .unwrap_or("daily")
110 .to_string();
111 let interval = obj.get("interval").and_then(|v| v.as_u64()).unwrap_or(1) as u32;
112 let days = obj
113 .get("days")
114 .and_then(|v| v.as_array())
115 .map(|arr| {
116 arr.iter()
117 .filter_map(|v| v.as_str().map(|s| s.to_string()))
118 .collect()
119 })
120 .unwrap_or_default();
121 let until = obj
122 .get("until")
123 .and_then(|v| v.as_str())
124 .map(|s| s.to_string());
125 let count = obj.get("count").and_then(|v| v.as_u64()).map(|v| v as u32);
126
127 Some(Repeat {
128 frequency,
129 days,
130 interval,
131 until,
132 count,
133 })
134}
135
136#[async_trait]
137
138impl AgentTool for CalendarTool {
139 fn name(&self) -> &str {
140 "calendar"
141 }
142
143 fn label(&self) -> &str {
144 "Calendar"
145 }
146
147 fn description(&self) -> &'static str {
148 "Manage calendar events — create, update, delete, list, search, freebusy. \
149 All datetimes use ISO 8601 / RFC 3339 format (e.g. \"2026-06-07T09:00:00Z\")."
150 }
151
152 fn parameters_schema(&self) -> Value {
153 json!({
154 "type": "object",
155 "properties": {
156 "op": {
157 "type": "string",
158 "enum": ["create", "update", "delete", "list", "get", "search", "freebusy"],
159 "description": "Calendar operation to perform"
160 },
161 "title": {
162 "type": "string",
163 "description": "Event title (required for create, optional for update)"
164 },
165 "start": {
166 "type": "string",
167 "description": "Event start time (ISO 8601). Required for create, optional for update."
168 },
169 "end": {
170 "type": "string",
171 "description": "Event end time (ISO 8601). Required for create, optional for update."
172 },
173 "all_day": {
174 "type": "boolean",
175 "description": "Whether this is an all-day event"
176 },
177 "description": {
178 "type": "string",
179 "description": "Event description / notes"
180 },
181 "location": {
182 "type": "string",
183 "description": "Event location"
184 },
185 "repeat": {
186 "type": "object",
187 "description": "Recurrence rule",
188 "properties": {
189 "frequency": {
190 "type": "string",
191 "enum": ["daily", "weekly", "monthly", "yearly"],
192 "description": "Recurrence frequency"
193 },
194 "interval": {
195 "type": "integer",
196 "description": "Recurrence interval (default: 1)"
197 },
198 "days": {
199 "type": "array",
200 "items": { "type": "string" },
201 "description": "For weekly: ['mon','wed','fri']"
202 },
203 "count": {
204 "type": "integer",
205 "description": "Max number of occurrences"
206 },
207 "until": {
208 "type": "string",
209 "description": "End date for recurrence (ISO date, e.g. '2026-12-31')"
210 }
211 }
212 },
213 "reminder_minutes": {
214 "type": "array",
215 "items": { "type": "integer" },
216 "description": "Minutes before event to trigger reminders, e.g. [5, 15, 60]"
217 },
218 "uid": {
219 "type": "string",
220 "description": "Event UID (required for update, delete, get)"
221 },
222 "from": {
223 "type": "string",
224 "description": "Range start time for list/freebusy (ISO 8601)"
225 },
226 "to": {
227 "type": "string",
228 "description": "Range end time for list/freebusy (ISO 8601)"
229 },
230 "query": {
231 "type": "string",
232 "description": "Search query for event title/description (search op)"
233 }
234 },
235 "required": ["op"]
236 })
237 }
238
239 async fn execute(
240 &self,
241 _tool_call_id: &str,
242 params: Value,
243 _signal: Option<tokio::sync::oneshot::Receiver<()>>,
244 _ctx: &ToolContext,
245 ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
246 let op = params
247 .get("op")
248 .and_then(|v| v.as_str())
249 .ok_or_else(|| "Missing required parameter: op".to_string())?;
250
251 match op {
252 "create" => self.exec_create(¶ms).await,
253 "update" => self.exec_update(¶ms).await,
254 "delete" => self.exec_delete(¶ms).await,
255 "list" => self.exec_list(¶ms).await,
256 "get" => self.exec_get(¶ms).await,
257 "search" => self.exec_search(¶ms).await,
258 "freebusy" => self.exec_freebusy(¶ms).await,
259 other => Err(format!(
260 "Unknown calendar op '{other}'. Valid: create, update, delete, list, get, search, freebusy"
261 )),
262 }
263 }
264}
265
266impl CalendarTool {
267 async fn exec_create(&self, params: &Value) -> Result<AgentToolResult, String> {
268 let title = opt_str(params, "title")
269 .ok_or_else(|| "create requires 'title' parameter".to_string())?;
270 let start = opt_str(params, "start")
271 .ok_or_else(|| "create requires 'start' parameter".to_string())?;
272 let end =
273 opt_str(params, "end").ok_or_else(|| "create requires 'end' parameter".to_string())?;
274
275 let start_dt = parse_dt(start)?;
276 let end_dt = parse_dt(end)?;
277
278 let draft = EventDraft {
279 title: title.to_string(),
280 start: start_dt,
281 end: end_dt,
282 all_day: opt_bool(params, "all_day").unwrap_or(false),
283 description: opt_str(params, "description").map(|s| s.to_string()),
284 location: opt_str(params, "location").map(|s| s.to_string()),
285 repeat: opt_repeat(params),
286 reminder_minutes: opt_reminder_minutes(params).unwrap_or_default(),
287 source: oxios_calendar::EventSource::Agent,
288 note_path: opt_str(params, "note_path").map(|s| s.to_string()),
289 };
290
291 match self.engine.create(draft).await {
292 Ok(result) => Ok(AgentToolResult::success(
293 serde_json::to_string_pretty(&json!({
294 "uid": result.uid,
295 "status": "created",
296 "conflicts": result.conflicts,
297 "file": result.file,
298 }))
299 .unwrap_or_default(),
300 )),
301 Err(e) => Ok(AgentToolResult::error(format!(
302 "Failed to create event: {e}"
303 ))),
304 }
305 }
306
307 async fn exec_update(&self, params: &Value) -> Result<AgentToolResult, String> {
308 let uid =
309 opt_str(params, "uid").ok_or_else(|| "update requires 'uid' parameter".to_string())?;
310
311 let patch = EventPatch {
312 title: opt_str(params, "title").map(|s| s.to_string()),
313 start: opt_str(params, "start").and_then(|s| parse_dt(s).ok()),
314 end: opt_str(params, "end").and_then(|s| parse_dt(s).ok()),
315 all_day: opt_bool(params, "all_day"),
316 description: opt_str(params, "description").map(|s| Some(s.to_string())),
317 location: opt_str(params, "location").map(|s| Some(s.to_string())),
318 repeat: opt_repeat(params).map(Some),
319 note_path: opt_str(params, "note_path").map(|s| Some(s.to_string())),
320 reminder_minutes: opt_reminder_minutes(params),
321 };
322
323 match self.engine.update(uid, patch).await {
324 Ok(result) => Ok(AgentToolResult::success(
325 serde_json::to_string_pretty(&json!({
326 "uid": result.uid,
327 "status": "updated",
328 "conflicts": result.conflicts,
329 }))
330 .unwrap_or_default(),
331 )),
332 Err(e) => Ok(AgentToolResult::error(format!(
333 "Failed to update event: {e}"
334 ))),
335 }
336 }
337
338 async fn exec_delete(&self, params: &Value) -> Result<AgentToolResult, String> {
339 let uid =
340 opt_str(params, "uid").ok_or_else(|| "delete requires 'uid' parameter".to_string())?;
341
342 match self.engine.delete(uid).await {
343 Ok(()) => Ok(AgentToolResult::success(format!("Event '{uid}' deleted."))),
344 Err(e) => Ok(AgentToolResult::error(format!(
345 "Failed to delete event: {e}"
346 ))),
347 }
348 }
349
350 async fn exec_list(&self, params: &Value) -> Result<AgentToolResult, String> {
351 let from =
352 opt_str(params, "from").ok_or_else(|| "list requires 'from' parameter".to_string())?;
353 let to = opt_str(params, "to").ok_or_else(|| "list requires 'to' parameter".to_string())?;
354
355 let from_dt = parse_dt(from)?;
356 let to_dt = parse_dt(to)?;
357
358 match self.engine.list(from_dt, to_dt).await {
359 Ok(events) => {
360 if events.is_empty() {
361 return Ok(AgentToolResult::success("No events in the given range."));
362 }
363 let display: Vec<Value> = events
364 .iter()
365 .map(|e| {
366 json!({
367 "uid": e.uid,
368 "title": e.title,
369 "start": e.start.to_rfc3339(),
370 "end": e.end.to_rfc3339(),
371 "status": e.status,
372 })
373 })
374 .collect();
375 Ok(AgentToolResult::success(
376 serde_json::to_string_pretty(&json!({
377 "events": display,
378 "count": display.len(),
379 }))
380 .unwrap_or_default(),
381 ))
382 }
383 Err(e) => Ok(AgentToolResult::error(format!(
384 "Failed to list events: {e}"
385 ))),
386 }
387 }
388
389 async fn exec_get(&self, params: &Value) -> Result<AgentToolResult, String> {
390 let uid =
391 opt_str(params, "uid").ok_or_else(|| "get requires 'uid' parameter".to_string())?;
392
393 match self.engine.get(uid).await {
394 Ok(event) => Ok(AgentToolResult::success(
395 serde_json::to_string_pretty(&json!({
396 "uid": event.uid,
397 "title": event.title,
398 "start": event.start.to_rfc3339(),
399 "end": event.end.to_rfc3339(),
400 "all_day": event.all_day,
401 "description": event.description,
402 "location": event.location,
403 "rrule": event.rrule,
404 "status": event.status,
405 }))
406 .unwrap_or_default(),
407 )),
408 Err(e) => Ok(AgentToolResult::error(format!("Failed to get event: {e}"))),
409 }
410 }
411
412 async fn exec_search(&self, params: &Value) -> Result<AgentToolResult, String> {
413 let query = opt_str(params, "query")
414 .ok_or_else(|| "search requires 'query' parameter".to_string())?;
415
416 match self.engine.search(query).await {
417 Ok(events) => {
418 if events.is_empty() {
419 return Ok(AgentToolResult::success(format!(
420 "No events matching '{query}'."
421 )));
422 }
423 let display: Vec<Value> = events
424 .iter()
425 .map(|e| {
426 json!({
427 "uid": e.uid,
428 "title": e.title,
429 "start": e.start.to_rfc3339(),
430 "end": e.end.to_rfc3339(),
431 })
432 })
433 .collect();
434 Ok(AgentToolResult::success(
435 serde_json::to_string_pretty(&json!({
436 "events": display,
437 "count": display.len(),
438 "query": query,
439 }))
440 .unwrap_or_default(),
441 ))
442 }
443 Err(e) => Ok(AgentToolResult::error(format!(
444 "Failed to search events: {e}"
445 ))),
446 }
447 }
448
449 async fn exec_freebusy(&self, params: &Value) -> Result<AgentToolResult, String> {
450 let from = opt_str(params, "from")
451 .ok_or_else(|| "freebusy requires 'from' parameter".to_string())?;
452 let to =
453 opt_str(params, "to").ok_or_else(|| "freebusy requires 'to' parameter".to_string())?;
454
455 let from_dt = parse_dt(from)?;
456 let to_dt = parse_dt(to)?;
457
458 match self.engine.freebusy(from_dt, to_dt).await {
459 Ok(slots) => Ok(AgentToolResult::success(
460 serde_json::to_string_pretty(&json!({
461 "slots": slots,
462 "count": slots.len(),
463 }))
464 .unwrap_or_default(),
465 )),
466 Err(e) => Ok(AgentToolResult::error(format!(
467 "Failed to compute freebusy: {e}"
468 ))),
469 }
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use chrono::Datelike;
477
478 #[test]
479 fn test_parse_dt_valid() {
480 let dt = parse_dt("2026-06-07T09:00:00Z").unwrap();
481 assert_eq!(dt.year(), 2026);
482 assert_eq!(dt.month(), 6);
483 assert_eq!(dt.day(), 7);
484 }
485
486 #[test]
487 fn test_parse_dt_invalid() {
488 assert!(parse_dt("not-a-date").is_err());
489 }
490
491 #[test]
492 fn test_opt_repeat_basic() {
493 let params = json!({
494 "repeat": {
495 "frequency": "weekly",
496 "days": ["mon", "wed"],
497 "interval": 2,
498 "count": 10
499 }
500 });
501 let rule = opt_repeat(¶ms).unwrap();
502 assert_eq!(rule.frequency, "weekly");
503 assert_eq!(rule.days, vec!["mon", "wed"]);
504 assert_eq!(rule.interval, 2);
505 assert_eq!(rule.count, Some(10));
506 assert!(rule.until.is_none());
507 }
508}