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}