Skip to main content

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}