1pub mod config;
2pub mod init;
3pub mod meetings;
4pub mod recordings;
5pub mod reports;
6pub mod users;
7pub mod webinars;
8
9fn schema_doc() -> serde_json::Value {
10 let mut schema = serde_json::json!({
11 "clispec": "0.3",
12 "name": "zoom",
13 "version": env!("CARGO_PKG_VERSION"),
14 "description": "CLI for the Zoom API",
15 "global_args": [
16 {
17 "name": "--output",
18 "type": "string",
19 "description": "Output format: auto (default), text, json",
20 "default": "auto"
21 },
22 {
23 "name": "--quiet",
24 "type": "boolean",
25 "description": "Suppress non-data output"
26 },
27 {
28 "name": "--profile",
29 "type": "string",
30 "description": "Config profile to use"
31 },
32 {
33 "name": "--json",
34 "type": "boolean",
35 "description": "Output as JSON (alias for --output=json)"
36 }
37 ],
38 "commands": [
39 {
40 "name": "meetings list",
41 "description": "List meetings for a user",
42 "mutating": false,
43 "output_fields": [
44 {"name": "id", "type": "integer"},
45 {"name": "topic", "type": "string"},
46 {"name": "start_time", "type": "string"},
47 {"name": "duration", "type": "integer"},
48 {"name": "join_url", "type": "string"},
49 {"name": "status", "type": "string"}
50 ],
51 "args": [
52 {"name": "--user", "type": "string", "default": "me", "description": "User ID or 'me'"},
53 {"name": "--type", "type": "string", "description": "Filter: scheduled|live|upcoming"},
54 {"name": "--limit", "type": "integer", "description": "Maximum number of results"},
55 {"name": "--offset", "type": "integer", "description": "Number of results to skip"},
56 {"name": "--fields", "type": "string[]", "description": "Fields to include in output"}
57 ]
58 },
59 {
60 "name": "meetings get",
61 "description": "Get a meeting by ID",
62 "mutating": false,
63 "output_fields": [
64 {"name": "id", "type": "integer"},
65 {"name": "topic", "type": "string"},
66 {"name": "start_time", "type": "string"},
67 {"name": "duration", "type": "integer"},
68 {"name": "join_url", "type": "string"},
69 {"name": "start_url", "type": "string"},
70 {"name": "status", "type": "string"},
71 {"name": "password", "type": "string"}
72 ],
73 "args": [
74 {"name": "id", "type": "integer", "required": true, "description": "Meeting ID"}
75 ]
76 },
77 {
78 "name": "meetings create",
79 "description": "Create a meeting",
80 "mutating": true,
81 "output_fields": [
82 {"name": "id", "type": "integer"},
83 {"name": "topic", "type": "string"},
84 {"name": "join_url", "type": "string"},
85 {"name": "start_url", "type": "string"}
86 ],
87 "args": [
88 {"name": "--topic", "type": "string", "required": true, "description": "Meeting topic"},
89 {"name": "--duration", "type": "integer", "description": "Duration in minutes"},
90 {"name": "--start", "type": "string", "description": "Start time (ISO 8601)"},
91 {"name": "--password", "type": "string", "description": "Meeting password"}
92 ]
93 },
94 {
95 "name": "meetings update",
96 "description": "Update a meeting",
97 "mutating": true,
98 "output_fields": [
99 {"name": "updated", "type": "boolean", "description": "Always true on success"},
100 {"name": "id", "type": "integer", "description": "Meeting ID that was updated"}
101 ],
102 "args": [
103 {"name": "id", "type": "integer", "required": true, "description": "Meeting ID"},
104 {"name": "--topic", "type": "string", "description": "New topic"},
105 {"name": "--duration", "type": "integer", "description": "New duration in minutes"},
106 {"name": "--start", "type": "string", "description": "New start time (ISO 8601)"}
107 ]
108 },
109 {
110 "name": "meetings delete",
111 "description": "Delete a meeting",
112 "mutating": true,
113 "output_fields": [
114 {"name": "deleted", "type": "boolean", "description": "Always true on success"},
115 {"name": "id", "type": "integer", "description": "Meeting ID that was deleted"}
116 ],
117 "args": [
118 {"name": "id", "type": "integer", "required": true, "description": "Meeting ID"},
119 {"name": "--yes", "type": "boolean", "default": false, "description": "Skip confirmation prompt"}
120 ]
121 },
122 {
123 "name": "meetings end",
124 "description": "End a live meeting",
125 "mutating": true,
126 "output_fields": [
127 {"name": "ended", "type": "boolean", "description": "Always true on success"},
128 {"name": "id", "type": "integer", "description": "Meeting ID that was ended"}
129 ],
130 "args": [
131 {"name": "id", "type": "integer", "required": true, "description": "Meeting ID"}
132 ]
133 },
134 {
135 "name": "meetings participants",
136 "description": "List participants from a past meeting",
137 "mutating": false,
138 "output_fields": [
139 {"name": "name", "type": "string"},
140 {"name": "user_email", "type": "string"},
141 {"name": "join_time", "type": "string"},
142 {"name": "leave_time", "type": "string"},
143 {"name": "duration", "type": "integer"}
144 ],
145 "args": [
146 {"name": "meeting_id", "type": "string", "required": true, "description": "Meeting ID or UUID"}
147 ]
148 },
149 {
150 "name": "meetings invite",
151 "description": "Get meeting invitation text",
152 "mutating": false,
153 "output_fields": [
154 {"name": "invitation", "type": "string"}
155 ],
156 "args": [
157 {"name": "id", "type": "integer", "required": true, "description": "Meeting ID"}
158 ]
159 },
160 {
161 "name": "recordings list",
162 "description": "List cloud recordings for a user",
163 "mutating": false,
164 "output_fields": [
165 {"name": "id", "type": "integer"},
166 {"name": "topic", "type": "string"},
167 {"name": "start_time", "type": "string"},
168 {"name": "duration", "type": "integer"},
169 {"name": "total_size", "type": "integer"},
170 {"name": "recording_files", "type": "array"}
171 ],
172 "args": [
173 {"name": "--user", "type": "string", "default": "me", "description": "User ID or 'me'"},
174 {"name": "--from", "type": "string", "description": "Start date (YYYY-MM-DD)"},
175 {"name": "--to", "type": "string", "description": "End date (YYYY-MM-DD)"},
176 {"name": "--limit", "type": "integer", "description": "Maximum number of results"},
177 {"name": "--offset", "type": "integer", "description": "Number of results to skip"},
178 {"name": "--fields", "type": "string[]", "description": "Fields to include in output"}
179 ]
180 },
181 {
182 "name": "recordings get",
183 "description": "Get recording details for a meeting",
184 "mutating": false,
185 "output_fields": [
186 {"name": "id", "type": "integer"},
187 {"name": "topic", "type": "string"},
188 {"name": "start_time", "type": "string"},
189 {"name": "duration", "type": "integer"},
190 {"name": "total_size", "type": "integer"},
191 {"name": "recording_files", "type": "array"}
192 ],
193 "args": [
194 {"name": "meeting_id", "type": "string", "required": true, "description": "Meeting ID or UUID"}
195 ]
196 },
197 {
198 "name": "recordings download",
199 "description": "Download recording files for a meeting",
200 "mutating": true,
201 "output_fields": [
202 {"name": "downloaded", "type": "integer", "description": "Number of files successfully downloaded"},
203 {"name": "meeting_id", "type": "string", "description": "Meeting ID or UUID that was downloaded"},
204 {"name": "out_dir", "type": "string", "description": "Directory files were written to"}
205 ],
206 "args": [
207 {"name": "meeting_id", "type": "string", "required": true, "description": "Meeting ID or UUID"},
208 {"name": "--out", "type": "path", "default": ".", "description": "Output directory"}
209 ]
210 },
211 {
212 "name": "recordings delete",
213 "description": "Delete all cloud recordings for a meeting",
214 "mutating": true,
215 "output_fields": [
216 {"name": "deleted", "type": "boolean", "description": "Always true on success"},
217 {"name": "meeting_id", "type": "string", "description": "Meeting ID or UUID whose recordings were deleted"},
218 {"name": "trash", "type": "boolean", "description": "True if moved to trash, false if permanently deleted"}
219 ],
220 "args": [
221 {"name": "meeting_id", "type": "string", "required": true, "description": "Meeting ID or UUID"},
222 {"name": "--permanent", "type": "boolean", "default": false, "description": "Permanently delete instead of moving to trash"},
223 {"name": "--yes", "type": "boolean", "default": false, "description": "Skip confirmation prompt"}
224 ]
225 },
226 {
227 "name": "recordings start",
228 "description": "Start cloud recording for a live meeting",
229 "mutating": true,
230 "output_fields": [
231 {"name": "action", "type": "string", "description": "The action performed (always 'start')"},
232 {"name": "meeting_id", "type": "integer", "description": "Numeric meeting ID the action was applied to"}
233 ],
234 "args": [
235 {"name": "meeting_id", "type": "integer", "required": true, "description": "Numeric meeting ID of the live meeting"}
236 ]
237 },
238 {
239 "name": "recordings stop",
240 "description": "Stop cloud recording for a live meeting",
241 "mutating": true,
242 "output_fields": [
243 {"name": "action", "type": "string", "description": "The action performed (always 'stop')"},
244 {"name": "meeting_id", "type": "integer", "description": "Numeric meeting ID the action was applied to"}
245 ],
246 "args": [
247 {"name": "meeting_id", "type": "integer", "required": true, "description": "Numeric meeting ID of the live meeting"}
248 ]
249 },
250 {
251 "name": "recordings pause",
252 "description": "Pause cloud recording for a live meeting",
253 "mutating": true,
254 "output_fields": [
255 {"name": "action", "type": "string", "description": "The action performed (always 'pause')"},
256 {"name": "meeting_id", "type": "integer", "description": "Numeric meeting ID the action was applied to"}
257 ],
258 "args": [
259 {"name": "meeting_id", "type": "integer", "required": true, "description": "Numeric meeting ID of the live meeting"}
260 ]
261 },
262 {
263 "name": "recordings resume",
264 "description": "Resume cloud recording for a live meeting",
265 "mutating": true,
266 "output_fields": [
267 {"name": "action", "type": "string", "description": "The action performed (always 'resume')"},
268 {"name": "meeting_id", "type": "integer", "description": "Numeric meeting ID the action was applied to"}
269 ],
270 "args": [
271 {"name": "meeting_id", "type": "integer", "required": true, "description": "Numeric meeting ID of the live meeting"}
272 ]
273 },
274 {
275 "name": "recordings transcript",
276 "description": "Download transcript files (VTT/chat) for a meeting",
277 "mutating": true,
278 "output_fields": [
279 {"name": "files_downloaded", "type": "integer", "description": "Number of transcript/chat files downloaded"},
280 {"name": "paths", "type": "array", "description": "Absolute paths of the downloaded files"}
281 ],
282 "args": [
283 {"name": "meeting_id", "type": "string", "required": true, "description": "Meeting ID or UUID"},
284 {"name": "--out", "type": "path", "default": ".", "description": "Output directory"}
285 ]
286 },
287 {
288 "name": "users list",
289 "description": "List users in the account",
290 "mutating": false,
291 "output_fields": [
292 {"name": "id", "type": "string"},
293 {"name": "email", "type": "string"},
294 {"name": "display_name", "type": "string"},
295 {"name": "first_name", "type": "string"},
296 {"name": "last_name", "type": "string"},
297 {"name": "status", "type": "string"},
298 {"name": "type", "type": "integer"}
299 ],
300 "args": [
301 {"name": "--status", "type": "string", "description": "Filter: active|inactive|pending"},
302 {"name": "--limit", "type": "integer", "description": "Maximum number of results"},
303 {"name": "--offset", "type": "integer", "description": "Number of results to skip"},
304 {"name": "--fields", "type": "string[]", "description": "Fields to include in output"}
305 ]
306 },
307 {
308 "name": "users get",
309 "description": "Get a user by ID or email",
310 "mutating": false,
311 "output_fields": [
312 {"name": "id", "type": "string"},
313 {"name": "email", "type": "string"},
314 {"name": "display_name", "type": "string"},
315 {"name": "first_name", "type": "string"},
316 {"name": "last_name", "type": "string"},
317 {"name": "status", "type": "string"},
318 {"name": "type", "type": "integer"}
319 ],
320 "args": [
321 {"name": "id_or_email", "type": "string", "required": true, "description": "User ID or email"}
322 ]
323 },
324 {
325 "name": "users me",
326 "description": "Get the current user",
327 "mutating": false,
328 "output_fields": [
329 {"name": "id", "type": "string"},
330 {"name": "email", "type": "string"},
331 {"name": "display_name", "type": "string"},
332 {"name": "first_name", "type": "string"},
333 {"name": "last_name", "type": "string"},
334 {"name": "status", "type": "string"},
335 {"name": "type", "type": "integer"}
336 ]
337 },
338 {
339 "name": "users create",
340 "description": "Create a new user",
341 "mutating": true,
342 "output_fields": [
343 {"name": "id", "type": "string"},
344 {"name": "email", "type": "string"},
345 {"name": "first_name", "type": "string"},
346 {"name": "last_name", "type": "string"},
347 {"name": "status", "type": "string"}
348 ],
349 "args": [
350 {"name": "--email", "type": "string", "required": true, "description": "User email"},
351 {"name": "--first-name", "type": "string", "description": "First name"},
352 {"name": "--last-name", "type": "string", "description": "Last name"},
353 {"name": "--type", "type": "integer", "default": 1, "description": "User type: 1=Basic, 2=Licensed, 3=On-prem"}
354 ]
355 },
356 {
357 "name": "users deactivate",
358 "description": "Deactivate a user",
359 "mutating": true,
360 "output_fields": [],
361 "args": [
362 {"name": "id_or_email", "type": "string", "required": true, "description": "User ID or email"}
363 ]
364 },
365 {
366 "name": "users activate",
367 "description": "Activate (reactivate) a user",
368 "mutating": true,
369 "output_fields": [],
370 "args": [
371 {"name": "id_or_email", "type": "string", "required": true, "description": "User ID or email"}
372 ]
373 },
374 {
375 "name": "webinars list",
376 "description": "List webinars for a user",
377 "mutating": false,
378 "output_fields": [
379 {"name": "id", "type": "integer"},
380 {"name": "topic", "type": "string"},
381 {"name": "start_time", "type": "string"},
382 {"name": "duration", "type": "integer"},
383 {"name": "join_url", "type": "string"},
384 {"name": "status", "type": "string"}
385 ],
386 "args": [
387 {"name": "--user", "type": "string", "default": "me", "description": "User ID or 'me'"},
388 {"name": "--limit", "type": "integer", "description": "Maximum number of results"},
389 {"name": "--offset", "type": "integer", "description": "Number of results to skip"},
390 {"name": "--fields", "type": "string[]", "description": "Fields to include in output"}
391 ]
392 },
393 {
394 "name": "webinars get",
395 "description": "Get a webinar by ID",
396 "mutating": false,
397 "output_fields": [
398 {"name": "id", "type": "integer"},
399 {"name": "topic", "type": "string"},
400 {"name": "start_time", "type": "string"},
401 {"name": "duration", "type": "integer"},
402 {"name": "join_url", "type": "string"},
403 {"name": "status", "type": "string"},
404 {"name": "agenda", "type": "string"}
405 ],
406 "args": [
407 {"name": "id", "type": "integer", "required": true, "description": "Webinar ID"}
408 ]
409 },
410 {
411 "name": "reports meetings",
412 "description": "Meeting summary report for a user",
413 "mutating": false,
414 "output_fields": [
415 {"name": "id", "type": "integer"},
416 {"name": "uuid", "type": "string"},
417 {"name": "topic", "type": "string"},
418 {"name": "start_time", "type": "string"},
419 {"name": "duration", "type": "integer"},
420 {"name": "participants_count", "type": "integer"},
421 {"name": "total_minutes", "type": "integer"}
422 ],
423 "args": [
424 {"name": "--user", "type": "string", "default": "me", "description": "User ID or 'me'"},
425 {"name": "--from", "type": "string", "required": true, "description": "Start date (YYYY-MM-DD)"},
426 {"name": "--to", "type": "string", "description": "End date (YYYY-MM-DD, default: today)"},
427 {"name": "--limit", "type": "integer", "description": "Maximum number of results"},
428 {"name": "--offset", "type": "integer", "description": "Number of results to skip"},
429 {"name": "--fields", "type": "string[]", "description": "Fields to include in output"}
430 ]
431 },
432 {
433 "name": "reports participants",
434 "description": "Participant report for a past meeting",
435 "mutating": false,
436 "output_fields": [
437 {"name": "name", "type": "string"},
438 {"name": "user_email", "type": "string"},
439 {"name": "join_time", "type": "string"},
440 {"name": "leave_time", "type": "string"},
441 {"name": "duration", "type": "integer"}
442 ],
443 "args": [
444 {"name": "meeting_id", "type": "string", "required": true, "description": "Meeting ID or UUID"}
445 ]
446 },
447 {
448 "name": "config show",
449 "description": "Show current configuration",
450 "mutating": false,
451 "output_fields": [
452 {"name": "profiles", "type": "array"},
453 {"name": "active_profile", "type": "string"},
454 {"name": "env_overrides", "type": "object"}
455 ]
456 },
457 {
458 "name": "config delete",
459 "description": "Delete a profile from the config file",
460 "mutating": true,
461 "output_fields": [],
462 "args": [
463 {"name": "profile", "type": "string", "required": true, "description": "Profile name to delete"},
464 {"name": "--force", "type": "boolean", "default": false, "description": "Skip confirmation prompt"}
465 ]
466 },
467 {
468 "name": "init",
469 "description": "Set up credentials interactively. When stdout is not a TTY, prints JSON setup instructions instead.",
470 "mutating": true,
471 "output_fields": [
472 {"name": "configPath", "type": "string", "description": "Absolute path to the config file that will be written"},
473 {"name": "tokenInstructions", "type": "object", "description": "Step-by-step instructions for creating a Server-to-Server OAuth app"},
474 {"name": "requiredCredentials", "type": "array", "description": "Credential keys required in the config file: account_id, client_id, client_secret"},
475 {"name": "requiredScopes", "type": "array", "description": "Zoom OAuth scopes that must be granted to the app"},
476 {"name": "optionalScopes", "type": "array", "description": "Zoom OAuth scopes that unlock additional commands"},
477 {"name": "example", "type": "object", "description": "Example config file path and format"}
478 ],
479 "args": [
480 {"name": "--profile", "type": "string", "description": "Profile name to create or update"}
481 ]
482 },
483 {
484 "name": "schema",
485 "description": "Print the machine-readable clispec v0.3 schema",
486 "mutating": false
487 },
488 {
489 "name": "completions",
490 "description": "Generate shell completions",
491 "mutating": false,
492 "args": [
493 {"name": "shell", "type": "string", "required": true, "description": "Shell to generate completions for"}
494 ]
495 }
496 ],
497 "errors": [
498 {"kind": "auth_error", "exit_code": 2, "retryable": false, "description": "Authentication failed or forbidden"},
499 {"kind": "not_found", "exit_code": 3, "retryable": false, "description": "Resource not found"},
500 {"kind": "invalid_input", "exit_code": 2, "retryable": false, "description": "Invalid user input or missing config"},
501 {"kind": "rate_limit", "exit_code": 1, "retryable": true, "description": "Rate limited by Zoom API"},
502 {"kind": "api_error", "exit_code": 1, "retryable": false, "description": "Non-2xx response from Zoom API"},
503 {"kind": "http_error", "exit_code": 1, "retryable": true, "description": "Network or TLS error"},
504 {"kind": "error", "exit_code": 1, "retryable": false, "description": "Unexpected error"},
505 {"kind": "confirmation_required", "exit_code": 2, "retryable": false, "description": "Destructive operation requires explicit confirmation"},
506 {"kind": "conflict", "exit_code": 1, "retryable": false, "description": "Resource already exists or state conflict"}
507 ]
508 });
509 enrich_v0_3(&mut schema);
510 schema
511}
512
513fn enrich_v0_3(schema: &mut serde_json::Value) {
514 schema["output"] = serde_json::json!({"tty":"text","piped":"json"});
515 let Some(commands) = schema["commands"].as_array_mut() else {
516 return;
517 };
518 for command in commands {
519 let Some(object) = command.as_object_mut() else {
520 continue;
521 };
522 let name = object["name"].as_str().unwrap_or_default().to_string();
523 let mutating = object["mutating"].as_bool().unwrap_or(false);
524 object.insert(
525 "effects".into(),
526 serde_json::json!(if !mutating {
527 "read_only"
528 } else if name.ends_with(" create") {
529 "non_idempotent"
530 } else {
531 "idempotent"
532 }),
533 );
534
535 if name == "completions" {
536 object.insert("output_kind".into(), serde_json::json!("opaque"));
537 object.insert("media_type".into(), serde_json::json!("text/plain"));
538 continue;
539 }
540
541 let unbounded = matches!(
542 name.as_str(),
543 "meetings list"
544 | "recordings list"
545 | "users list"
546 | "webinars list"
547 | "reports meetings"
548 );
549 object.insert(
550 "cardinality".into(),
551 serde_json::json!(if unbounded { "unbounded" } else { "bounded" }),
552 );
553 if unbounded {
554 object.insert(
555 "pagination".into(),
556 serde_json::json!({"style":"offset","limit_arg":"--limit","offset_arg":"--offset"}),
557 );
558 object.insert("fields_arg".into(), serde_json::json!("--fields"));
559 }
560 if matches!(
561 name.as_str(),
562 "meetings delete" | "recordings delete" | "config delete"
563 ) {
564 object.insert(
565 "confirmation_bypass_arg".into(),
566 serde_json::json!(if name == "config delete" {
567 "--force"
568 } else {
569 "--yes"
570 }),
571 );
572 }
573 if name == "config show" {
574 object.insert(
575 "example".into(),
576 serde_json::json!({"args":["config","show"]}),
577 );
578 }
579 if name == "schema" {
580 object.insert("cardinality".into(), serde_json::json!("single"));
581 object.insert(
582 "stdout_schema".into(),
583 serde_json::json!({"$ref":"https://clispec.dev/schema/v0.3.json"}),
584 );
585 }
586
587 if let Some(fields) = object
588 .get_mut("output_fields")
589 .and_then(serde_json::Value::as_array_mut)
590 {
591 for field in fields {
592 let Some(field) = field.as_object_mut() else {
593 continue;
594 };
595 if field.get("type").and_then(serde_json::Value::as_str) == Some("array")
596 && !field.contains_key("items")
597 {
598 let string_items = matches!(
599 field.get("name").and_then(serde_json::Value::as_str),
600 Some("paths" | "requiredCredentials" | "requiredScopes" | "optionalScopes")
601 );
602 field.insert(
603 "items".into(),
604 serde_json::json!({"type": if string_items { "string" } else { "object" }}),
605 );
606 }
607 }
608 }
609 if !object.contains_key("output_fields") && !object.contains_key("stdout_schema") {
610 object.insert("stdout_schema".into(), serde_json::json!({}));
611 }
612 }
613}
614
615pub fn schema() {
616 println!(
617 "{}",
618 serde_json::to_string_pretty(&schema_doc()).expect("serialize")
619 );
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use jsonschema;
626
627 #[test]
628 fn schema_output_is_valid_json_with_required_fields() {
629 let doc = schema_doc();
630 assert_eq!(doc["clispec"], "0.3");
631 assert_eq!(doc["name"], "zoom");
632 assert!(!doc["version"].as_str().unwrap_or("").is_empty());
633 assert!(!doc["commands"].as_array().unwrap().is_empty());
634 let errors = doc["errors"].as_array().unwrap();
635 assert!(!errors.is_empty());
636 assert!(
637 errors
638 .iter()
639 .all(|e| e["kind"].is_string() && e["exit_code"].is_number())
640 );
641 }
642
643 #[test]
644 fn schema_output_validates_against_clispec_v0_3_json_schema() {
645 let schema_json = include_str!("../../tests/fixtures/clispec-v0.3.json");
646 let meta_schema: serde_json::Value =
647 serde_json::from_str(schema_json).expect("fixture must be valid JSON");
648 let validator =
649 jsonschema::validator_for(&meta_schema).expect("clispec JSON Schema must compile");
650 let doc = schema_doc();
651 let errors: Vec<String> = validator.iter_errors(&doc).map(|e| e.to_string()).collect();
652 assert!(
653 errors.is_empty(),
654 "schema output does not validate against clispec v0.3:\n{}",
655 errors.join("\n")
656 );
657 }
658
659 #[test]
660 fn schema_errors_include_conflict_kind() {
661 let doc = schema_doc();
662 let errors = doc["errors"].as_array().unwrap();
663 let has_conflict = errors.iter().any(|e| e["kind"] == "conflict");
664 assert!(has_conflict, "errors must include 'conflict' kind");
665 }
666
667 #[test]
668 fn schema_all_commands_have_mutating_field() {
669 let doc = schema_doc();
670 if let Some(cmds) = doc["commands"].as_array() {
671 for cmd in cmds {
672 assert!(
673 cmd["mutating"].is_boolean(),
674 "command '{}' must have a boolean mutating field",
675 cmd["name"].as_str().unwrap_or("?")
676 );
677 }
678 }
679 }
680}