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