Skip to main content

opendev_tools_impl/
schedule.rs

1//! Schedule tool — create and manage scheduled tasks persisted to disk.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use chrono::{DateTime, Utc};
7use opendev_tools_core::{BaseTool, ToolContext, ToolDisplayMeta, ToolResult};
8
9/// Tool for creating and listing scheduled tasks.
10#[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;