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 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}