opendev_tools_impl/
schedule.rs1use std::collections::HashMap;
4use std::path::PathBuf;
5
6use chrono::{DateTime, Utc};
7use opendev_tools_core::{BaseTool, ToolContext, ToolDisplayMeta, ToolResult};
8
9#[derive(Debug)]
11pub struct ScheduleTool;
12
13impl ScheduleTool {
14 fn schedules_path() -> Option<PathBuf> {
15 dirs::home_dir().map(|h| h.join(".opendev").join("schedules.json"))
16 }
17}
18
19#[derive(Debug, serde::Serialize, serde::Deserialize)]
20struct ScheduleEntry {
21 id: String,
22 description: String,
23 command: String,
24 #[serde(with = "chrono::serde::ts_seconds")]
25 created_at: DateTime<Utc>,
26 #[serde(with = "chrono::serde::ts_seconds_option")]
27 run_at: Option<DateTime<Utc>>,
28 interval_secs: Option<u64>,
29 enabled: bool,
30}
31
32#[async_trait::async_trait]
33impl BaseTool for ScheduleTool {
34 fn name(&self) -> &str {
35 "schedule"
36 }
37
38 fn description(&self) -> &str {
39 "Create, list, or remove scheduled tasks. Tasks are persisted to ~/.opendev/schedules.json."
40 }
41
42 fn parameter_schema(&self) -> serde_json::Value {
43 serde_json::json!({
44 "type": "object",
45 "properties": {
46 "action": {
47 "type": "string",
48 "enum": ["create", "list", "remove", "enable", "disable"],
49 "description": "Action to perform"
50 },
51 "id": {
52 "type": "string",
53 "description": "Schedule ID (for remove/enable/disable)"
54 },
55 "description": {
56 "type": "string",
57 "description": "Human-readable description (for create)"
58 },
59 "command": {
60 "type": "string",
61 "description": "Command to run (for create)"
62 },
63 "interval_secs": {
64 "type": "integer",
65 "description": "Repeat interval in seconds (for create)"
66 },
67 "delay_secs": {
68 "type": "integer",
69 "description": "Delay before first run in seconds (for create)"
70 }
71 },
72 "required": ["action"]
73 })
74 }
75
76 async fn execute(
77 &self,
78 args: HashMap<String, serde_json::Value>,
79 _ctx: &ToolContext,
80 ) -> ToolResult {
81 let action = match args.get("action").and_then(|v| v.as_str()) {
82 Some(a) => a,
83 None => return ToolResult::fail("action is required"),
84 };
85
86 let path = match Self::schedules_path() {
87 Some(p) => p,
88 None => return ToolResult::fail("Cannot determine home directory"),
89 };
90
91 match action {
92 "create" => {
93 let desc = args
94 .get("description")
95 .and_then(|v| v.as_str())
96 .unwrap_or("Untitled schedule");
97 let command = match args.get("command").and_then(|v| v.as_str()) {
98 Some(c) => c,
99 None => return ToolResult::fail("command is required for create"),
100 };
101 let interval = args.get("interval_secs").and_then(|v| v.as_u64());
102 let delay = args.get("delay_secs").and_then(|v| v.as_u64()).unwrap_or(0);
103
104 let run_at = if delay > 0 {
105 Some(Utc::now() + chrono::Duration::seconds(delay as i64))
106 } else {
107 None
108 };
109
110 let entry = ScheduleEntry {
111 id: uuid::Uuid::new_v4().to_string()[..8].to_string(),
112 description: desc.to_string(),
113 command: command.to_string(),
114 created_at: Utc::now(),
115 run_at,
116 interval_secs: interval,
117 enabled: true,
118 };
119
120 let mut schedules = load_schedules(&path);
121 let id = entry.id.clone();
122 schedules.push(entry);
123 if let Err(e) = save_schedules(&path, &schedules) {
124 return ToolResult::fail(format!("Failed to save: {e}"));
125 }
126
127 ToolResult::ok(format!("Created schedule '{id}': {desc}"))
128 }
129 "list" => {
130 let schedules = load_schedules(&path);
131 if schedules.is_empty() {
132 return ToolResult::ok("No scheduled tasks".to_string());
133 }
134
135 let mut output = format!("Scheduled tasks ({}):\n", schedules.len());
136 for s in &schedules {
137 let status = if s.enabled { "enabled" } else { "disabled" };
138 output.push_str(&format!(
139 " [{}] {} — '{}' ({})\n",
140 s.id, s.description, s.command, status
141 ));
142 }
143 ToolResult::ok(output)
144 }
145 "remove" => {
146 let id = match args.get("id").and_then(|v| v.as_str()) {
147 Some(i) => i,
148 None => return ToolResult::fail("id is required for remove"),
149 };
150 let mut schedules = load_schedules(&path);
151 let before = schedules.len();
152 schedules.retain(|s| s.id != id);
153 if schedules.len() == before {
154 return ToolResult::fail(format!("Schedule '{id}' not found"));
155 }
156 if let Err(e) = save_schedules(&path, &schedules) {
157 return ToolResult::fail(format!("Failed to save: {e}"));
158 }
159 ToolResult::ok(format!("Removed schedule '{id}'"))
160 }
161 "enable" | "disable" => {
162 let id = match args.get("id").and_then(|v| v.as_str()) {
163 Some(i) => i,
164 None => return ToolResult::fail("id is required"),
165 };
166 let mut schedules = load_schedules(&path);
167 let enabled = action == "enable";
168 let mut found = false;
169 for s in &mut schedules {
170 if s.id == id {
171 s.enabled = enabled;
172 found = true;
173 break;
174 }
175 }
176 if !found {
177 return ToolResult::fail(format!("Schedule '{id}' not found"));
178 }
179 if let Err(e) = save_schedules(&path, &schedules) {
180 return ToolResult::fail(format!("Failed to save: {e}"));
181 }
182 ToolResult::ok(format!("Schedule '{id}' {action}d"))
183 }
184 _ => ToolResult::fail(format!(
185 "Unknown action: {action}. Available: create, list, remove, enable, disable"
186 )),
187 }
188 }
189
190 fn display_meta(&self) -> Option<ToolDisplayMeta> {
191 Some(ToolDisplayMeta {
192 verb: "Schedule",
193 label: "task",
194 category: "Other",
195 primary_arg_keys: &["action", "description", "command"],
196 })
197 }
198}
199
200fn load_schedules(path: &std::path::Path) -> Vec<ScheduleEntry> {
201 if !path.exists() {
202 return Vec::new();
203 }
204 let content = match std::fs::read_to_string(path) {
205 Ok(c) => c,
206 Err(_) => return Vec::new(),
207 };
208 serde_json::from_str(&content).unwrap_or_default()
209}
210
211fn save_schedules(path: &std::path::Path, schedules: &[ScheduleEntry]) -> Result<(), String> {
212 if let Some(parent) = path.parent() {
213 std::fs::create_dir_all(parent).map_err(|e| format!("Cannot create directory: {e}"))?;
214 }
215 let json =
216 serde_json::to_string_pretty(schedules).map_err(|e| format!("Serialization error: {e}"))?;
217 std::fs::write(path, json).map_err(|e| format!("Write error: {e}"))
218}
219
220#[cfg(test)]
221#[path = "schedule_tests.rs"]
222mod tests;