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}