1use super::traits::{Tool, ToolResult};
2use crate::config::Config;
3use crate::cron::{
4 self, DeliveryConfig, JobType, Schedule, SessionTarget, deserialize_maybe_stringified,
5};
6use crate::security::SecurityPolicy;
7use async_trait::async_trait;
8use serde_json::json;
9use std::sync::Arc;
10
11pub struct CronAddTool {
12 config: Arc<Config>,
13 security: Arc<SecurityPolicy>,
14}
15
16impl CronAddTool {
17 pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
18 Self { config, security }
19 }
20
21 fn enforce_mutation_allowed(&self, action: &str) -> Option<ToolResult> {
22 if !self.security.can_act() {
23 return Some(ToolResult {
24 success: false,
25 output: String::new(),
26 error: Some(format!(
27 "Security policy: read-only mode, cannot perform '{action}'"
28 )),
29 });
30 }
31
32 if self.security.is_rate_limited() {
33 return Some(ToolResult {
34 success: false,
35 output: String::new(),
36 error: Some("Rate limit exceeded: too many actions in the last hour".to_string()),
37 });
38 }
39
40 if !self.security.record_action() {
41 return Some(ToolResult {
42 success: false,
43 output: String::new(),
44 error: Some("Rate limit exceeded: action budget exhausted".to_string()),
45 });
46 }
47
48 None
49 }
50}
51
52#[async_trait]
53impl Tool for CronAddTool {
54 fn name(&self) -> &str {
55 "cron_add"
56 }
57
58 fn description(&self) -> &str {
59 "Create a scheduled cron job (shell or agent) with cron/at/every schedules. \
60 Use job_type='agent' with a prompt to run the AI agent on schedule. \
61 To deliver output to a channel (Discord, Telegram, Slack, Mattermost, Matrix, QQ), set \
62 delivery={\"mode\":\"announce\",\"channel\":\"discord\",\"to\":\"<channel_id_or_chat_id>\"}. \
63 This is the preferred tool for sending scheduled/delayed messages to users via channels."
64 }
65
66 fn parameters_schema(&self) -> serde_json::Value {
67 json!({
68 "type": "object",
69 "properties": {
70 "name": {
71 "type": "string",
72 "description": "Optional human-readable name for the job"
73 },
74 "schedule": {
79 "description": "When to run the job. Exactly one of three forms must be used.",
80 "oneOf": [
81 {
82 "type": "object",
83 "description": "Cron expression schedule (repeating). Example: {\"kind\":\"cron\",\"expr\":\"0 9 * * 1-5\",\"tz\":\"America/New_York\"}",
84 "properties": {
85 "kind": { "type": "string", "enum": ["cron"] },
86 "expr": { "type": "string", "description": "Standard 5-field cron expression, e.g. '*/5 * * * *'" },
87 "tz": { "type": "string", "description": "Optional IANA timezone name, e.g. 'America/New_York'. Defaults to UTC." }
88 },
89 "required": ["kind", "expr"]
90 },
91 {
92 "type": "object",
93 "description": "One-shot schedule at a specific UTC datetime. Example: {\"kind\":\"at\",\"at\":\"2025-12-31T23:59:00Z\"}",
94 "properties": {
95 "kind": { "type": "string", "enum": ["at"] },
96 "at": { "type": "string", "description": "ISO 8601 UTC datetime string, e.g. '2025-12-31T23:59:00Z'" }
97 },
98 "required": ["kind", "at"]
99 },
100 {
101 "type": "object",
102 "description": "Repeating interval schedule in milliseconds. Example: {\"kind\":\"every\",\"every_ms\":3600000} runs every hour.",
103 "properties": {
104 "kind": { "type": "string", "enum": ["every"] },
105 "every_ms": { "type": "integer", "description": "Interval in milliseconds, e.g. 3600000 for every hour" }
106 },
107 "required": ["kind", "every_ms"]
108 }
109 ]
110 },
111 "job_type": {
112 "type": "string",
113 "enum": ["shell", "agent"],
114 "description": "Type of job: 'shell' runs a command, 'agent' runs the AI agent with a prompt"
115 },
116 "command": {
117 "type": "string",
118 "description": "Shell command to run (required when job_type is 'shell')"
119 },
120 "prompt": {
121 "type": "string",
122 "description": "Agent prompt to run on schedule (required when job_type is 'agent')"
123 },
124 "session_target": {
125 "type": "string",
126 "enum": ["isolated", "main"],
127 "description": "Agent session context: 'isolated' starts a fresh session each run, 'main' reuses the primary session"
128 },
129 "model": {
130 "type": "string",
131 "description": "Optional model override for agent jobs, e.g. 'x-ai/grok-4-1-fast'"
132 },
133 "allowed_tools": {
134 "type": "array",
135 "items": { "type": "string" },
136 "description": "Optional allowlist of tool names for agent jobs. When omitted, all tools remain available."
137 },
138 "delivery": {
139 "type": "object",
140 "description": "Optional delivery config to send job output to a channel after each run. When provided, all three of mode, channel, and to are expected.",
141 "properties": {
142 "mode": {
143 "type": "string",
144 "enum": ["none", "announce"],
145 "description": "'announce' sends output to the specified channel; 'none' disables delivery"
146 },
147 "channel": {
148 "type": "string",
149 "enum": ["telegram", "discord", "slack", "mattermost", "matrix", "qq"],
150 "description": "Channel type to deliver output to"
151 },
152 "to": {
153 "type": "string",
154 "description": "Destination ID: Discord channel ID, Telegram chat ID, Slack channel name, etc."
155 },
156 "best_effort": {
157 "type": "boolean",
158 "description": "If true, a delivery failure does not fail the job itself. Defaults to true."
159 }
160 }
161 },
162 "delete_after_run": {
163 "type": "boolean",
164 "description": "If true, the job is automatically deleted after its first successful run. Defaults to true for 'at' schedules."
165 },
166 "approved": {
167 "type": "boolean",
168 "description": "Set true to explicitly approve medium/high-risk shell commands in supervised mode",
169 "default": false
170 }
171 },
172 "required": ["schedule"]
173 })
174 }
175
176 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
177 if !self.config.cron.enabled {
178 return Ok(ToolResult {
179 success: false,
180 output: String::new(),
181 error: Some("cron is disabled by config (cron.enabled=false)".to_string()),
182 });
183 }
184
185 let schedule = match args.get("schedule") {
186 Some(v) => match deserialize_maybe_stringified::<Schedule>(v) {
187 Ok(schedule) => schedule,
188 Err(e) => {
189 return Ok(ToolResult {
190 success: false,
191 output: String::new(),
192 error: Some(format!("Invalid schedule: {e}")),
193 });
194 }
195 },
196 None => {
197 return Ok(ToolResult {
198 success: false,
199 output: String::new(),
200 error: Some("Missing 'schedule' parameter".to_string()),
201 });
202 }
203 };
204
205 let name = args
206 .get("name")
207 .and_then(serde_json::Value::as_str)
208 .map(str::to_string);
209
210 let job_type = match args.get("job_type").and_then(serde_json::Value::as_str) {
211 Some("agent") => JobType::Agent,
212 Some("shell") => JobType::Shell,
213 Some(other) => {
214 return Ok(ToolResult {
215 success: false,
216 output: String::new(),
217 error: Some(format!("Invalid job_type: {other}")),
218 });
219 }
220 None => {
221 if args.get("prompt").is_some() {
222 JobType::Agent
223 } else {
224 JobType::Shell
225 }
226 }
227 };
228
229 let default_delete_after_run = matches!(schedule, Schedule::At { .. });
230 let delete_after_run = args
231 .get("delete_after_run")
232 .and_then(serde_json::Value::as_bool)
233 .unwrap_or(default_delete_after_run);
234 let approved = args
235 .get("approved")
236 .and_then(serde_json::Value::as_bool)
237 .unwrap_or(false);
238 let delivery = match args.get("delivery") {
239 Some(v) => match serde_json::from_value::<DeliveryConfig>(v.clone()) {
240 Ok(cfg) => Some(cfg),
241 Err(e) => {
242 return Ok(ToolResult {
243 success: false,
244 output: String::new(),
245 error: Some(format!("Invalid delivery config: {e}")),
246 });
247 }
248 },
249 None => None,
250 };
251
252 let result = match job_type {
253 JobType::Shell => {
254 let command = match args.get("command").and_then(serde_json::Value::as_str) {
255 Some(command) if !command.trim().is_empty() => command,
256 _ => {
257 return Ok(ToolResult {
258 success: false,
259 output: String::new(),
260 error: Some("Missing 'command' for shell job".to_string()),
261 });
262 }
263 };
264
265 if let Err(reason) = self.security.validate_command_execution(command, approved) {
266 return Ok(ToolResult {
267 success: false,
268 output: String::new(),
269 error: Some(reason),
270 });
271 }
272
273 if let Some(blocked) = self.enforce_mutation_allowed("cron_add") {
274 return Ok(blocked);
275 }
276
277 cron::add_shell_job_with_approval(
278 &self.config,
279 name,
280 schedule,
281 command,
282 delivery,
283 approved,
284 )
285 }
286 JobType::Agent => {
287 let prompt = match args.get("prompt").and_then(serde_json::Value::as_str) {
288 Some(prompt) if !prompt.trim().is_empty() => prompt,
289 _ => {
290 return Ok(ToolResult {
291 success: false,
292 output: String::new(),
293 error: Some("Missing 'prompt' for agent job".to_string()),
294 });
295 }
296 };
297
298 let session_target = match args.get("session_target") {
299 Some(v) => match serde_json::from_value::<SessionTarget>(v.clone()) {
300 Ok(target) => target,
301 Err(e) => {
302 return Ok(ToolResult {
303 success: false,
304 output: String::new(),
305 error: Some(format!("Invalid session_target: {e}")),
306 });
307 }
308 },
309 None => SessionTarget::Isolated,
310 };
311
312 let model = args
313 .get("model")
314 .and_then(serde_json::Value::as_str)
315 .map(str::to_string);
316 let allowed_tools = match args.get("allowed_tools") {
317 Some(v) => match serde_json::from_value::<Vec<String>>(v.clone()) {
318 Ok(v) => {
319 if v.is_empty() {
320 None } else {
322 Some(v)
323 }
324 }
325 Err(e) => {
326 return Ok(ToolResult {
327 success: false,
328 output: String::new(),
329 error: Some(format!("Invalid allowed_tools: {e}")),
330 });
331 }
332 },
333 None => None,
334 };
335
336 if let Some(blocked) = self.enforce_mutation_allowed("cron_add") {
337 return Ok(blocked);
338 }
339
340 cron::add_agent_job(
341 &self.config,
342 name,
343 schedule,
344 prompt,
345 session_target,
346 model,
347 delivery,
348 delete_after_run,
349 allowed_tools,
350 )
351 }
352 JobType::Workflow => {
353 return Ok(ToolResult {
354 success: false,
355 output: String::new(),
356 error: Some(
357 "Workflow cron jobs are managed via workflow YAML triggers, \
358 not the cron_add tool"
359 .to_string(),
360 ),
361 });
362 }
363 };
364
365 match result {
366 Ok(job) => Ok(ToolResult {
367 success: true,
368 output: serde_json::to_string_pretty(&json!({
369 "id": job.id,
370 "name": job.name,
371 "job_type": job.job_type,
372 "schedule": job.schedule,
373 "next_run": job.next_run,
374 "enabled": job.enabled,
375 "allowed_tools": job.allowed_tools
376 }))?,
377 error: None,
378 }),
379 Err(e) => Ok(ToolResult {
380 success: false,
381 output: String::new(),
382 error: Some(e.to_string()),
383 }),
384 }
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use crate::config::Config;
392 use crate::security::AutonomyLevel;
393 use tempfile::TempDir;
394
395 async fn test_config(tmp: &TempDir) -> Arc<Config> {
396 let config = Config {
397 workspace_dir: tmp.path().join("workspace"),
398 config_path: tmp.path().join("config.toml"),
399 ..Config::default()
400 };
401 tokio::fs::create_dir_all(&config.workspace_dir)
402 .await
403 .unwrap();
404 Arc::new(config)
405 }
406
407 fn test_security(cfg: &Config) -> Arc<SecurityPolicy> {
408 Arc::new(SecurityPolicy::from_config(
409 &cfg.autonomy,
410 &cfg.workspace_dir,
411 ))
412 }
413
414 #[tokio::test]
415 async fn adds_shell_job() {
416 let tmp = TempDir::new().unwrap();
417 let cfg = test_config(&tmp).await;
418 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
419 let result = tool
420 .execute(json!({
421 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
422 "job_type": "shell",
423 "command": "echo ok"
424 }))
425 .await
426 .unwrap();
427
428 assert!(result.success, "{:?}", result.error);
429 assert!(result.output.contains("next_run"));
430 }
431
432 #[tokio::test]
433 async fn shell_job_persists_delivery() {
434 let tmp = TempDir::new().unwrap();
435 let cfg = test_config(&tmp).await;
436 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
437 let result = tool
438 .execute(json!({
439 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
440 "job_type": "shell",
441 "command": "echo ok",
442 "delivery": {
443 "mode": "announce",
444 "channel": "discord",
445 "to": "1234567890",
446 "best_effort": true
447 }
448 }))
449 .await
450 .unwrap();
451
452 assert!(result.success, "{:?}", result.error);
453
454 let jobs = cron::list_jobs(&cfg).unwrap();
455 assert_eq!(jobs.len(), 1);
456 assert_eq!(jobs[0].delivery.mode, "announce");
457 assert_eq!(jobs[0].delivery.channel.as_deref(), Some("discord"));
458 assert_eq!(jobs[0].delivery.to.as_deref(), Some("1234567890"));
459 assert!(jobs[0].delivery.best_effort);
460 }
461
462 #[tokio::test]
463 async fn blocks_disallowed_shell_command() {
464 let tmp = TempDir::new().unwrap();
465 let mut config = Config {
466 workspace_dir: tmp.path().join("workspace"),
467 config_path: tmp.path().join("config.toml"),
468 ..Config::default()
469 };
470 config.autonomy.allowed_commands = vec!["echo".into()];
471 config.autonomy.level = AutonomyLevel::Supervised;
472 tokio::fs::create_dir_all(&config.workspace_dir)
473 .await
474 .unwrap();
475 let cfg = Arc::new(config);
476 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
477
478 let result = tool
479 .execute(json!({
480 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
481 "job_type": "shell",
482 "command": "curl https://example.com"
483 }))
484 .await
485 .unwrap();
486
487 assert!(!result.success);
488 assert!(result.error.unwrap_or_default().contains("not allowed"));
489 }
490
491 #[tokio::test]
492 async fn blocks_mutation_in_read_only_mode() {
493 let tmp = TempDir::new().unwrap();
494 let mut config = Config {
495 workspace_dir: tmp.path().join("workspace"),
496 config_path: tmp.path().join("config.toml"),
497 ..Config::default()
498 };
499 config.autonomy.level = AutonomyLevel::ReadOnly;
500 std::fs::create_dir_all(&config.workspace_dir).unwrap();
501 let cfg = Arc::new(config);
502 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
503
504 let result = tool
505 .execute(json!({
506 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
507 "job_type": "shell",
508 "command": "echo ok"
509 }))
510 .await
511 .unwrap();
512
513 assert!(!result.success);
514 let error = result.error.unwrap_or_default();
515 assert!(error.contains("read-only") || error.contains("not allowed"));
516 }
517
518 #[tokio::test]
519 async fn blocks_add_when_rate_limited() {
520 let tmp = TempDir::new().unwrap();
521 let mut config = Config {
522 workspace_dir: tmp.path().join("workspace"),
523 config_path: tmp.path().join("config.toml"),
524 ..Config::default()
525 };
526 config.autonomy.level = AutonomyLevel::Full;
527 config.autonomy.max_actions_per_hour = 0;
528 std::fs::create_dir_all(&config.workspace_dir).unwrap();
529 let cfg = Arc::new(config);
530 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
531
532 let result = tool
533 .execute(json!({
534 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
535 "job_type": "shell",
536 "command": "echo ok"
537 }))
538 .await
539 .unwrap();
540
541 assert!(!result.success);
542 assert!(
543 result
544 .error
545 .unwrap_or_default()
546 .contains("Rate limit exceeded")
547 );
548 assert!(cron::list_jobs(&cfg).unwrap().is_empty());
549 }
550
551 #[tokio::test]
552 async fn medium_risk_shell_command_requires_approval() {
553 let tmp = TempDir::new().unwrap();
554 let mut config = Config {
555 workspace_dir: tmp.path().join("workspace"),
556 config_path: tmp.path().join("config.toml"),
557 ..Config::default()
558 };
559 config.autonomy.allowed_commands = vec!["touch".into()];
560 config.autonomy.level = AutonomyLevel::Supervised;
561 std::fs::create_dir_all(&config.workspace_dir).unwrap();
562 let cfg = Arc::new(config);
563 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
564
565 let denied = tool
566 .execute(json!({
567 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
568 "job_type": "shell",
569 "command": "touch cron-approval-test"
570 }))
571 .await
572 .unwrap();
573 assert!(!denied.success);
574 assert!(
575 denied
576 .error
577 .unwrap_or_default()
578 .contains("explicit approval")
579 );
580
581 let approved = tool
582 .execute(json!({
583 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
584 "job_type": "shell",
585 "command": "touch cron-approval-test",
586 "approved": true
587 }))
588 .await
589 .unwrap();
590 assert!(approved.success, "{:?}", approved.error);
591 }
592
593 #[tokio::test]
594 async fn accepts_schedule_passed_as_json_string() {
595 let tmp = TempDir::new().unwrap();
596 let cfg = test_config(&tmp).await;
597 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
598
599 let result = tool
602 .execute(json!({
603 "schedule": r#"{"kind":"cron","expr":"*/5 * * * *"}"#,
604 "job_type": "shell",
605 "command": "echo string-schedule"
606 }))
607 .await
608 .unwrap();
609
610 assert!(result.success, "{:?}", result.error);
611 assert!(result.output.contains("next_run"));
612 }
613
614 #[tokio::test]
615 async fn accepts_stringified_interval_schedule() {
616 let tmp = TempDir::new().unwrap();
617 let cfg = test_config(&tmp).await;
618 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
619
620 let result = tool
621 .execute(json!({
622 "schedule": r#"{"kind":"every","every_ms":60000}"#,
623 "job_type": "shell",
624 "command": "echo interval"
625 }))
626 .await
627 .unwrap();
628
629 assert!(result.success, "{:?}", result.error);
630 }
631
632 #[tokio::test]
633 async fn accepts_stringified_schedule_with_timezone() {
634 let tmp = TempDir::new().unwrap();
635 let cfg = test_config(&tmp).await;
636 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
637
638 let result = tool
639 .execute(json!({
640 "schedule": r#"{"kind":"cron","expr":"*/30 9-15 * * 1-5","tz":"Asia/Shanghai"}"#,
641 "job_type": "shell",
642 "command": "echo tz-test"
643 }))
644 .await
645 .unwrap();
646
647 assert!(result.success, "{:?}", result.error);
648 }
649
650 #[tokio::test]
651 async fn rejects_invalid_schedule() {
652 let tmp = TempDir::new().unwrap();
653 let cfg = test_config(&tmp).await;
654 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
655
656 let result = tool
657 .execute(json!({
658 "schedule": { "kind": "every", "every_ms": 0 },
659 "job_type": "shell",
660 "command": "echo nope"
661 }))
662 .await
663 .unwrap();
664
665 assert!(!result.success);
666 assert!(
667 result
668 .error
669 .unwrap_or_default()
670 .contains("every_ms must be > 0")
671 );
672 }
673
674 #[tokio::test]
675 async fn agent_job_requires_prompt() {
676 let tmp = TempDir::new().unwrap();
677 let cfg = test_config(&tmp).await;
678 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
679
680 let result = tool
681 .execute(json!({
682 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
683 "job_type": "agent"
684 }))
685 .await
686 .unwrap();
687 assert!(!result.success);
688 assert!(
689 result
690 .error
691 .unwrap_or_default()
692 .contains("Missing 'prompt'")
693 );
694 }
695
696 #[tokio::test]
697 async fn agent_job_persists_allowed_tools() {
698 let tmp = TempDir::new().unwrap();
699 let cfg = test_config(&tmp).await;
700 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
701
702 let result = tool
703 .execute(json!({
704 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
705 "job_type": "agent",
706 "prompt": "check status",
707 "allowed_tools": ["file_read", "web_search"]
708 }))
709 .await
710 .unwrap();
711
712 assert!(result.success, "{:?}", result.error);
713
714 let jobs = cron::list_jobs(&cfg).unwrap();
715 assert_eq!(jobs.len(), 1);
716 assert_eq!(
717 jobs[0].allowed_tools,
718 Some(vec!["file_read".into(), "web_search".into()])
719 );
720 }
721
722 #[tokio::test]
723 async fn empty_allowed_tools_stored_as_none() {
724 let tmp = TempDir::new().unwrap();
725 let cfg = test_config(&tmp).await;
726 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
727
728 let result = tool
729 .execute(json!({
730 "schedule": { "kind": "cron", "expr": "*/5 * * * *" },
731 "job_type": "agent",
732 "prompt": "check status",
733 "allowed_tools": []
734 }))
735 .await
736 .unwrap();
737
738 assert!(result.success, "{:?}", result.error);
739
740 let jobs = cron::list_jobs(&cfg).unwrap();
741 assert_eq!(jobs.len(), 1);
742 assert_eq!(
743 jobs[0].allowed_tools, None,
744 "empty allowed_tools should be stored as None"
745 );
746 }
747
748 #[tokio::test]
749 async fn delivery_schema_includes_matrix_channel() {
750 let tmp = TempDir::new().unwrap();
751 let cfg = test_config(&tmp).await;
752 let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
753
754 let values =
755 tool.parameters_schema()["properties"]["delivery"]["properties"]["channel"]["enum"]
756 .as_array()
757 .cloned()
758 .unwrap_or_default();
759
760 assert!(values.iter().any(|value| value == "matrix"));
761 }
762
763 #[test]
764 fn schedule_schema_is_oneof_with_cron_at_every_variants() {
765 let tmp = tempfile::TempDir::new().unwrap();
766 let cfg = Arc::new(Config {
767 workspace_dir: tmp.path().join("workspace"),
768 config_path: tmp.path().join("config.toml"),
769 ..Config::default()
770 });
771 let security = Arc::new(SecurityPolicy::from_config(
772 &cfg.autonomy,
773 &cfg.workspace_dir,
774 ));
775 let tool = CronAddTool::new(cfg, security);
776 let schema = tool.parameters_schema();
777
778 let top_required = schema["required"].as_array().expect("top-level required");
780 assert!(top_required.iter().any(|v| v == "schedule"));
781
782 let one_of = schema["properties"]["schedule"]["oneOf"]
784 .as_array()
785 .expect("schedule.oneOf must be an array");
786 assert_eq!(one_of.len(), 3, "expected cron, at, and every variants");
787
788 let kinds: Vec<&str> = one_of
789 .iter()
790 .filter_map(|v| v["properties"]["kind"]["enum"][0].as_str())
791 .collect();
792 assert!(kinds.contains(&"cron"), "missing cron variant");
793 assert!(kinds.contains(&"at"), "missing at variant");
794 assert!(kinds.contains(&"every"), "missing every variant");
795
796 for variant in one_of {
798 let kind = variant["properties"]["kind"]["enum"][0]
799 .as_str()
800 .expect("variant kind");
801 let req: Vec<&str> = variant["required"]
802 .as_array()
803 .unwrap_or_else(|| panic!("{kind} variant must have required"))
804 .iter()
805 .filter_map(|v| v.as_str())
806 .collect();
807 assert!(
808 req.contains(&"kind"),
809 "{kind} variant missing 'kind' in required"
810 );
811 match kind {
812 "cron" => assert!(req.contains(&"expr"), "cron variant missing 'expr'"),
813 "at" => assert!(req.contains(&"at"), "at variant missing 'at'"),
814 "every" => {
815 assert!(
816 req.contains(&"every_ms"),
817 "every variant missing 'every_ms'"
818 );
819 assert_eq!(
820 variant["properties"]["every_ms"]["type"].as_str(),
821 Some("integer"),
822 "every_ms must be typed as integer"
823 );
824 }
825 _ => panic!("unexpected kind: {kind}"),
826 }
827 }
828 }
829}