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#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
149pub struct ArgumentDefinition {
150    /// Argument name (used in error messages and documentation)
151    pub name: String,
152
153    /// Expected type of the argument
154    pub arg_type: ArgumentType,
155
156    /// Whether the argument is mandatory
157    pub required: bool,
158
159    /// Human-readable description
160    pub description: String,
161
162    /// Validation rules to apply
163    #[serde(default)]
164    pub validation: Vec<ValidationRule>,
165}
166
167/// Definition of a named option (flag)
168///
169/// Options are optional (by default) and can be specified
170/// with short (`-o`) or long (`--option`) forms.
171///
172/// # Example
173///
174/// ```yaml
175/// name: output
176/// short: o
177/// long: output
178/// option_type: path
179/// required: false
180/// default: "output.txt"
181/// description: "Output file path"
182/// choices: []
183/// ```
184#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
185pub struct OptionDefinition {
186    /// Option name (internal identifier)
187    pub name: String,
188
189    /// Short form (single character, e.g., "o" for -o)
190    pub short: Option<String>,
191
192    /// Long form (e.g., "output" for --output)
193    pub long: Option<String>,
194
195    /// Expected type of the option value
196    pub option_type: ArgumentType,
197
198    /// Whether this option is mandatory
199    #[serde(default)]
200    pub required: bool,
201
202    /// Default value if not specified
203    pub default: Option<String>,
204
205    /// Human-readable description
206    pub description: String,
207
208    /// Restricted set of allowed values
209    ///
210    /// If non-empty, the value must be one of these choices.
211    #[serde(default)]
212    pub choices: Vec<String>,
213}
214
215/// Supported argument and option types
216///
217/// These types are used for automatic parsing and validation
218/// of user input.
219///
220/// # Serialization
221///
222/// Types are serialized as lowercase strings in YAML/JSON:
223/// - `String` → "string"
224/// - `Integer` → "integer"
225/// - `Float` → "float"
226/// - `Bool` → "bool"
227/// - `Path` → "path"
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
229#[serde(rename_all = "lowercase")]
230pub enum ArgumentType {
231    /// UTF-8 string
232    String,
233
234    /// Signed integer (i64)
235    Integer,
236
237    /// Floating-point number (f64)
238    Float,
239
240    /// Boolean value (true/false, yes/no, 1/0)
241    Bool,
242
243    /// File system path
244    ///
245    /// Represents a path that may or may not exist,
246    /// depending on validation rules.
247    Path,
248}
249
250impl ArgumentType {
251    /// Get the type name as a string for error messages
252    ///
253    /// # Example
254    ///
255    /// ```
256    /// use dynamic_cli::config::schema::ArgumentType;
257    ///
258    /// assert_eq!(ArgumentType::Integer.as_str(), "integer");
259    /// assert_eq!(ArgumentType::Path.as_str(), "path");
260    /// ```
261    pub fn as_str(&self) -> &'static str {
262        match self {
263            ArgumentType::String => "string",
264            ArgumentType::Integer => "integer",
265            ArgumentType::Float => "float",
266            ArgumentType::Bool => "bool",
267            ArgumentType::Path => "path",
268        }
269    }
270}
271
272/// Validation rules for arguments and options
273///
274/// These rules are applied after type parsing to enforce
275/// additional constraints on values.
276///
277/// # Variants
278///
279/// - `MustExist`: For paths, require that the file/directory exists
280/// - `Extensions`: For paths, restrict to specific file extensions
281/// - `Range`: For numbers, enforce min/max bounds
282///
283/// # Serialization
284///
285/// Rules use untagged enum serialization:
286///
287/// ```yaml
288/// # MustExist
289/// - must_exist: true
290///
291/// # Extensions
292/// - extensions: [yaml, yml, json]
293///
294/// # Range
295/// - min: 0.0
296///   max: 100.0
297/// ```
298#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
299#[serde(untagged)]
300pub enum ValidationRule {
301    /// Require that a path exists on the file system
302    MustExist { must_exist: bool },
303
304    /// Restrict file extensions (for path arguments)
305    ///
306    /// Extensions should be specified without the leading dot.
307    /// Example: `["yaml", "yml"]` matches "config.yaml" and "data.yml"
308    Extensions { extensions: Vec<String> },
309
310    /// Enforce numeric range constraints
311    ///
312    /// Either or both bounds can be specified:
313    /// - `min: Some(0.0), max: None` → x ≥ 0
314    /// - `min: None, max: Some(100.0)` → x ≤ 100
315    /// - `min: Some(0.0), max: Some(100.0)` → 0 ≤ x ≤ 100
316    Range { min: Option<f64>, max: Option<f64> },
317}
318
319impl CommandsConfig {
320    /// Create a minimal valid configuration for testing
321    ///
322    /// This is useful for unit tests and examples.
323    ///
324    /// # Example
325    ///
326    /// ```
327    /// use dynamic_cli::config::schema::CommandsConfig;
328    ///
329    /// let config = CommandsConfig::minimal();
330    /// assert_eq!(config.metadata.version, "0.1.0");
331    /// assert!(config.commands.is_empty());
332    /// ```
333    #[cfg(test)]
334    pub fn minimal() -> Self {
335        Self {
336            metadata: Metadata {
337                version: "0.1.0".to_string(),
338                prompt: "test".to_string(),
339                prompt_suffix: " > ".to_string(),
340            },
341            commands: vec![],
342            global_options: vec![],
343        }
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn test_argument_type_as_str() {
353        assert_eq!(ArgumentType::String.as_str(), "string");
354        assert_eq!(ArgumentType::Integer.as_str(), "integer");
355        assert_eq!(ArgumentType::Float.as_str(), "float");
356        assert_eq!(ArgumentType::Bool.as_str(), "bool");
357        assert_eq!(ArgumentType::Path.as_str(), "path");
358    }
359
360    #[test]
361    fn test_default_prompt_suffix() {
362        assert_eq!(default_prompt_suffix(), " > ");
363    }
364
365    #[test]
366    fn test_minimal_config() {
367        let config = CommandsConfig::minimal();
368
369        assert_eq!(config.metadata.version, "0.1.0");
370        assert_eq!(config.metadata.prompt, "test");
371        assert_eq!(config.metadata.prompt_suffix, " > ");
372        assert!(config.commands.is_empty());
373        assert!(config.global_options.is_empty());
374    }
375
376    #[test]
377    fn test_deserialize_argument_type() {
378        // Test YAML deserialization of ArgumentType
379        let yaml = r#"
380            type: string
381        "#;
382
383        #[derive(Deserialize)]
384        struct TestStruct {
385            #[serde(rename = "type")]
386            type_field: ArgumentType,
387        }
388
389        let result: TestStruct = serde_yaml::from_str(yaml).unwrap();
390        assert_eq!(result.type_field, ArgumentType::String);
391    }
392
393    #[test]
394    fn test_deserialize_metadata() {
395        let yaml = r#"
396            version: "1.0.0"
397            prompt: "myapp"
398            prompt_suffix: " $ "
399        "#;
400
401        let metadata: Metadata = serde_yaml::from_str(yaml).unwrap();
402
403        assert_eq!(metadata.version, "1.0.0");
404        assert_eq!(metadata.prompt, "myapp");
405        assert_eq!(metadata.prompt_suffix, " $ ");
406    }
407
408    #[test]
409    fn test_deserialize_metadata_with_default() {
410        // Test that prompt_suffix gets default value if not specified
411        let yaml = r#"
412            version: "1.0.0"
413            prompt: "myapp"
414        "#;
415
416        let metadata: Metadata = serde_yaml::from_str(yaml).unwrap();
417
418        assert_eq!(metadata.prompt_suffix, " > ");
419    }
420
421    #[test]
422    fn test_deserialize_command_definition() {
423        let yaml = r#"
424            name: test_cmd
425            aliases: [tc, test]
426            description: "A test command"
427            required: true
428            arguments: []
429            options: []
430            implementation: "test_handler"
431        "#;
432
433        let cmd: CommandDefinition = serde_yaml::from_str(yaml).unwrap();
434
435        assert_eq!(cmd.name, "test_cmd");
436        assert_eq!(cmd.aliases, vec!["tc", "test"]);
437        assert_eq!(cmd.description, "A test command");
438        assert!(cmd.required);
439        assert_eq!(cmd.implementation, "test_handler");
440    }
441
442    #[test]
443    fn test_deserialize_argument_definition() {
444        let yaml = r#"
445            name: input_file
446            arg_type: path
447            required: true
448            description: "Input file"
449            validation:
450              - must_exist: true
451              - extensions: [yaml, yml]
452        "#;
453
454        let arg: ArgumentDefinition = serde_yaml::from_str(yaml).unwrap();
455
456        assert_eq!(arg.name, "input_file");
457        assert_eq!(arg.arg_type, ArgumentType::Path);
458        assert!(arg.required);
459        assert_eq!(arg.description, "Input file");
460        assert_eq!(arg.validation.len(), 2);
461    }
462
463    #[test]
464    fn test_deserialize_option_definition() {
465        let yaml = r#"
466            name: output
467            short: o
468            long: output
469            option_type: path
470            required: false
471            default: "out.txt"
472            description: "Output file"
473            choices: []
474        "#;
475
476        let opt: OptionDefinition = serde_yaml::from_str(yaml).unwrap();
477
478        assert_eq!(opt.name, "output");
479        assert_eq!(opt.short, Some("o".to_string()));
480        assert_eq!(opt.long, Some("output".to_string()));
481        assert_eq!(opt.option_type, ArgumentType::Path);
482        assert!(!opt.required);
483        assert_eq!(opt.default, Some("out.txt".to_string()));
484    }
485
486    #[test]
487    fn test_deserialize_validation_rule_must_exist() {
488        let yaml = r#"
489            must_exist: true
490        "#;
491
492        let rule: ValidationRule = serde_yaml::from_str(yaml).unwrap();
493
494        assert_eq!(rule, ValidationRule::MustExist { must_exist: true });
495    }
496
497    #[test]
498    fn test_deserialize_validation_rule_extensions() {
499        let yaml = r#"
500            extensions: [yaml, yml, json]
501        "#;
502
503        let rule: ValidationRule = serde_yaml::from_str(yaml).unwrap();
504
505        match rule {
506            ValidationRule::Extensions { extensions } => {
507                assert_eq!(extensions, vec!["yaml", "yml", "json"]);
508            }
509            _ => panic!("Wrong variant"),
510        }
511    }
512
513    #[test]
514    fn test_deserialize_validation_rule_range() {
515        let yaml = r#"
516            min: 0.0
517            max: 100.0
518        "#;
519
520        let rule: ValidationRule = serde_yaml::from_str(yaml).unwrap();
521
522        match rule {
523            ValidationRule::Range { min, max } => {
524                assert_eq!(min, Some(0.0));
525                assert_eq!(max, Some(100.0));
526            }
527            _ => panic!("Wrong variant"),
528        }
529    }
530
531    #[test]
532    fn test_deserialize_full_config() {
533        let yaml = r#"
534            metadata:
535              version: "1.0.0"
536              prompt: "test"
537              prompt_suffix: " > "
538            commands:
539              - name: hello
540                aliases: []
541                description: "Say hello"
542                required: false
543                arguments: []
544                options: []
545                implementation: "hello_handler"
546            global_options: []
547        "#;
548
549        let config: CommandsConfig = serde_yaml::from_str(yaml).unwrap();
550
551        assert_eq!(config.metadata.version, "1.0.0");
552        assert_eq!(config.commands.len(), 1);
553        assert_eq!(config.commands[0].name, "hello");
554    }
555
556    #[test]
557    fn test_serialize_and_deserialize_roundtrip() {
558        let original = CommandsConfig {
559            metadata: Metadata {
560                version: "1.0.0".to_string(),
561                prompt: "test".to_string(),
562                prompt_suffix: " > ".to_string(),
563            },
564            commands: vec![CommandDefinition {
565                name: "cmd1".to_string(),
566                aliases: vec!["c1".to_string()],
567                description: "Test command".to_string(),
568                required: true,
569                arguments: vec![],
570                options: vec![],
571                implementation: "handler1".to_string(),
572            }],
573            global_options: vec![],
574        };
575
576        // Serialize to YAML
577        let yaml = serde_yaml::to_string(&original).unwrap();
578
579        // Deserialize back
580        let deserialized: CommandsConfig = serde_yaml::from_str(&yaml).unwrap();
581
582        assert_eq!(original, deserialized);
583    }
584
585    #[test]
586    fn test_json_deserialization() {
587        let json = r#"
588        {
589            "metadata": {
590                "version": "1.0.0",
591                "prompt": "test",
592                "prompt_suffix": " > "
593            },
594            "commands": [],
595            "global_options": []
596        }
597        "#;
598
599        let config: CommandsConfig = serde_json::from_str(json).unwrap();
600
601        assert_eq!(config.metadata.version, "1.0.0");
602        assert_eq!(config.commands.len(), 0);
603    }
604}