Skip to main content

zoom_cli/commands/
mod.rs

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}