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
73fn default_prompt_suffix() -> String {
74 " > ".to_string()
75}
76
77/// Definition of a single command
78///
79/// Describes a command with its arguments, options, and validation rules.
80/// Each command must have a corresponding handler implementation.
81///
82/// # Example
83///
84/// ```yaml
85/// name: simulate
86/// aliases: [sim, run]
87/// description: "Run a simulation"
88/// required: true
89/// arguments:
90/// - name: input_file
91/// arg_type: path
92/// required: true
93/// description: "Input configuration file"
94/// validation:
95/// - must_exist: true
96/// - extensions: [yaml, json]
97/// options: []
98/// implementation: "simulate_handler"
99/// ```
100#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
101pub struct CommandDefinition {
102 /// Command name (used for invocation)
103 pub name: String,
104
105 /// Alternative names for the command
106 #[serde(default)]
107 pub aliases: Vec<String>,
108
109 /// Human-readable description for help text
110 pub description: String,
111
112 /// Whether this command is required to be implemented
113 ///
114 /// If true, the application will fail to start if no handler is registered.
115 #[serde(default)]
116 pub required: bool,
117
118 /// Positional arguments
119 #[serde(default)]
120 pub arguments: Vec<ArgumentDefinition>,
121
122 /// Named options (flags)
123 #[serde(default)]
124 pub options: Vec<OptionDefinition>,
125
126 /// Name of the handler implementation
127 ///
128 /// This string is used to match the command with its
129 /// registered handler in the CommandRegistry.
130 pub implementation: String,
131}
132
133/// Definition of a positional argument
134///
135/// Positional arguments are required in order and don't have
136/// a flag prefix (unlike options).
137///
138/// # Example
139///
140/// ```yaml
141/// name: input_file
142/// arg_type: path
143/// required: true
144/// description: "Path to input file"
145/// validation:
146/// - must_exist: true
147/// - extensions: [yaml, yml]
148/// ```
149///
150/// To prevent a sensitive argument value from being written to the REPL
151/// history file, set `secure: true`:
152///
153/// ```yaml
154/// name: password
155/// arg_type: string
156/// required: true
157/// description: "User password"
158/// secure: true
159/// ```
160///
161/// When a command line contains at least one argument marked `secure: true`,
162/// the entire line is silently omitted from the history file. The command
163/// name itself is not filtered — only lines with a secure argument value
164/// are suppressed.
165#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
166pub struct ArgumentDefinition {
167 /// Argument name (used in error messages and documentation)
168 pub name: String,
169
170 /// Expected type of the argument
171 pub arg_type: ArgumentType,
172
173 /// Whether the argument is mandatory
174 pub required: bool,
175
176 /// Human-readable description
177 pub description: String,
178
179 /// Validation rules to apply
180 #[serde(default)]
181 pub validation: Vec<ValidationRule>,
182
183 /// Whether this argument carries a sensitive value.
184 ///
185 /// When `true`, any REPL command line that provides a value for this
186 /// argument is not written to the history file. Defaults to `false`.
187 #[serde(default)]
188 pub secure: bool,
189}
190
191/// Definition of a named option (flag)
192///
193/// Options are optional (by default) and can be specified
194/// with short (`-o`) or long (`--option`) forms.
195///
196/// # Example
197///
198/// ```yaml
199/// name: output
200/// short: o
201/// long: output
202/// option_type: path
203/// required: false
204/// default: "output.txt"
205/// description: "Output file path"
206/// choices: []
207/// ```
208#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
209pub struct OptionDefinition {
210 /// Option name (internal identifier)
211 pub name: String,
212
213 /// Short form (single character, e.g., "o" for -o)
214 pub short: Option<String>,
215
216 /// Long form (e.g., "output" for --output)
217 pub long: Option<String>,
218
219 /// Expected type of the option value
220 pub option_type: ArgumentType,
221
222 /// Whether this option is mandatory
223 #[serde(default)]
224 pub required: bool,
225
226 /// Default value if not specified
227 pub default: Option<String>,
228
229 /// Human-readable description
230 pub description: String,
231
232 /// Restricted set of allowed values
233 ///
234 /// If non-empty, the value must be one of these choices. When
235 /// `repeatable` is `true`, this list also doubles as the set of
236 /// valid discriminants for each occurrence (see [`Self::option_parameters`]).
237 #[serde(default)]
238 pub choices: Vec<String>,
239
240 /// Whether this option can appear more than once on a single
241 /// command line, each occurrence carrying its own discriminant
242 /// (from `choices`) and `key=value` sub-parameters.
243 ///
244 /// Defaults to `false` — fully backward-compatible with existing
245 /// scalar options.
246 #[serde(default)]
247 pub repeatable: bool,
248
249 /// Per-discriminant shape of the `key=value` sub-parameters accepted
250 /// by a repeatable option.
251 ///
252 /// Keys must exactly match the entries in [`Self::choices`] (enforced
253 /// by the config validator, not at deserialization time). Each value
254 /// reuses [`ArgumentDefinition`] — the same vocabulary already used
255 /// for positional `arguments:` — rather than a dedicated mini-schema
256 /// type.
257 ///
258 /// Ignored (and should be empty) when `repeatable` is `false`.
259 #[serde(default)]
260 pub option_parameters: HashMap<String, Vec<ArgumentDefinition>>,
261}
262
263/// Supported argument and option types
264///
265/// These types are used for automatic parsing and validation
266/// of user input.
267///
268/// # Serialization
269///
270/// Types are serialized as lowercase strings in YAML/JSON:
271/// - `String` → "string"
272/// - `Integer` → "integer"
273/// - `Float` → "float"
274/// - `Bool` → "bool"
275/// - `Path` → "path"
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
277#[serde(rename_all = "lowercase")]
278pub enum ArgumentType {
279 /// UTF-8 string
280 String,
281
282 /// Signed integer (i64)
283 Integer,
284
285 /// Floating-point number (f64)
286 Float,
287
288 /// Boolean value (true/false, yes/no, 1/0)
289 Bool,
290
291 /// File system path
292 ///
293 /// Represents a path that may or may not exist,
294 /// depending on validation rules.
295 Path,
296}
297
298impl ArgumentType {
299 /// Get the type name as a string for error messages
300 ///
301 /// # Example
302 ///
303 /// ```
304 /// use dynamic_cli::config::schema::ArgumentType;
305 ///
306 /// assert_eq!(ArgumentType::Integer.as_str(), "integer");
307 /// assert_eq!(ArgumentType::Path.as_str(), "path");
308 /// ```
309 pub fn as_str(&self) -> &'static str {
310 match self {
311 ArgumentType::String => "string",
312 ArgumentType::Integer => "integer",
313 ArgumentType::Float => "float",
314 ArgumentType::Bool => "bool",
315 ArgumentType::Path => "path",
316 }
317 }
318}
319
320/// Validation rules for arguments and options
321///
322/// These rules are applied after type parsing to enforce
323/// additional constraints on values.
324///
325/// # Variants
326///
327/// - `MustExist`: For paths, require that the file/directory exists
328/// - `Extensions`: For paths, restrict to specific file extensions
329/// - `Range`: For numbers, enforce min/max bounds
330///
331/// # Serialization
332///
333/// Rules use untagged enum serialization:
334///
335/// ```yaml
336/// # MustExist
337/// - must_exist: true
338///
339/// # Extensions
340/// - extensions: [yaml, yml, json]
341///
342/// # Range
343/// - min: 0.0
344/// max: 100.0
345/// ```
346#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
347#[serde(untagged)]
348pub enum ValidationRule {
349 /// Require that a path exists on the file system
350 MustExist { must_exist: bool },
351
352 /// Restrict file extensions (for path arguments)
353 ///
354 /// Extensions should be specified without the leading dot.
355 /// Example: `["yaml", "yml"]` matches "config.yaml" and "data.yml"
356 Extensions { extensions: Vec<String> },
357
358 /// Enforce numeric range constraints
359 ///
360 /// Either or both bounds can be specified:
361 /// - `min: Some(0.0), max: None` → x ≥ 0
362 /// - `min: None, max: Some(100.0)` → x ≤ 100
363 /// - `min: Some(0.0), max: Some(100.0)` → 0 ≤ x ≤ 100
364 Range { min: Option<f64>, max: Option<f64> },
365}
366
367impl CommandsConfig {
368 /// Create a minimal valid configuration for testing
369 ///
370 /// This is useful for unit tests and examples.
371 ///
372 /// # Example
373 ///
374 /// ```
375 /// use dynamic_cli::config::schema::CommandsConfig;
376 ///
377 /// let config = CommandsConfig::minimal();
378 /// assert_eq!(config.metadata.version, "0.1.0");
379 /// assert!(config.commands.is_empty());
380 /// ```
381 #[cfg(test)]
382 pub fn minimal() -> Self {
383 Self {
384 metadata: Metadata {
385 version: "0.1.0".to_string(),
386 prompt: "test".to_string(),
387 prompt_suffix: " > ".to_string(),
388 },
389 commands: vec![],
390 global_options: vec![],
391 }
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn test_argument_type_as_str() {
401 assert_eq!(ArgumentType::String.as_str(), "string");
402 assert_eq!(ArgumentType::Integer.as_str(), "integer");
403 assert_eq!(ArgumentType::Float.as_str(), "float");
404 assert_eq!(ArgumentType::Bool.as_str(), "bool");
405 assert_eq!(ArgumentType::Path.as_str(), "path");
406 }
407
408 #[test]
409 fn test_default_prompt_suffix() {
410 assert_eq!(default_prompt_suffix(), " > ");
411 }
412
413 #[test]
414 fn test_minimal_config() {
415 let config = CommandsConfig::minimal();
416
417 assert_eq!(config.metadata.version, "0.1.0");
418 assert_eq!(config.metadata.prompt, "test");
419 assert_eq!(config.metadata.prompt_suffix, " > ");
420 assert!(config.commands.is_empty());
421 assert!(config.global_options.is_empty());
422 }
423
424 #[test]
425 fn test_deserialize_argument_type() {
426 // Test YAML deserialization of ArgumentType
427 let yaml = r#"
428 type: string
429 "#;
430
431 #[derive(Deserialize)]
432 struct TestStruct {
433 #[serde(rename = "type")]
434 type_field: ArgumentType,
435 }
436
437 let result: TestStruct = serde_yaml::from_str(yaml).unwrap();
438 assert_eq!(result.type_field, ArgumentType::String);
439 }
440
441 #[test]
442 fn test_deserialize_metadata() {
443 let yaml = r#"
444 version: "1.0.0"
445 prompt: "myapp"
446 prompt_suffix: " $ "
447 "#;
448
449 let metadata: Metadata = serde_yaml::from_str(yaml).unwrap();
450
451 assert_eq!(metadata.version, "1.0.0");
452 assert_eq!(metadata.prompt, "myapp");
453 assert_eq!(metadata.prompt_suffix, " $ ");
454 }
455
456 #[test]
457 fn test_deserialize_metadata_with_default() {
458 // Test that prompt_suffix gets default value if not specified
459 let yaml = r#"
460 version: "1.0.0"
461 prompt: "myapp"
462 "#;
463
464 let metadata: Metadata = serde_yaml::from_str(yaml).unwrap();
465
466 assert_eq!(metadata.prompt_suffix, " > ");
467 }
468
469 #[test]
470 fn test_deserialize_command_definition() {
471 let yaml = r#"
472 name: test_cmd
473 aliases: [tc, test]
474 description: "A test command"
475 required: true
476 arguments: []
477 options: []
478 implementation: "test_handler"
479 "#;
480
481 let cmd: CommandDefinition = serde_yaml::from_str(yaml).unwrap();
482
483 assert_eq!(cmd.name, "test_cmd");
484 assert_eq!(cmd.aliases, vec!["tc", "test"]);
485 assert_eq!(cmd.description, "A test command");
486 assert!(cmd.required);
487 assert_eq!(cmd.implementation, "test_handler");
488 }
489
490 #[test]
491 fn test_deserialize_argument_definition() {
492 let yaml = r#"
493 name: input_file
494 arg_type: path
495 required: true
496 description: "Input file"
497 validation:
498 - must_exist: true
499 - extensions: [yaml, yml]
500 "#;
501
502 let arg: ArgumentDefinition = serde_yaml::from_str(yaml).unwrap();
503
504 assert_eq!(arg.name, "input_file");
505 assert_eq!(arg.arg_type, ArgumentType::Path);
506 assert!(arg.required);
507 assert_eq!(arg.description, "Input file");
508 assert_eq!(arg.validation.len(), 2);
509 // secure defaults to false when absent from YAML
510 assert!(!arg.secure);
511 }
512
513 #[test]
514 fn test_deserialize_argument_definition_secure_default_false() {
515 // When `secure` is absent from the YAML, serde must default to false.
516 let yaml = r#"
517 name: username
518 arg_type: string
519 required: true
520 description: "User name"
521 "#;
522
523 let arg: ArgumentDefinition = serde_yaml::from_str(yaml).unwrap();
524 assert!(!arg.secure, "secure must default to false when absent");
525 }
526
527 #[test]
528 fn test_deserialize_argument_definition_secure_true() {
529 // When `secure: true` is present, it must be deserialised correctly.
530 let yaml = r#"
531 name: password
532 arg_type: string
533 required: true
534 description: "User password"
535 secure: true
536 "#;
537
538 let arg: ArgumentDefinition = serde_yaml::from_str(yaml).unwrap();
539 assert!(arg.secure, "secure must be true when set in YAML");
540 }
541
542 #[test]
543 fn test_deserialize_argument_definition_secure_false_explicit() {
544 // Explicit `secure: false` must round-trip correctly.
545 let yaml = r#"
546 name: output
547 arg_type: path
548 required: false
549 description: "Output path"
550 secure: false
551 "#;
552
553 let arg: ArgumentDefinition = serde_yaml::from_str(yaml).unwrap();
554 assert!(!arg.secure);
555 }
556
557 #[test]
558 fn test_serialize_argument_definition_secure_roundtrip() {
559 let original = ArgumentDefinition {
560 name: "secret".to_string(),
561 arg_type: ArgumentType::String,
562 required: true,
563 description: "A secret value".to_string(),
564 validation: vec![],
565 secure: true,
566 };
567
568 let yaml = serde_yaml::to_string(&original).unwrap();
569 let deserialized: ArgumentDefinition = serde_yaml::from_str(&yaml).unwrap();
570
571 assert_eq!(original, deserialized);
572 assert!(deserialized.secure);
573 }
574
575 #[test]
576 fn test_deserialize_option_definition() {
577 let yaml = r#"
578 name: output
579 short: o
580 long: output
581 option_type: path
582 required: false
583 default: "out.txt"
584 description: "Output file"
585 choices: []
586 "#;
587
588 let opt: OptionDefinition = serde_yaml::from_str(yaml).unwrap();
589
590 assert_eq!(opt.name, "output");
591 assert_eq!(opt.short, Some("o".to_string()));
592 assert_eq!(opt.long, Some("output".to_string()));
593 assert_eq!(opt.option_type, ArgumentType::Path);
594 assert!(!opt.required);
595 assert_eq!(opt.default, Some("out.txt".to_string()));
596 }
597
598 #[test]
599 fn test_deserialize_validation_rule_must_exist() {
600 let yaml = r#"
601 must_exist: true
602 "#;
603
604 let rule: ValidationRule = serde_yaml::from_str(yaml).unwrap();
605
606 assert_eq!(rule, ValidationRule::MustExist { must_exist: true });
607 }
608
609 #[test]
610 fn test_deserialize_validation_rule_extensions() {
611 let yaml = r#"
612 extensions: [yaml, yml, json]
613 "#;
614
615 let rule: ValidationRule = serde_yaml::from_str(yaml).unwrap();
616
617 match rule {
618 ValidationRule::Extensions { extensions } => {
619 assert_eq!(extensions, vec!["yaml", "yml", "json"]);
620 }
621 _ => panic!("Wrong variant"),
622 }
623 }
624
625 #[test]
626 fn test_deserialize_validation_rule_range() {
627 let yaml = r#"
628 min: 0.0
629 max: 100.0
630 "#;
631
632 let rule: ValidationRule = serde_yaml::from_str(yaml).unwrap();
633
634 match rule {
635 ValidationRule::Range { min, max } => {
636 assert_eq!(min, Some(0.0));
637 assert_eq!(max, Some(100.0));
638 }
639 _ => panic!("Wrong variant"),
640 }
641 }
642
643 #[test]
644 fn test_deserialize_full_config() {
645 let yaml = r#"
646 metadata:
647 version: "1.0.0"
648 prompt: "test"
649 prompt_suffix: " > "
650 commands:
651 - name: hello
652 aliases: []
653 description: "Say hello"
654 required: false
655 arguments: []
656 options: []
657 implementation: "hello_handler"
658 global_options: []
659 "#;
660
661 let config: CommandsConfig = serde_yaml::from_str(yaml).unwrap();
662
663 assert_eq!(config.metadata.version, "1.0.0");
664 assert_eq!(config.commands.len(), 1);
665 assert_eq!(config.commands[0].name, "hello");
666 }
667
668 #[test]
669 fn test_serialize_and_deserialize_roundtrip() {
670 let original = CommandsConfig {
671 metadata: Metadata {
672 version: "1.0.0".to_string(),
673 prompt: "test".to_string(),
674 prompt_suffix: " > ".to_string(),
675 },
676 commands: vec![CommandDefinition {
677 name: "cmd1".to_string(),
678 aliases: vec!["c1".to_string()],
679 description: "Test command".to_string(),
680 required: true,
681 arguments: vec![],
682 options: vec![],
683 implementation: "handler1".to_string(),
684 }],
685 global_options: vec![],
686 };
687
688 // Serialize to YAML
689 let yaml = serde_yaml::to_string(&original).unwrap();
690
691 // Deserialize back
692 let deserialized: CommandsConfig = serde_yaml::from_str(&yaml).unwrap();
693
694 assert_eq!(original, deserialized);
695 }
696
697 #[test]
698 fn test_json_deserialization() {
699 let json = r#"
700 {
701 "metadata": {
702 "version": "1.0.0",
703 "prompt": "test",
704 "prompt_suffix": " > "
705 },
706 "commands": [],
707 "global_options": []
708 }
709 "#;
710
711 let config: CommandsConfig = serde_json::from_str(json).unwrap();
712
713 assert_eq!(config.metadata.version, "1.0.0");
714 assert_eq!(config.commands.len(), 0);
715 }
716}