use super::traits::{Tool, ToolResult}; use crate::config::Config; use crate::cron; use async_trait::async_trait; use serde::Serialize; use serde_json::json; use std::sync::Arc; const MAX_RUN_OUTPUT_CHARS: usize = 500; pub struct CronRunsTool { config: Arc, } impl CronRunsTool { pub fn new(config: Arc) -> Self { Self { config } } } #[derive(Serialize)] struct RunView { id: i64, job_id: String, started_at: chrono::DateTime, finished_at: chrono::DateTime, status: String, output: Option, duration_ms: Option, } #[async_trait] impl Tool for CronRunsTool { fn name(&self) -> &str { "cron_runs" } fn description(&self) -> &str { "List recent run history for a cron job" } fn parameters_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "job_id": { "type": "string" }, "limit": { "type": "integer" } }, "required": ["job_id"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { if !self.config.cron.enabled { return Ok(ToolResult { success: false, output: String::new(), error: Some("cron is disabled by config (cron.enabled=false)".to_string()), }); } let job_id = match args.get("job_id").and_then(serde_json::Value::as_str) { Some(v) if !v.trim().is_empty() => v, _ => { return Ok(ToolResult { success: false, output: String::new(), error: Some("Missing 'job_id' parameter".to_string()), }); } }; let limit = args .get("limit") .and_then(serde_json::Value::as_u64) .map_or(10, |v| usize::try_from(v).unwrap_or(10)); match cron::list_runs(&self.config, job_id, limit) { Ok(runs) => { let runs: Vec = runs .into_iter() .map(|run| RunView { id: run.id, job_id: run.job_id, started_at: run.started_at, finished_at: run.finished_at, status: run.status, output: run.output.map(|out| truncate(&out, MAX_RUN_OUTPUT_CHARS)), duration_ms: run.duration_ms, }) .collect(); Ok(ToolResult { success: true, output: serde_json::to_string_pretty(&runs)?, error: None, }) } Err(e) => Ok(ToolResult { success: false, output: String::new(), error: Some(e.to_string()), }), } } } fn truncate(input: &str, max_chars: usize) -> String { if input.chars().count() <= max_chars { return input.to_string(); } let mut out: String = input.chars().take(max_chars).collect(); out.push_str("..."); out } #[cfg(test)] mod tests { use super::*; use crate::config::Config; use chrono::{Duration as ChronoDuration, Utc}; use tempfile::TempDir; async fn test_config(tmp: &TempDir) -> Arc { let config = Config { workspace_dir: tmp.path().join("workspace"), config_path: tmp.path().join("config.toml"), ..Config::default() }; tokio::fs::create_dir_all(&config.workspace_dir) .await .unwrap(); Arc::new(config) } #[tokio::test] async fn lists_runs_with_truncation() { let tmp = TempDir::new().unwrap(); let cfg = test_config(&tmp).await; let job = cron::add_job(&cfg, "*/5 * * * *", "echo ok").unwrap(); let long_output = "x".repeat(1000); let now = Utc::now(); cron::record_run( &cfg, &job.id, now, now + ChronoDuration::milliseconds(1), "ok", Some(&long_output), 1, ) .unwrap(); let tool = CronRunsTool::new(cfg.clone()); let result = tool .execute(json!({ "job_id": job.id, "limit": 5 })) .await .unwrap(); assert!(result.success); assert!(result.output.contains("...")); } #[tokio::test] async fn errors_when_job_id_missing() { let tmp = TempDir::new().unwrap(); let cfg = test_config(&tmp).await; let tool = CronRunsTool::new(cfg); let result = tool.execute(json!({})).await.unwrap(); assert!(!result.success); assert!(result .error .unwrap_or_default() .contains("Missing 'job_id'")); } }