dynamic_cli/config/schema.rs
1//! Configuration schema definitions
2//!
3//! This module defines all data structures for representing
4//! CLI/REPL configurations loaded from YAML or JSON files.
5//!
6//! # Main Components
7//!
8//! - [`CommandsConfig`]: Root configuration structure
9//! - [`CommandDefinition`]: Individual command specification
10//! - [`ArgumentType`]: Supported argument types
11//! - [`ValidationRule`]: Validation constraints
12
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16/// Complete configuration for CLI/REPL commands
17///
18/// This is the root structure deserialized from YAML/JSON files.
19/// It contains metadata about the interface and all command definitions.
20///
21/// # Example YAML
22///
23/// ```yaml
24/// metadata:
25/// version: "1.0.0"
26/// prompt: "myapp"
27/// prompt_suffix: " > "
28/// commands:
29/// - name: hello
30/// description: "Say hello"
31/// # ... more fields
32/// global_options: []
33/// ```
34#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
35pub struct CommandsConfig {
36 /// Metadata about the application interface
37 pub metadata: Metadata,
38
39 /// List of all available commands
40 pub commands: Vec<CommandDefinition>,
41
42 /// Global options available to all commands
43 #[serde(default)]
44 pub global_options: Vec<OptionDefinition>,
45}
46
47/// Metadata for the CLI/REPL interface
48///
49/// Contains information about the application version
50/// and prompt customization for REPL mode.
51///
52/// # Fields
53///
54/// - `version`: Application version string
55/// - `prompt`: Command prompt prefix (e.g., "myapp")
56/// - `prompt_suffix`: Suffix after prompt (e.g., " > ")
57#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
58pub struct Metadata {
59 /// Application version (e.g., "1.0.0")
60 pub version: String,
61
62 /// Prompt prefix displayed in REPL mode
63 ///
64 /// Example: "chrom-rs" will display as "chrom-rs > "
65 pub prompt: String,
66
67 /// Prompt suffix (typically " > " or ": ")
68 #[serde(default = "default_prompt_suffix")]
69 pub prompt_suffix: String,
70}
71
72/// Default prompt suffix
73///
74/// `pub(crate)` (not private) so [`crate::interface::ReplInterface::new`] can
75/// reuse the exact same default when constructed without a config — a single
76/// source of truth for the `" > "` literal, rather than duplicating it.
77pub(crate) fn default_prompt_suffix() -> String {
78 " > ".to_string()
79}
80
81/// Definition of a single command
82///
83/// Describes a command with its arguments, options, and validation rules.
84/// Each command must have a corresponding handler implementation.
85///
86/// # Example
87///
88/// ```yaml
89/// name: simulate
90/// aliases: [sim, run]
91/// description: "Run a simulation"
92/// required: true
93/// arguments:
94/// - name: input_file
95/// arg_type: path
96/// required: true
97/// description: "Input configuration file"
98/// validation:
99/// - must_exist: true
100/// - extensions: [yaml, json]
101/// options: []
102/// implementation: "simulate_handler"
103/// continue_on_failure: false
104/// requires_success: false
105/// ```
106#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
107pub struct CommandDefinition {
108 /// Command name (used for invocation)
109 pub name: String,
110
111 /// Alternative names for the command
112 #[serde(default)]
113 pub aliases: Vec<String>,
114
115 /// Human-readable description for help text
116 pub description: String,
117
118 /// Whether this command is required to be implemented
119 ///
120 /// If true, the application will fail to start if no handler is registered.
121 #[serde(default)]
122 pub required: bool,
123
124 /// Positional arguments
125 #[serde(default)]
126 pub arguments: Vec<ArgumentDefinition>,
127
128 /// Named options (flags)
129 #[serde(default)]
130 pub options: Vec<OptionDefinition>,
131
132 /// Name of the handler implementation
133 ///
134 /// This string is used to match the command with its
135 /// registered handler in the CommandRegistry.
136 pub implementation: String,
137
138 /// Whether the chain continues when *this* command fails (DD-026).
139 ///
140 /// Governs the chain's behaviour when this command is one segment of a
141 /// multi-command CLI invocation (or a chained `run_script()` line) and
142 /// its handler returns an error:
143 ///
144 /// - `false` (default): stop-on-first-error — identical to today's
145 /// single-command exit behaviour, just applied per segment.
146 /// - `true`: the chain-position error is reported but execution
147 /// proceeds to the next segment. Intended for genuinely optional
148 /// steps (e.g. a non-critical secondary export).
149 ///
150 /// Has no effect outside a chain (a single, non-chained command
151 /// behaves exactly as it does today regardless of this value).
152 #[serde(default)]
153 pub continue_on_failure: bool,
154
155 /// Whether *this* command requires every earlier command in the chain
156 /// to have succeeded before it is allowed to run (DD-026).
157 ///
158 /// Not to be confused with [`Self::required`], which is an unrelated,
159 /// startup-time check ("a handler must be registered for this
160 /// command") — `requires_success` is a per-invocation, chain-position
161 /// concern evaluated at dispatch time, and only observable downstream
162 /// of an earlier command whose own `continue_on_failure` is `true`
163 /// (otherwise the chain has already stopped by the time this command
164 /// would run).
165 ///
166 /// - `false` (default): runs regardless of any earlier failure in the
167 /// chain (moot under the default stop-on-first policy).
168 /// - `true`: if any earlier command in the chain has already failed
169 /// (tracked via a running `chain_has_failure` flag, regardless of
170 /// that failure's own `continue_on_failure` value), this command is
171 /// skipped — not executed, not counted as an additional failure —
172 /// and reported distinctly from a chain-position error.
173 #[serde(default)]
174 pub requires_success: bool,
175}
176
177/// Definition of a positional argument
178///
179/// Positional arguments are required in order and don't have
180/// a flag prefix (unlike options).
181///
182/// # Example
183///
184/// ```yaml
185/// name: input_file
186/// arg_type: path
187/// required: true
188/// description: "Path to input file"
189/// validation:
190/// - must_exist: true
191/// - extensions: [yaml, yml]
192/// ```
193///
194/// To prevent a sensitive argument value from being written to the REPL
195/// history file, set `secure: true`:
196///
197/// ```yaml
198/// name: password
199/// arg_type: string
200/// required: true
201/// description: "User password"
202/// secure: true
203/// ```
204///
205/// When a command line contains at least one argument marked `secure: true`,
206/// the entire line is silently omitted from the history file. The command
207/// name itself is not filtered — only lines with a secure argument value
208/// are suppressed.
209#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
210pub struct ArgumentDefinition {
211 /// Argument name (used in error messages and documentation)
212 pub name: String,
213
214 /// Expected type of the argument
215 pub arg_type: ArgumentType,
216
217 /// Whether the argument is mandatory
218 pub required: bool,
219
220 /// Human-readable description
221 pub description: String,
222
223 /// Validation rules to apply
224 #[serde(default)]
225 pub validation: Vec<ValidationRule>,
226
227 /// Whether this argument carries a sensitive value.
228 ///
229 /// When `true`, any REPL command line that provides a value for this
230 /// argument is not written to the history file. Defaults to `false`.
231 #[serde(default)]
232 pub secure: bool,
233}
234
235/// Definition of a named option (flag)
236///
237/// Options are optional (by default) and can be specified
238/// with short (`-o`) or long (`--option`) forms.
239///
240/// # Example
241///
242/// ```yaml
243/// name: output
244/// short: o
245/// long: output
246/// option_type: path
247/// required: false
248/// default: "output.txt"
249/// description: "Output file path"
250/// choices: []
251/// ```
252#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
253pub struct OptionDefinition {
254 /// Option name (internal identifier)
255 pub name: String,
256
257 /// Short form (single character, e.g., "o" for -o)
258 pub short: Option<String>,
259
260 /// Long form (e.g., "output" for --output)
261 pub long: Option<String>,
262
263 /// Expected type of the option value
264 pub option_type: ArgumentType,
265
266 /// Whether this option is mandatory
267 #[serde(default)]
268 pub required: bool,
269
270 /// Default value if not specified
271 pub default: Option<String>,
272
273 /// Human-readable description
274 pub description: String,
275
276 /// Restricted set of allowed values
277 ///
278 /// If non-empty, the value must be one of these choices. When
279 /// `repeatable` is `true`, this list also doubles as the set of
280 /// valid discriminants for each occurrence (see [`Self::option_parameters`]).
281 #[serde(default)]
282 pub choices: Vec<String>,
283
284 /// Whether this option can appear more than once on a single
285 /// command line, each occurrence carrying its own discriminant
286 /// (from `choices`) and `key=value` sub-parameters.
287 ///
288 /// Defaults to `false` — fully backward-compatible with existing
289 /// scalar options.
290 #[serde(default)]
291 pub repeatable: bool,
292
293 /// Per-discriminant shape of the `key=value` sub-parameters accepted
294 /// by a repeatable option.
295 ///
296 /// Keys must exactly match the entries in [`Self::choices`] (enforced
297 /// by the config validator, not at deserialization time). Each value
298 /// reuses [`ArgumentDefinition`] — the same vocabulary already used
299 /// for positional `arguments:` — rather than a dedicated mini-schema
300 /// type.
301 ///
302 /// Ignored (and should be empty) when `repeatable` is `false`.
303 #[serde(default)]
304 pub option_parameters: HashMap<String, Vec<ArgumentDefinition>>,
305}
306
307/// Supported argument and option types
308///
309/// These types are used for automatic parsing and validation
310/// of user input.
311///
312/// # Serialization
313///
314/// Types are serialized as lowercase strings in YAML/JSON:
315/// - `String` → "string"
316/// - `Integer` → "integer"
317/// - `Float` → "float"
318/// - `Bool` → "bool"
319/// - `Path` → "path"
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
321#[serde(rename_all = "lowercase")]
322pub enum ArgumentType {
323 /// UTF-8 string
324 String,
325
326 /// Signed integer (i64)
327 Integer,
328
329 /// Floating-point number (f64)
330 Float,
331
332 /// Boolean value (true/false, yes/no, 1/0)
333 Bool,
334
335 /// File system path
336 ///
337 /// Represents a path that may or may not exist,
338 /// depending on validation rules.
339 Path,
340}
341
342impl ArgumentType {
343 /// Get the type name as a string for error messages
344 ///
345 /// # Example
346 ///
347 /// ```
348 /// use dynamic_cli::config::schema::ArgumentType;
349 ///
350 /// assert_eq!(ArgumentType::Integer.as_str(), "integer");
351 /// assert_eq!(ArgumentType::Path.as_str(), "path");
352 /// ```
353 pub fn as_str(&self) -> &'static str {
354 match self {
355 ArgumentType::String => "string",
356 ArgumentType::Integer => "integer",
357 ArgumentType::Float => "float",
358 ArgumentType::Bool => "bool",
359 ArgumentType::Path => "path",
360 }
361 }
362}
363
364/// Validation rules for arguments and options
365///
366/// These rules are applied after type parsing to enforce
367/// additional constraints on values.
368///
369/// # Variants
370///
371/// - `MustExist`: For paths, require that the file/directory exists
372/// - `Extensions`: For paths, restrict to specific file extensions
373/// - `Range`: For numbers, enforce min/max bounds
374///
375/// # Serialization
376///
377/// Rules use untagged enum serialization:
378///
379/// ```yaml
380/// # MustExist
381/// - must_exist: true
382///
383/// # Extensions
384/// - extensions: [yaml, yml, json]
385///
386/// # Range
387/// - min: 0.0
388/// max: 100.0
389/// ```
390#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
391#[serde(untagged)]
392pub enum ValidationRule {
393 /// Require that a path exists on the file system
394 MustExist { must_exist: bool },
395
396 /// Restrict file extensions (for path arguments)
397 ///
398 /// Extensions should be specified without the leading dot.
399 /// Example: `["yaml", "yml"]` matches "config.yaml" and "data.yml"
400 Extensions { extensions: Vec<String> },
401
402 /// Enforce numeric range constraints
403 ///
404 /// Either or both bounds can be specified:
405 /// - `min: Some(0.0), max: None` → x ≥ 0
406 /// - `min: None, max: Some(100.0)` → x ≤ 100
407 /// - `min: Some(0.0), max: Some(100.0)` → 0 ≤ x ≤ 100
408 Range { min: Option<f64>, max: Option<f64> },
409}
410
411impl CommandsConfig {
412 /// Create a minimal valid configuration for testing
413 ///
414 /// This is useful for unit tests and examples.
415 ///
416 /// # Example
417 ///
418 /// ```
419 /// use dynamic_cli::config::schema::CommandsConfig;
420 ///
421 /// let config = CommandsConfig::minimal();
422 /// assert_eq!(config.metadata.version, "0.1.0");
423 /// assert!(config.commands.is_empty());
424 /// ```
425 #[cfg(test)]
426 pub fn minimal() -> Self {
427 Self {
428 metadata: Metadata {
429 version: "0.1.0".to_string(),
430 prompt: "test".to_string(),
431 prompt_suffix: " > ".to_string(),
432 },
433 commands: vec![],
434 global_options: vec![],
435 }
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 #[test]
444 fn test_argument_type_as_str() {
445 assert_eq!(ArgumentType::String.as_str(), "string");
446 assert_eq!(ArgumentType::Integer.as_str(), "integer");
447 assert_eq!(ArgumentType::Float.as_str(), "float");
448 assert_eq!(ArgumentType::Bool.as_str(), "bool");
449 assert_eq!(ArgumentType::Path.as_str(), "path");
450 }
451
452 #[test]
453 fn test_default_prompt_suffix() {
454 assert_eq!(default_prompt_suffix(), " > ");
455 }
456
457 #[test]
458 fn test_minimal_config() {
459 let config = CommandsConfig::minimal();
460
461 assert_eq!(config.metadata.version, "0.1.0");
462 assert_eq!(config.metadata.prompt, "test");
463 assert_eq!(config.metadata.prompt_suffix, " > ");
464 assert!(config.commands.is_empty());
465 assert!(config.global_options.is_empty());
466 }
467
468 #[test]
469 fn test_deserialize_argument_type() {
470 // Test YAML deserialization of ArgumentType
471 let yaml = r#"
472 type: string
473 "#;
474
475 #[derive(Deserialize)]
476 struct TestStruct {
477 #[serde(rename = "type")]
478 type_field: ArgumentType,
479 }
480
481 let result: TestStruct = serde_yaml::from_str(yaml).unwrap();
482 assert_eq!(result.type_field, ArgumentType::String);
483 }
484
485 #[test]
486 fn test_deserialize_metadata() {
487 let yaml = r#"
488 version: "1.0.0"
489 prompt: "myapp"
490 prompt_suffix: " $ "
491 "#;
492
493 let metadata: Metadata = serde_yaml::from_str(yaml).unwrap();
494
495 assert_eq!(metadata.version, "1.0.0");
496 assert_eq!(metadata.prompt, "myapp");
497 assert_eq!(metadata.prompt_suffix, " $ ");
498 }
499
500 #[test]
501 fn test_deserialize_metadata_with_default() {
502 // Test that prompt_suffix gets default value if not specified
503 let yaml = r#"
504 version: "1.0.0"
505 prompt: "myapp"
506 "#;
507
508 let metadata: Metadata = serde_yaml::from_str(yaml).unwrap();
509
510 assert_eq!(metadata.prompt_suffix, " > ");
511 }
512
513 #[test]
514 fn test_deserialize_command_definition() {
515 let yaml = r#"
516 name: test_cmd
517 aliases: [tc, test]
518 description: "A test command"
519 required: true
520 arguments: []
521 options: []
522 implementation: "test_handler"
523 "#;
524
525 let cmd: CommandDefinition = serde_yaml::from_str(yaml).unwrap();
526
527 assert_eq!(cmd.name, "test_cmd");
528 assert_eq!(cmd.aliases, vec!["tc", "test"]);
529 assert_eq!(cmd.description, "A test command");
530 assert!(cmd.required);
531 assert_eq!(cmd.implementation, "test_handler");
532 // continue_on_failure / requires_success absent from YAML: both
533 // must default to false (DD-026, #53).
534 assert!(!cmd.continue_on_failure);
535 assert!(!cmd.requires_success);
536 }
537
538 #[test]
539 fn test_deserialize_command_definition_continue_on_failure_and_requires_success_default_false()
540 {
541 // Explicit companion to test_deserialize_command_definition,
542 // mirroring the secure-field default test below: confirms serde's
543 // #[serde(default)] applies even when the fields are wholly absent
544 // from an otherwise-complete config, not just incidentally true in
545 // the general fixture above.
546 let yaml = r#"
547 name: solve
548 description: "Run the solver"
549 implementation: "solve_handler"
550 "#;
551
552 let cmd: CommandDefinition = serde_yaml::from_str(yaml).unwrap();
553
554 assert!(!cmd.continue_on_failure);
555 assert!(!cmd.requires_success);
556 }
557
558 #[test]
559 fn test_deserialize_command_definition_continue_on_failure_and_requires_success_explicit() {
560 let yaml = r#"
561 name: export
562 description: "Optional secondary export"
563 implementation: "export_handler"
564 continue_on_failure: true
565 requires_success: true
566 "#;
567
568 let cmd: CommandDefinition = serde_yaml::from_str(yaml).unwrap();
569
570 assert!(cmd.continue_on_failure);
571 assert!(cmd.requires_success);
572 }
573
574 #[test]
575 fn test_command_definition_requires_success_distinct_from_required() {
576 // Guards against the two fields being confused with one another
577 // (DD-026): `required` is the pre-existing "handler must be
578 // registered at startup" check; `requires_success` is the new,
579 // unrelated per-invocation chain guard.
580 let yaml = r#"
581 name: cleanup
582 description: "Cleanup step"
583 required: true
584 implementation: "cleanup_handler"
585 requires_success: false
586 "#;
587
588 let cmd: CommandDefinition = serde_yaml::from_str(yaml).unwrap();
589
590 assert!(cmd.required);
591 assert!(!cmd.requires_success);
592 }
593
594 #[test]
595 fn test_deserialize_argument_definition() {
596 let yaml = r#"
597 name: input_file
598 arg_type: path
599 required: true
600 description: "Input file"
601 validation:
602 - must_exist: true
603 - extensions: [yaml, yml]
604 "#;
605
606 let arg: ArgumentDefinition = serde_yaml::from_str(yaml).unwrap();
607
608 assert_eq!(arg.name, "input_file");
609 assert_eq!(arg.arg_type, ArgumentType::Path);
610 assert!(arg.required);
611 assert_eq!(arg.description, "Input file");
612 assert_eq!(arg.validation.len(), 2);
613 // secure defaults to false when absent from YAML
614 assert!(!arg.secure);
615 }
616
617 #[test]
618 fn test_deserialize_argument_definition_secure_default_false() {
619 // When `secure` is absent from the YAML, serde must default to false.
620 let yaml = r#"
621 name: username
622 arg_type: string
623 required: true
624 description: "User name"
625 "#;
626
627 let arg: ArgumentDefinition = serde_yaml::from_str(yaml).unwrap();
628 assert!(!arg.secure, "secure must default to false when absent");
629 }
630
631 #[test]
632 fn test_deserialize_argument_definition_secure_true() {
633 // When `secure: true` is present, it must be deserialised correctly.
634 let yaml = r#"
635 name: password
636 arg_type: string
637 required: true
638 description: "User password"
639 secure: true
640 "#;
641
642 let arg: ArgumentDefinition = serde_yaml::from_str(yaml).unwrap();
643 assert!(arg.secure, "secure must be true when set in YAML");
644 }
645
646 #[test]
647 fn test_deserialize_argument_definition_secure_false_explicit() {
648 // Explicit `secure: false` must round-trip correctly.
649 let yaml = r#"
650 name: output
651 arg_type: path
652 required: false
653 description: "Output path"
654 secure: false
655 "#;
656
657 let arg: ArgumentDefinition = serde_yaml::from_str(yaml).unwrap();
658 assert!(!arg.secure);
659 }
660
661 #[test]
662 fn test_serialize_argument_definition_secure_roundtrip() {
663 let original = ArgumentDefinition {
664 name: "secret".to_string(),
665 arg_type: ArgumentType::String,
666 required: true,
667 description: "A secret value".to_string(),
668 validation: vec![],
669 secure: true,
670 };
671
672 let yaml = serde_yaml::to_string(&original).unwrap();
673 let deserialized: ArgumentDefinition = serde_yaml::from_str(&yaml).unwrap();
674
675 assert_eq!(original, deserialized);
676 assert!(deserialized.secure);
677 }
678
679 #[test]
680 fn test_deserialize_option_definition() {
681 let yaml = r#"
682 name: output
683 short: o
684 long: output
685 option_type: path
686 required: false
687 default: "out.txt"
688 description: "Output file"
689 choices: []
690 "#;
691
692 let opt: OptionDefinition = serde_yaml::from_str(yaml).unwrap();
693
694 assert_eq!(opt.name, "output");
695 assert_eq!(opt.short, Some("o".to_string()));
696 assert_eq!(opt.long, Some("output".to_string()));
697 assert_eq!(opt.option_type, ArgumentType::Path);
698 assert!(!opt.required);
699 assert_eq!(opt.default, Some("out.txt".to_string()));
700 }
701
702 #[test]
703 fn test_deserialize_validation_rule_must_exist() {
704 let yaml = r#"
705 must_exist: true
706 "#;
707
708 let rule: ValidationRule = serde_yaml::from_str(yaml).unwrap();
709
710 assert_eq!(rule, ValidationRule::MustExist { must_exist: true });
711 }
712
713 #[test]
714 fn test_deserialize_validation_rule_extensions() {
715 let yaml = r#"
716 extensions: [yaml, yml, json]
717 "#;
718
719 let rule: ValidationRule = serde_yaml::from_str(yaml).unwrap();
720
721 match rule {
722 ValidationRule::Extensions { extensions } => {
723 assert_eq!(extensions, vec!["yaml", "yml", "json"]);
724 }
725 _ => panic!("Wrong variant"),
726 }
727 }
728
729 #[test]
730 fn test_deserialize_validation_rule_range() {
731 let yaml = r#"
732 min: 0.0
733 max: 100.0
734 "#;
735
736 let rule: ValidationRule = serde_yaml::from_str(yaml).unwrap();
737
738 match rule {
739 ValidationRule::Range { min, max } => {
740 assert_eq!(min, Some(0.0));
741 assert_eq!(max, Some(100.0));
742 }
743 _ => panic!("Wrong variant"),
744 }
745 }
746
747 #[test]
748 fn test_deserialize_full_config() {
749 let yaml = r#"
750 metadata:
751 version: "1.0.0"
752 prompt: "test"
753 prompt_suffix: " > "
754 commands:
755 - name: hello
756 aliases: []
757 description: "Say hello"
758 required: false
759 arguments: []
760 options: []
761 implementation: "hello_handler"
762 global_options: []
763 "#;
764
765 let config: CommandsConfig = serde_yaml::from_str(yaml).unwrap();
766
767 assert_eq!(config.metadata.version, "1.0.0");
768 assert_eq!(config.commands.len(), 1);
769 assert_eq!(config.commands[0].name, "hello");
770 }
771
772 #[test]
773 fn test_serialize_and_deserialize_roundtrip() {
774 let original = CommandsConfig {
775 metadata: Metadata {
776 version: "1.0.0".to_string(),
777 prompt: "test".to_string(),
778 prompt_suffix: " > ".to_string(),
779 },
780 commands: vec![CommandDefinition {
781 name: "cmd1".to_string(),
782 aliases: vec!["c1".to_string()],
783 description: "Test command".to_string(),
784 required: true,
785 arguments: vec![],
786 options: vec![],
787 implementation: "handler1".to_string(),
788 continue_on_failure: false,
789 requires_success: false,
790 }],
791 global_options: vec![],
792 };
793
794 // Serialize to YAML
795 let yaml = serde_yaml::to_string(&original).unwrap();
796
797 // Deserialize back
798 let deserialized: CommandsConfig = serde_yaml::from_str(&yaml).unwrap();
799
800 assert_eq!(original, deserialized);
801 }
802
803 #[test]
804 fn test_json_deserialization() {
805 let json = r#"
806 {
807 "metadata": {
808 "version": "1.0.0",
809 "prompt": "test",
810 "prompt_suffix": " > "
811 },
812 "commands": [],
813 "global_options": []
814 }
815 "#;
816
817 let config: CommandsConfig = serde_json::from_str(json).unwrap();
818
819 assert_eq!(config.metadata.version, "1.0.0");
820 assert_eq!(config.commands.len(), 0);
821 }
822}