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