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