Skip to main content

yuki_cli/
schema.rs

1use serde_json::{Value, json};
2
3pub fn generate() -> Value {
4    json!({
5        "clispec": "0.2",
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": "completions",
411                "description": "Generate shell completions.",
412                "mutating": false,
413                "args": [
414                    {"name": "shell", "type": "string", "required": true, "description": "Shell to generate completions for.", "enum": ["bash", "fish", "zsh", "powershell", "elvish"]}
415                ]
416            }
417        ],
418        "errors": [
419            {
420                "kind": "auth_failed",
421                "exit_code": 2,
422                "retryable": false,
423                "description": "Authentication failed: invalid or expired API key."
424            },
425            {
426                "kind": "not_found",
427                "exit_code": 3,
428                "retryable": false,
429                "description": "The requested resource was not found."
430            },
431            {
432                "kind": "rate_limited",
433                "exit_code": 4,
434                "retryable": true,
435                "description": "API rate limit exceeded (1000 calls/day)."
436            },
437            {
438                "kind": "config_error",
439                "exit_code": 1,
440                "retryable": false,
441                "description": "Configuration error: missing or invalid config file."
442            },
443            {
444                "kind": "conflict",
445                "exit_code": 5,
446                "retryable": false,
447                "description": "The operation conflicts with existing data (e.g. duplicate document)."
448            },
449            {
450                "kind": "confirmation_required",
451                "exit_code": 2,
452                "retryable": false,
453                "description": "A mutating command was invoked non-interactively without --yes."
454            },
455            {
456                "kind": "error",
457                "exit_code": 1,
458                "retryable": false,
459                "description": "An unexpected error occurred."
460            }
461        ]
462    })
463}
464
465pub fn print_schema() {
466    let schema = generate();
467    println!(
468        "{}",
469        serde_json::to_string_pretty(&schema).expect("serialize schema")
470    );
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use jsonschema::Validator;
477    use serde_json::Value;
478
479    #[test]
480    fn schema_is_valid_json() {
481        let schema = generate();
482        let serialized = serde_json::to_string_pretty(&schema).unwrap();
483        let _: Value = serde_json::from_str(&serialized).unwrap();
484    }
485
486    #[test]
487    fn schema_has_required_top_level_keys() {
488        let schema = generate();
489        assert!(schema.get("clispec").is_some(), "missing clispec field");
490        assert!(schema.get("name").is_some(), "missing name field");
491        assert!(schema.get("version").is_some(), "missing version field");
492        assert!(schema.get("commands").is_some(), "missing commands field");
493        assert!(schema.get("errors").is_some(), "missing errors field");
494        assert!(
495            schema.get("global_args").is_some(),
496            "missing global_args field"
497        );
498    }
499
500    #[test]
501    fn schema_clispec_version() {
502        let schema = generate();
503        assert_eq!(schema["clispec"], "0.2");
504    }
505
506    #[test]
507    fn schema_commands_is_array() {
508        let schema = generate();
509        assert!(schema["commands"].is_array(), "commands must be an array");
510    }
511
512    #[test]
513    fn schema_all_commands_have_mutating_field() {
514        let schema = generate();
515        let commands = schema["commands"].as_array().unwrap();
516        for cmd in commands {
517            let name = cmd["name"].as_str().unwrap_or("unknown");
518            assert!(
519                cmd.get("mutating").is_some(),
520                "command '{name}' is missing 'mutating' field"
521            );
522        }
523    }
524
525    #[test]
526    fn schema_errors_has_conflict_kind() {
527        let schema = generate();
528        let errors = schema["errors"].as_array().unwrap();
529        let has_conflict = errors.iter().any(|e| e["kind"] == "conflict");
530        assert!(has_conflict, "errors array must include kind='conflict'");
531    }
532
533    #[test]
534    fn schema_errors_have_exit_codes() {
535        let schema = generate();
536        let errors = schema["errors"].as_array().unwrap();
537        for err in errors {
538            let kind = err["kind"].as_str().unwrap_or("unknown");
539            assert!(
540                err.get("exit_code").is_some(),
541                "error '{kind}' is missing 'exit_code'"
542            );
543        }
544    }
545
546    #[test]
547    fn schema_global_args_has_output_flag() {
548        let schema = generate();
549        let global_args = schema["global_args"].as_array().unwrap();
550        let has_output = global_args.iter().any(|a| a["name"] == "--output");
551        assert!(has_output, "global_args must include --output flag");
552    }
553
554    #[test]
555    fn schema_global_args_has_yes_flag() {
556        let schema = generate();
557        let global_args = schema["global_args"].as_array().unwrap();
558        let has_yes = global_args.iter().any(|a| a["name"] == "--yes");
559        assert!(has_yes, "global_args must include --yes flag");
560    }
561
562    #[test]
563    fn schema_includes_leaf_commands() {
564        let schema = generate();
565        let commands = schema["commands"].as_array().unwrap();
566        let names: Vec<&str> = commands
567            .iter()
568            .map(|c| c["name"].as_str().unwrap_or(""))
569            .collect();
570        assert!(names.contains(&"admin list"), "missing 'admin list'");
571        assert!(names.contains(&"vat returns"), "missing 'vat returns'");
572        assert!(names.contains(&"invoices list"), "missing 'invoices list'");
573    }
574
575    #[test]
576    fn schema_validates_against_clispec_v02() {
577        let schema_json: Value =
578            serde_json::from_str(include_str!("../tests/fixtures/schema-v0.2.json"))
579                .expect("parse clispec schema fixture");
580
581        let validator = Validator::new(&schema_json).expect("compile clispec schema");
582        let output = generate();
583        if let Err(e) = validator.validate(&output) {
584            panic!("Schema does not validate against clispec v0.2:\n{e}");
585        }
586    }
587
588    #[test]
589    fn schema_works_without_config() {
590        // Must not panic or require any config file; generate() is purely static.
591        let schema = generate();
592        assert!(schema.get("name").is_some());
593    }
594
595    #[test]
596    fn list_commands_have_limit_flag() {
597        let schema = generate();
598        let commands = schema["commands"].as_array().unwrap();
599        let list_cmd = commands
600            .iter()
601            .find(|c| c["name"] == "contacts list")
602            .unwrap();
603        let args = list_cmd["args"].as_array().unwrap();
604        let has_limit = args.iter().any(|a| a["name"] == "--limit");
605        assert!(has_limit, "contacts list is missing --limit arg");
606    }
607
608    #[test]
609    fn list_commands_have_offset_flag() {
610        let schema = generate();
611        let commands = schema["commands"].as_array().unwrap();
612        let list_cmd = commands
613            .iter()
614            .find(|c| c["name"] == "contacts list")
615            .unwrap();
616        let args = list_cmd["args"].as_array().unwrap();
617        let has_offset = args.iter().any(|a| a["name"] == "--offset");
618        assert!(has_offset, "contacts list is missing --offset arg");
619    }
620
621    #[test]
622    fn list_commands_have_fields_flag() {
623        let schema = generate();
624        let commands = schema["commands"].as_array().unwrap();
625        let list_cmd = commands
626            .iter()
627            .find(|c| c["name"] == "contacts list")
628            .unwrap();
629        let args = list_cmd["args"].as_array().unwrap();
630        let has_fields = args.iter().any(|a| a["name"] == "--fields");
631        assert!(has_fields, "contacts list is missing --fields arg");
632    }
633
634    #[test]
635    fn output_fields_declared_on_commands() {
636        let schema = generate();
637        let commands = schema["commands"].as_array().unwrap();
638        let with_output_fields = commands
639            .iter()
640            .filter(|c| {
641                c.get("output_fields")
642                    .and_then(|f| f.as_array())
643                    .map(|a| !a.is_empty())
644                    .unwrap_or(false)
645            })
646            .count();
647        assert!(
648            with_output_fields > 0,
649            "at least some commands must have output_fields"
650        );
651    }
652}