Skip to main content

yuki_cli/
schema.rs

1use serde_json::{Value, json};
2
3pub fn generate() -> Value {
4    let mut schema = json!({
5        "clispec": "0.3",
6        "name": "yuki",
7        "version": env!("CARGO_PKG_VERSION"),
8        "description": "CLI client for the Yuki bookkeeping API",
9        "global_args": [
10            {
11                "name": "--output",
12                "type": "string",
13                "description": "Output format: auto, text, or json.",
14                "enum": ["auto", "text", "json"],
15                "default": "auto"
16            },
17            {
18                "name": "--admin",
19                "type": "string",
20                "description": "Override the active administration by name."
21            },
22            {
23                "name": "--quiet",
24                "type": "boolean",
25                "description": "Suppress all output except errors.",
26                "default": false
27            },
28            {
29                "name": "--yes",
30                "type": "boolean",
31                "description": "Skip confirmation prompts (for use in scripts and pipelines).",
32                "default": false
33            }
34        ],
35        "commands": [
36            {
37                "name": "admin list",
38                "description": "List all available administrations.",
39                "mutating": false,
40                "args": [
41                    {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
42                    {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
43                    {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
44                ],
45                "output_fields": [
46                    {"name": "name", "type": "string"},
47                    {"name": "domain_id", "type": "string"},
48                    {"name": "admin_id", "type": "string"},
49                    {"name": "default", "type": "boolean"}
50                ]
51            },
52            {
53                "name": "admin switch",
54                "description": "Switch the active administration.",
55                "mutating": true,
56                "args": [
57                    {"name": "name", "type": "string", "required": true, "description": "Name of the administration to activate."}
58                ]
59            },
60            {
61                "name": "vat returns",
62                "description": "List VAT returns for a given year.",
63                "mutating": false,
64                "args": [
65                    {"name": "year", "type": "string", "required": false, "description": "Fiscal year (e.g. 2025)."}
66                ],
67                "output_fields": [
68                    {"name": "period", "type": "string"},
69                    {"name": "status", "type": "string"},
70                    {"name": "amount", "type": "string"}
71                ]
72            },
73            {
74                "name": "vat codes",
75                "description": "List active VAT codes.",
76                "mutating": false,
77                "args": [
78                    {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
79                    {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
80                    {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
81                ],
82                "output_fields": [
83                    {"name": "code", "type": "string"},
84                    {"name": "description", "type": "string"},
85                    {"name": "percentage", "type": "string"}
86                ]
87            },
88            {
89                "name": "contacts search",
90                "description": "Search contacts by name or other criteria.",
91                "mutating": false,
92                "args": [
93                    {"name": "query", "type": "string", "required": true, "description": "Search query."}
94                ],
95                "output_fields": [
96                    {"name": "id", "type": "string"},
97                    {"name": "name", "type": "string"},
98                    {"name": "type", "type": "string"}
99                ]
100            },
101            {
102                "name": "contacts list",
103                "description": "List contacts filtered by type.",
104                "mutating": false,
105                "args": [
106                    {"name": "--contact-type", "type": "string", "required": false, "description": "Contact type (e.g. customer, supplier)."},
107                    {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
108                    {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
109                    {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
110                ],
111                "output_fields": [
112                    {"name": "id", "type": "string"},
113                    {"name": "name", "type": "string"},
114                    {"name": "type", "type": "string"},
115                    {"name": "email", "type": "string"}
116                ]
117            },
118            {
119                "name": "accounts balance",
120                "description": "Show the balance of a general ledger account for a period.",
121                "mutating": false,
122                "args": [
123                    {"name": "--account", "type": "string", "required": false, "description": "GL account code."},
124                    {"name": "--period", "type": "string", "required": false, "description": "Accounting period (e.g. 2025-01)."}
125                ],
126                "output_fields": [
127                    {"name": "account", "type": "string"},
128                    {"name": "description", "type": "string"},
129                    {"name": "balance", "type": "string"}
130                ]
131            },
132            {
133                "name": "accounts transactions",
134                "description": "List transactions for a general ledger account.",
135                "mutating": false,
136                "args": [
137                    {"name": "--account", "type": "string", "required": false, "description": "GL account code."},
138                    {"name": "--period", "type": "string", "required": false, "description": "Accounting period (e.g. 2025-01)."},
139                    {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
140                    {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
141                    {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
142                ],
143                "output_fields": [
144                    {"name": "date", "type": "string"},
145                    {"name": "description", "type": "string"},
146                    {"name": "amount", "type": "string"},
147                    {"name": "reference", "type": "string"}
148                ]
149            },
150            {
151                "name": "accounts scheme",
152                "description": "Show the chart of accounts (GL account scheme).",
153                "mutating": false,
154                "args": [
155                    {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
156                    {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
157                    {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
158                ],
159                "output_fields": [
160                    {"name": "code", "type": "string"},
161                    {"name": "description", "type": "string"},
162                    {"name": "type", "type": "string"}
163                ]
164            },
165            {
166                "name": "accounts revenue",
167                "description": "Show net revenue for a period.",
168                "mutating": false,
169                "args": [
170                    {"name": "--period", "type": "string", "required": false, "description": "Accounting period (e.g. 2025, 2025-Q1, 2025-01)."}
171                ],
172                "output_fields": [
173                    {"name": "period", "type": "string"},
174                    {"name": "revenue", "type": "string"}
175                ]
176            },
177            {
178                "name": "accounts start-balance",
179                "description": "Show opening balances per GL account for a book year.",
180                "mutating": false,
181                "args": [
182                    {"name": "--year", "type": "string", "required": false, "description": "Book year (e.g. 2025)."},
183                    {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
184                    {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
185                    {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
186                ],
187                "output_fields": [
188                    {"name": "account", "type": "string"},
189                    {"name": "description", "type": "string"},
190                    {"name": "balance", "type": "string"}
191                ]
192            },
193            {
194                "name": "projects list",
195                "description": "List all projects.",
196                "mutating": false,
197                "args": [
198                    {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
199                    {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
200                    {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
201                ],
202                "output_fields": [
203                    {"name": "code", "type": "string"},
204                    {"name": "name", "type": "string"},
205                    {"name": "status", "type": "string"}
206                ]
207            },
208            {
209                "name": "projects balance",
210                "description": "Show balance for a project.",
211                "mutating": false,
212                "args": [
213                    {"name": "project", "type": "string", "required": true, "description": "Project code."},
214                    {"name": "--account", "type": "string", "required": false, "description": "GL account code filter."},
215                    {"name": "--period", "type": "string", "required": false, "description": "Accounting period (e.g. 2025, 2025-Q1)."}
216                ],
217                "output_fields": [
218                    {"name": "account", "type": "string"},
219                    {"name": "description", "type": "string"},
220                    {"name": "balance", "type": "string"}
221                ]
222            },
223            {
224                "name": "invoices list",
225                "description": "List invoices, optionally filtered by period and type.",
226                "mutating": false,
227                "args": [
228                    {"name": "--period", "type": "string", "required": false, "description": "Accounting period (e.g. 2025-01)."},
229                    {"name": "--invoice-type", "type": "string", "required": false, "description": "Invoice type filter (e.g. sales, purchase)."},
230                    {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
231                    {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
232                    {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
233                ],
234                "output_fields": [
235                    {"name": "id", "type": "string"},
236                    {"name": "date", "type": "string"},
237                    {"name": "contact", "type": "string"},
238                    {"name": "amount", "type": "string"},
239                    {"name": "status", "type": "string"}
240                ]
241            },
242            {
243                "name": "invoices show",
244                "description": "Show details for a single invoice.",
245                "mutating": false,
246                "args": [
247                    {"name": "id", "type": "string", "required": true, "description": "Invoice ID."}
248                ],
249                "output_fields": [
250                    {"name": "id", "type": "string"},
251                    {"name": "date", "type": "string"},
252                    {"name": "contact", "type": "string"},
253                    {"name": "amount", "type": "string"},
254                    {"name": "status", "type": "string"}
255                ]
256            },
257            {
258                "name": "invoices document",
259                "description": "Show the document linked to a transaction.",
260                "mutating": false,
261                "args": [
262                    {"name": "id", "type": "string", "required": true, "description": "Transaction ID."}
263                ],
264                "output_fields": [
265                    {"name": "id", "type": "string"},
266                    {"name": "filename", "type": "string"},
267                    {"name": "url", "type": "string"}
268                ]
269            },
270            {
271                "name": "documents list",
272                "description": "List documents in a folder or of a given type.",
273                "mutating": false,
274                "args": [
275                    {"name": "--folder", "type": "string", "required": false, "description": "Archive folder name."},
276                    {"name": "--doc-type", "type": "string", "required": false, "description": "Document type filter."},
277                    {"name": "--limit", "type": "integer", "required": false, "description": "Maximum number of results to return."},
278                    {"name": "--offset", "type": "integer", "required": false, "description": "Number of results to skip (for pagination)."},
279                    {"name": "--fields", "type": "string", "required": false, "description": "Comma-separated list of fields to include in output."}
280                ],
281                "output_fields": [
282                    {"name": "id", "type": "string"},
283                    {"name": "filename", "type": "string"},
284                    {"name": "folder", "type": "string"},
285                    {"name": "date", "type": "string"}
286                ]
287            },
288            {
289                "name": "documents search",
290                "description": "Search documents by a query string.",
291                "mutating": false,
292                "args": [
293                    {"name": "query", "type": "string", "required": true, "description": "Search query."}
294                ],
295                "output_fields": [
296                    {"name": "id", "type": "string"},
297                    {"name": "filename", "type": "string"},
298                    {"name": "folder", "type": "string"},
299                    {"name": "date", "type": "string"}
300                ]
301            },
302            {
303                "name": "documents exists",
304                "description": "Check if an invoice exists in the archive (by amount, date, and optional contact).",
305                "mutating": false,
306                "args": [
307                    {"name": "--amount", "type": "number", "required": true, "description": "Invoice amount to search for."},
308                    {"name": "--date", "type": "string", "required": true, "description": "Invoice date (YYYY-MM-DD). Matches within +/-7 days."},
309                    {"name": "--contact", "type": "string", "required": false, "description": "Contact/supplier name to narrow the search."}
310                ],
311                "output_fields": [
312                    {"name": "exists", "type": "boolean"},
313                    {"name": "id", "type": "string"},
314                    {"name": "filename", "type": "string"}
315                ]
316            },
317            {
318                "name": "check btw",
319                "description": "Check outstanding BTW (VAT) items for a period.",
320                "mutating": false,
321                "args": [
322                    {"name": "period", "type": "string", "required": false, "description": "Accounting period (e.g. 2025-01)."}
323                ],
324                "output_fields": [
325                    {"name": "account", "type": "string"},
326                    {"name": "description", "type": "string"},
327                    {"name": "amount", "type": "string"}
328                ]
329            },
330            {
331                "name": "check unmatched",
332                "description": "Find bank transactions without matching booked invoices.",
333                "mutating": false,
334                "args": [
335                    {"name": "--period", "type": "string", "required": false, "description": "Accounting period (e.g. 2025-Q1)."},
336                    {"name": "--bank-account", "type": "string", "required": false, "description": "GL account code for the bank account.", "default": "11001"}
337                ],
338                "output_fields": [
339                    {"name": "date", "type": "string"},
340                    {"name": "description", "type": "string"},
341                    {"name": "amount", "type": "string"}
342                ]
343            },
344            {
345                "name": "check outstanding",
346                "description": "Check if a specific invoice reference is still outstanding.",
347                "mutating": false,
348                "args": [
349                    {"name": "reference", "type": "string", "required": true, "description": "Invoice reference to check."}
350                ],
351                "output_fields": [
352                    {"name": "reference", "type": "string"},
353                    {"name": "outstanding", "type": "boolean"},
354                    {"name": "amount", "type": "string"}
355                ]
356            },
357            {
358                "name": "upload file",
359                "description": "Upload a document with optional invoice metadata.",
360                "mutating": true,
361                "args": [
362                    {"name": "file", "type": "path", "required": true, "description": "Path to the file to upload."},
363                    {"name": "--folder", "type": "string", "required": false, "description": "Target folder.", "default": "uitzoeken"},
364                    {"name": "--amount", "type": "number", "required": false, "description": "Invoice amount (e.g. 114.27)."},
365                    {"name": "--category", "type": "string", "required": false, "description": "Cost category ID (e.g. 45100)."},
366                    {"name": "--payment-method", "type": "string", "required": false, "description": "Payment method ID."},
367                    {"name": "--project", "type": "string", "required": false, "description": "Project ID."},
368                    {"name": "--remarks", "type": "string", "required": false, "description": "Remarks or notes."},
369                    {"name": "--currency", "type": "string", "required": false, "description": "Currency code.", "default": "EUR"}
370                ],
371                "output_fields": [
372                    {"name": "id", "type": "string"},
373                    {"name": "filename", "type": "string"},
374                    {"name": "folder", "type": "string"}
375                ]
376            },
377            {
378                "name": "upload categories",
379                "description": "List available cost categories.",
380                "mutating": false,
381                "output_fields": [
382                    {"name": "id", "type": "string"},
383                    {"name": "description", "type": "string"}
384                ]
385            },
386            {
387                "name": "upload payment-methods",
388                "description": "List available payment methods.",
389                "mutating": false,
390                "output_fields": [
391                    {"name": "id", "type": "string"},
392                    {"name": "description", "type": "string"}
393                ]
394            },
395            {
396                "name": "init",
397                "description": "Initialize yuki configuration for this machine.",
398                "mutating": true,
399                "args": [
400                    {"name": "--api-key", "type": "string", "required": false, "description": "API key (skips interactive prompt if provided)."},
401                    {"name": "--default-admin", "type": "string", "required": false, "description": "Default administration name (auto-selects if only one available)."}
402                ]
403            },
404            {
405                "name": "schema",
406                "description": "Output JSON schema for agent integration.",
407                "mutating": false
408            },
409            {
410                "name": "capabilities",
411                "description": "Describe supported API areas and safety behavior without loading configuration.",
412                "mutating": false,
413                "output_fields": [
414                    {"name":"areas","type":"array","items":{"type":"string"}},
415                    {"name":"structured_output","type":"boolean"},
416                    {"name":"daily_api_limit","type":"integer"}
417                ]
418            },
419            {
420                "name": "completions",
421                "description": "Generate shell completions.",
422                "mutating": false,
423                "args": [
424                    {"name": "shell", "type": "string", "required": true, "description": "Shell to generate completions for.", "enum": ["bash", "fish", "zsh", "powershell", "elvish"]}
425                ]
426            }
427        ],
428        "errors": [
429            {
430                "kind": "auth_failed",
431                "exit_code": 2,
432                "retryable": false,
433                "description": "Authentication failed: invalid or expired API key."
434            },
435            {
436                "kind": "not_found",
437                "exit_code": 3,
438                "retryable": false,
439                "description": "The requested resource was not found."
440            },
441            {
442                "kind": "rate_limited",
443                "exit_code": 4,
444                "retryable": true,
445                "description": "API rate limit exceeded (1000 calls/day)."
446            },
447            {
448                "kind": "config_error",
449                "exit_code": 1,
450                "retryable": false,
451                "description": "Configuration error: missing or invalid config file."
452            },
453            {
454                "kind": "confirmation_required",
455                "exit_code": 1,
456                "retryable": false,
457                "description": "A mutating command was invoked non-interactively without --yes."
458            },
459            {
460                "kind": "error",
461                "exit_code": 1,
462                "retryable": false,
463                "description": "An unexpected error occurred."
464            }
465        ]
466    });
467    enrich_v0_3(&mut schema);
468    schema
469}
470
471fn enrich_v0_3(schema: &mut Value) {
472    schema["output"] = json!({"tty":"text","piped":"json"});
473    let Some(commands) = schema["commands"].as_array_mut() else {
474        return;
475    };
476    for command in commands {
477        let Some(object) = command.as_object_mut() else {
478            continue;
479        };
480        let name = object["name"].as_str().unwrap_or_default().to_string();
481        let mutating = object["mutating"].as_bool().unwrap_or(false);
482        object.insert(
483            "effects".into(),
484            json!(if !mutating {
485                "read_only"
486            } else if name == "upload file" {
487                "non_idempotent"
488            } else {
489                "idempotent"
490            }),
491        );
492        if name == "completions" {
493            object.insert("output_kind".into(), json!("opaque"));
494            object.insert("media_type".into(), json!("text/plain"));
495            continue;
496        }
497        let unbounded = object
498            .get("args")
499            .and_then(Value::as_array)
500            .is_some_and(|args| {
501                args.iter().any(|arg| arg["name"] == "--limit")
502                    && args.iter().any(|arg| arg["name"] == "--offset")
503                    && args.iter().any(|arg| arg["name"] == "--fields")
504            });
505        object.insert(
506            "cardinality".into(),
507            json!(if unbounded { "unbounded" } else { "bounded" }),
508        );
509        if unbounded {
510            object.insert(
511                "pagination".into(),
512                json!({"style":"offset","limit_arg":"--limit","offset_arg":"--offset"}),
513            );
514            object.insert("fields_arg".into(), json!("--fields"));
515        }
516        if mutating {
517            object.insert("confirmation_bypass_arg".into(), json!("--yes"));
518        }
519        if name == "capabilities" {
520            object.insert("example".into(), json!({"args":["capabilities"]}));
521        }
522        if name == "schema" {
523            object.insert("cardinality".into(), json!("single"));
524            object.insert(
525                "stdout_schema".into(),
526                json!({"$ref":"https://clispec.dev/schema/v0.3.json"}),
527            );
528        }
529        if !object.contains_key("output_fields") && !object.contains_key("stdout_schema") {
530            object.insert("stdout_schema".into(), json!({}));
531        }
532    }
533}
534
535pub fn print_schema() {
536    let schema = generate();
537    println!(
538        "{}",
539        serde_json::to_string_pretty(&schema).expect("serialize schema")
540    );
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use jsonschema::Validator;
547    use serde_json::Value;
548
549    #[test]
550    fn schema_is_valid_json() {
551        let schema = generate();
552        let serialized = serde_json::to_string_pretty(&schema).unwrap();
553        let _: Value = serde_json::from_str(&serialized).unwrap();
554    }
555
556    #[test]
557    fn schema_has_required_top_level_keys() {
558        let schema = generate();
559        assert!(schema.get("clispec").is_some(), "missing clispec field");
560        assert!(schema.get("name").is_some(), "missing name field");
561        assert!(schema.get("version").is_some(), "missing version field");
562        assert!(schema.get("commands").is_some(), "missing commands field");
563        assert!(schema.get("errors").is_some(), "missing errors field");
564        assert!(
565            schema.get("global_args").is_some(),
566            "missing global_args field"
567        );
568    }
569
570    #[test]
571    fn schema_clispec_version() {
572        let schema = generate();
573        assert_eq!(schema["clispec"], "0.3");
574    }
575
576    #[test]
577    fn schema_commands_is_array() {
578        let schema = generate();
579        assert!(schema["commands"].is_array(), "commands must be an array");
580    }
581
582    #[test]
583    fn schema_all_commands_have_mutating_field() {
584        let schema = generate();
585        let commands = schema["commands"].as_array().unwrap();
586        for cmd in commands {
587            let name = cmd["name"].as_str().unwrap_or("unknown");
588            assert!(
589                cmd.get("mutating").is_some(),
590                "command '{name}' is missing 'mutating' field"
591            );
592        }
593    }
594
595    #[test]
596    fn schema_errors_have_exit_codes() {
597        let schema = generate();
598        let errors = schema["errors"].as_array().unwrap();
599        for err in errors {
600            let kind = err["kind"].as_str().unwrap_or("unknown");
601            assert!(
602                err.get("exit_code").is_some(),
603                "error '{kind}' is missing 'exit_code'"
604            );
605        }
606    }
607
608    #[test]
609    fn schema_global_args_has_output_flag() {
610        let schema = generate();
611        let global_args = schema["global_args"].as_array().unwrap();
612        let has_output = global_args.iter().any(|a| a["name"] == "--output");
613        assert!(has_output, "global_args must include --output flag");
614    }
615
616    #[test]
617    fn schema_global_args_has_yes_flag() {
618        let schema = generate();
619        let global_args = schema["global_args"].as_array().unwrap();
620        let has_yes = global_args.iter().any(|a| a["name"] == "--yes");
621        assert!(has_yes, "global_args must include --yes flag");
622    }
623
624    #[test]
625    fn schema_includes_leaf_commands() {
626        let schema = generate();
627        let commands = schema["commands"].as_array().unwrap();
628        let names: Vec<&str> = commands
629            .iter()
630            .map(|c| c["name"].as_str().unwrap_or(""))
631            .collect();
632        assert!(names.contains(&"admin list"), "missing 'admin list'");
633        assert!(names.contains(&"vat returns"), "missing 'vat returns'");
634        assert!(names.contains(&"invoices list"), "missing 'invoices list'");
635    }
636
637    #[test]
638    fn schema_validates_against_clispec_v03() {
639        let schema_json: Value =
640            serde_json::from_str(include_str!("../tests/fixtures/schema-v0.3.json"))
641                .expect("parse clispec schema fixture");
642
643        let validator = Validator::new(&schema_json).expect("compile clispec schema");
644        let output = generate();
645        if let Err(e) = validator.validate(&output) {
646            panic!("Schema does not validate against clispec v0.3:\n{e}");
647        }
648    }
649
650    #[test]
651    fn schema_works_without_config() {
652        // Must not panic or require any config file; generate() is purely static.
653        let schema = generate();
654        assert!(schema.get("name").is_some());
655    }
656
657    #[test]
658    fn list_commands_have_limit_flag() {
659        let schema = generate();
660        let commands = schema["commands"].as_array().unwrap();
661        let list_cmd = commands
662            .iter()
663            .find(|c| c["name"] == "contacts list")
664            .unwrap();
665        let args = list_cmd["args"].as_array().unwrap();
666        let has_limit = args.iter().any(|a| a["name"] == "--limit");
667        assert!(has_limit, "contacts list is missing --limit arg");
668    }
669
670    #[test]
671    fn list_commands_have_offset_flag() {
672        let schema = generate();
673        let commands = schema["commands"].as_array().unwrap();
674        let list_cmd = commands
675            .iter()
676            .find(|c| c["name"] == "contacts list")
677            .unwrap();
678        let args = list_cmd["args"].as_array().unwrap();
679        let has_offset = args.iter().any(|a| a["name"] == "--offset");
680        assert!(has_offset, "contacts list is missing --offset arg");
681    }
682
683    #[test]
684    fn list_commands_have_fields_flag() {
685        let schema = generate();
686        let commands = schema["commands"].as_array().unwrap();
687        let list_cmd = commands
688            .iter()
689            .find(|c| c["name"] == "contacts list")
690            .unwrap();
691        let args = list_cmd["args"].as_array().unwrap();
692        let has_fields = args.iter().any(|a| a["name"] == "--fields");
693        assert!(has_fields, "contacts list is missing --fields arg");
694    }
695
696    #[test]
697    fn output_fields_declared_on_commands() {
698        let schema = generate();
699        let commands = schema["commands"].as_array().unwrap();
700        let with_output_fields = commands
701            .iter()
702            .filter(|c| {
703                c.get("output_fields")
704                    .and_then(|f| f.as_array())
705                    .map(|a| !a.is_empty())
706                    .unwrap_or(false)
707            })
708            .count();
709        assert!(
710            with_output_fields > 0,
711            "at least some commands must have output_fields"
712        );
713    }
714}