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
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}