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": "confirmation_required",
445                "exit_code": 1,
446                "retryable": false,
447                "description": "A mutating command was invoked non-interactively without --yes."
448            },
449            {
450                "kind": "error",
451                "exit_code": 1,
452                "retryable": false,
453                "description": "An unexpected error occurred."
454            }
455        ]
456    })
457}
458
459pub fn print_schema() {
460    let schema = generate();
461    println!(
462        "{}",
463        serde_json::to_string_pretty(&schema).expect("serialize schema")
464    );
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use jsonschema::Validator;
471    use serde_json::Value;
472
473    #[test]
474    fn schema_is_valid_json() {
475        let schema = generate();
476        let serialized = serde_json::to_string_pretty(&schema).unwrap();
477        let _: Value = serde_json::from_str(&serialized).unwrap();
478    }
479
480    #[test]
481    fn schema_has_required_top_level_keys() {
482        let schema = generate();
483        assert!(schema.get("clispec").is_some(), "missing clispec field");
484        assert!(schema.get("name").is_some(), "missing name field");
485        assert!(schema.get("version").is_some(), "missing version field");
486        assert!(schema.get("commands").is_some(), "missing commands field");
487        assert!(schema.get("errors").is_some(), "missing errors field");
488        assert!(
489            schema.get("global_args").is_some(),
490            "missing global_args field"
491        );
492    }
493
494    #[test]
495    fn schema_clispec_version() {
496        let schema = generate();
497        assert_eq!(schema["clispec"], "0.2");
498    }
499
500    #[test]
501    fn schema_commands_is_array() {
502        let schema = generate();
503        assert!(schema["commands"].is_array(), "commands must be an array");
504    }
505
506    #[test]
507    fn schema_all_commands_have_mutating_field() {
508        let schema = generate();
509        let commands = schema["commands"].as_array().unwrap();
510        for cmd in commands {
511            let name = cmd["name"].as_str().unwrap_or("unknown");
512            assert!(
513                cmd.get("mutating").is_some(),
514                "command '{name}' is missing 'mutating' field"
515            );
516        }
517    }
518
519    #[test]
520    fn schema_errors_have_exit_codes() {
521        let schema = generate();
522        let errors = schema["errors"].as_array().unwrap();
523        for err in errors {
524            let kind = err["kind"].as_str().unwrap_or("unknown");
525            assert!(
526                err.get("exit_code").is_some(),
527                "error '{kind}' is missing 'exit_code'"
528            );
529        }
530    }
531
532    #[test]
533    fn schema_global_args_has_output_flag() {
534        let schema = generate();
535        let global_args = schema["global_args"].as_array().unwrap();
536        let has_output = global_args.iter().any(|a| a["name"] == "--output");
537        assert!(has_output, "global_args must include --output flag");
538    }
539
540    #[test]
541    fn schema_global_args_has_yes_flag() {
542        let schema = generate();
543        let global_args = schema["global_args"].as_array().unwrap();
544        let has_yes = global_args.iter().any(|a| a["name"] == "--yes");
545        assert!(has_yes, "global_args must include --yes flag");
546    }
547
548    #[test]
549    fn schema_includes_leaf_commands() {
550        let schema = generate();
551        let commands = schema["commands"].as_array().unwrap();
552        let names: Vec<&str> = commands
553            .iter()
554            .map(|c| c["name"].as_str().unwrap_or(""))
555            .collect();
556        assert!(names.contains(&"admin list"), "missing 'admin list'");
557        assert!(names.contains(&"vat returns"), "missing 'vat returns'");
558        assert!(names.contains(&"invoices list"), "missing 'invoices list'");
559    }
560
561    #[test]
562    fn schema_validates_against_clispec_v02() {
563        let schema_json: Value =
564            serde_json::from_str(include_str!("../tests/fixtures/schema-v0.2.json"))
565                .expect("parse clispec schema fixture");
566
567        let validator = Validator::new(&schema_json).expect("compile clispec schema");
568        let output = generate();
569        if let Err(e) = validator.validate(&output) {
570            panic!("Schema does not validate against clispec v0.2:\n{e}");
571        }
572    }
573
574    #[test]
575    fn schema_works_without_config() {
576        // Must not panic or require any config file; generate() is purely static.
577        let schema = generate();
578        assert!(schema.get("name").is_some());
579    }
580
581    #[test]
582    fn list_commands_have_limit_flag() {
583        let schema = generate();
584        let commands = schema["commands"].as_array().unwrap();
585        let list_cmd = commands
586            .iter()
587            .find(|c| c["name"] == "contacts list")
588            .unwrap();
589        let args = list_cmd["args"].as_array().unwrap();
590        let has_limit = args.iter().any(|a| a["name"] == "--limit");
591        assert!(has_limit, "contacts list is missing --limit arg");
592    }
593
594    #[test]
595    fn list_commands_have_offset_flag() {
596        let schema = generate();
597        let commands = schema["commands"].as_array().unwrap();
598        let list_cmd = commands
599            .iter()
600            .find(|c| c["name"] == "contacts list")
601            .unwrap();
602        let args = list_cmd["args"].as_array().unwrap();
603        let has_offset = args.iter().any(|a| a["name"] == "--offset");
604        assert!(has_offset, "contacts list is missing --offset arg");
605    }
606
607    #[test]
608    fn list_commands_have_fields_flag() {
609        let schema = generate();
610        let commands = schema["commands"].as_array().unwrap();
611        let list_cmd = commands
612            .iter()
613            .find(|c| c["name"] == "contacts list")
614            .unwrap();
615        let args = list_cmd["args"].as_array().unwrap();
616        let has_fields = args.iter().any(|a| a["name"] == "--fields");
617        assert!(has_fields, "contacts list is missing --fields arg");
618    }
619
620    #[test]
621    fn output_fields_declared_on_commands() {
622        let schema = generate();
623        let commands = schema["commands"].as_array().unwrap();
624        let with_output_fields = commands
625            .iter()
626            .filter(|c| {
627                c.get("output_fields")
628                    .and_then(|f| f.as_array())
629                    .map(|a| !a.is_empty())
630                    .unwrap_or(false)
631            })
632            .count();
633        assert!(
634            with_output_fields > 0,
635            "at least some commands must have output_fields"
636        );
637    }
638}