dynamic_cli/validator/
mod.rs

1//! Argument validation module
2//!
3//! This module provides functions to validate parsed arguments against
4//! the rules defined in the configuration. It completes the parsing
5//! layer by ensuring values meet all specified constraints.
6//!
7//! # Overview
8//!
9//! The validator module works with [`ValidationRule`] definitions from
10//! the configuration to validate parsed argument values. It supports:
11//!
12//! - **File validation**: Existence checks and extension restrictions
13//! - **Range validation**: Numeric bounds (min/max)
14//! - **Type-specific validation**: Applied after type parsing
15//!
16//! # Architecture
17//!
18//! ```text
19//! Configuration (YAML/JSON)
20//!     ↓
21//! ValidationRule definitions
22//!     ↓
23//! Parsed arguments (HashMap<String, String>)
24//!     ↓
25//! Validators (this module)
26//!     ↓
27//! Validated values (Result<(), ValidationError>)
28//! ```
29//!
30//! # Submodules
31//!
32//! - [`file_validator`]: File existence and extension validation
33//! - [`range_validator`]: Numeric range validation
34//!
35//! # Usage Example
36//!
37//! ```no_run
38//! use dynamic_cli::validator::{file_validator, range_validator};
39//! use dynamic_cli::config::schema::ValidationRule;
40//! use std::path::Path;
41//!
42//! // Validate file exists
43//! let path = Path::new("config.yaml");
44//! file_validator::validate_file_exists(path, "config")?;
45//!
46//! // Validate file extension
47//! file_validator::validate_file_extension(
48//!     path,
49//!     "config",
50//!     &["yaml".to_string(), "yml".to_string()]
51//! )?;
52//!
53//! // Validate numeric range
54//! range_validator::validate_range(75.0, "percentage", Some(0.0), Some(100.0))?;
55//! # Ok::<(), dynamic_cli::error::DynamicCliError>(())
56//! ```
57//!
58//! # Integration with Other Modules
59//!
60//! ## With Parser Module
61//!
62//! The validator works with values after they've been parsed:
63//!
64//! ```text
65//! User Input: "simulate input.dat --threshold 0.5"
66//!     ↓ [parser]
67//! HashMap { "input": "input.dat", "threshold": "0.5" }
68//!     ↓ [validator]
69//! Validated ✓
70//! ```
71//!
72//! ## With Config Module
73//!
74//! Validation rules come from the configuration:
75//!
76//! ```yaml
77//! arguments:
78//!   - name: input
79//!     arg_type: path
80//!     validation:
81//!       - must_exist: true
82//!       - extensions: [dat, csv]
83//!   - name: threshold
84//!     arg_type: float
85//!     validation:
86//!       - min: 0.0
87//!         max: 1.0
88//! ```
89//!
90//! ## With Error Module
91//!
92//! All validation errors use [`ValidationError`]:
93//!
94//! - `FileNotFound` - File doesn't exist
95//! - `InvalidExtension` - Wrong file extension
96//! - `OutOfRange` - Value outside min/max bounds
97//! - `CustomConstraint` - Custom validation failed
98//!
99//! # Complete Workflow Example
100//!
101//! ```no_run
102//! use dynamic_cli::config::schema::{ArgumentDefinition, ArgumentType, ValidationRule};
103//! use dynamic_cli::validator::{file_validator, validate_file_exists, validate_file_extension};
104//! use std::collections::HashMap;
105//! use std::path::Path;
106//!
107//! // Define an argument with validation rules
108//! let arg_def = ArgumentDefinition {
109//!     name: "input_file".to_string(),
110//!     arg_type: ArgumentType::Path,
111//!     required: true,
112//!     description: "Input data file".to_string(),
113//!     validation: vec![
114//!         ValidationRule::MustExist { must_exist: true },
115//!         ValidationRule::Extensions {
116//!             extensions: vec!["csv".to_string(), "tsv".to_string()],
117//!         },
118//!     ],
119//! };
120//!
121//! // Parse arguments
122//! let mut args = HashMap::new();
123//! args.insert("input_file".to_string(), "data.csv".to_string());
124//!
125//! // Validate (would check if file exists in real scenario)
126//! if let Some(value) = args.get(&arg_def.name) {
127//!     let path = Path::new(value);
128//!     for rule in &arg_def.validation {
129//!         match rule {
130//!             ValidationRule::MustExist { must_exist } if *must_exist => {
131//!                 validate_file_exists(path, &arg_def.name)?;
132//!             },
133//!             ValidationRule::Extensions { extensions } => {
134//!                 validate_file_extension(path, &arg_def.name, extensions)?;
135//!             },
136//!             _ => {}
137//!         }
138//!     }
139//! }
140//! # Ok::<(), dynamic_cli::error::DynamicCliError>(())
141//! ```
142//!
143//! # Design Philosophy
144//!
145//! ## Fail Fast
146//!
147//! Validation happens early, before command execution, to catch
148//! errors as soon as possible. This prevents wasted computation
149//! and provides immediate feedback to users.
150//!
151//! ## Clear Error Messages
152//!
153//! All validation errors include:
154//! - The argument name that failed
155//! - The actual value provided
156//! - The expected constraint
157//! - Helpful context for fixing the issue
158//!
159//! ## Type-Appropriate Validation
160//!
161//! Validation rules are matched to argument types:
162//! - `Path` arguments: file existence, extensions
163//! - `Integer`/`Float` arguments: range constraints
164//! - All types: custom constraints
165//!
166//! ## No Side Effects
167//!
168//! Validators only check values - they don't modify files,
169//! create directories, or perform any side effects.
170//!
171//! [`ValidationRule`]: crate::config::schema::ValidationRule
172//! [`ValidationError`]: crate::error::ValidationError
173//! [`CommandDefinition`]: crate::config::schema::CommandDefinition
174
175// Public submodules
176pub mod file_validator;
177pub mod range_validator;
178
179// Re-export commonly used functions for convenience
180pub use file_validator::{validate_file_exists, validate_file_extension};
181pub use range_validator::validate_range;
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::config::schema::{ArgumentDefinition, ArgumentType, ValidationRule};
187    use std::fs;
188    use std::io::Write;
189    use std::path::Path;
190    use tempfile::TempDir;
191
192    /// Helper to create a temporary file
193    fn create_temp_file(dir: &TempDir, name: &str, content: &str) -> std::path::PathBuf {
194        let path = dir.path().join(name);
195        let mut file = fs::File::create(&path).unwrap();
196        write!(file, "{}", content).unwrap();
197        path
198    }
199
200    // ========================================================================
201    // Integration tests - Real-world scenarios
202    // ========================================================================
203
204    #[test]
205    fn test_validate_configuration_file_argument() {
206        let temp_dir = TempDir::new().unwrap();
207        let config_path = create_temp_file(&temp_dir, "config.yaml", "test: value");
208
209        // Define validation rules
210        let extensions = vec!["yaml".to_string(), "yml".to_string()];
211
212        // Validate - both checks should pass
213        assert!(validate_file_exists(&config_path, "config").is_ok());
214        assert!(validate_file_extension(&config_path, "config", &extensions).is_ok());
215    }
216
217    #[test]
218    fn test_validate_data_file_with_wrong_extension() {
219        let temp_dir = TempDir::new().unwrap();
220        let data_path = create_temp_file(&temp_dir, "data.txt", "some data");
221
222        let extensions = vec!["csv".to_string(), "tsv".to_string()];
223
224        // File exists
225        assert!(validate_file_exists(&data_path, "data").is_ok());
226
227        // But wrong extension
228        assert!(validate_file_extension(&data_path, "data", &extensions).is_err());
229    }
230
231    #[test]
232    fn test_validate_missing_file() {
233        let missing_path = Path::new("/nonexistent/file.dat");
234
235        // Should fail existence check
236        assert!(validate_file_exists(missing_path, "input").is_err());
237
238        // Extension check would pass (but we stop at existence)
239        let extensions = vec!["dat".to_string()];
240        assert!(validate_file_extension(missing_path, "input", &extensions).is_ok());
241    }
242
243    #[test]
244    fn test_validate_percentage_argument() {
245        // Valid percentages
246        assert!(validate_range(0.0, "percentage", Some(0.0), Some(100.0)).is_ok());
247        assert!(validate_range(50.0, "percentage", Some(0.0), Some(100.0)).is_ok());
248        assert!(validate_range(100.0, "percentage", Some(0.0), Some(100.0)).is_ok());
249
250        // Invalid percentages
251        assert!(validate_range(-1.0, "percentage", Some(0.0), Some(100.0)).is_err());
252        assert!(validate_range(101.0, "percentage", Some(0.0), Some(100.0)).is_err());
253    }
254
255    #[test]
256    fn test_validate_threshold_argument() {
257        // Common ML/science threshold: 0.0 to 1.0
258        assert!(validate_range(0.0, "threshold", Some(0.0), Some(1.0)).is_ok());
259        assert!(validate_range(0.5, "threshold", Some(0.0), Some(1.0)).is_ok());
260        assert!(validate_range(1.0, "threshold", Some(0.0), Some(1.0)).is_ok());
261
262        // Out of bounds
263        assert!(validate_range(-0.1, "threshold", Some(0.0), Some(1.0)).is_err());
264        assert!(validate_range(1.1, "threshold", Some(0.0), Some(1.0)).is_err());
265    }
266
267    // ========================================================================
268    // Multiple validation rules on same argument
269    // ========================================================================
270
271    #[test]
272    fn test_validate_argument_with_multiple_rules() {
273        let temp_dir = TempDir::new().unwrap();
274        let file_path = create_temp_file(&temp_dir, "data.csv", "col1,col2\n1,2\n");
275
276        // Simulate ArgumentDefinition with multiple validation rules
277        let rules = vec![
278            ValidationRule::MustExist { must_exist: true },
279            ValidationRule::Extensions {
280                extensions: vec!["csv".to_string(), "tsv".to_string()],
281            },
282        ];
283
284        // Apply all rules
285        for rule in &rules {
286            match rule {
287                ValidationRule::MustExist { must_exist } => {
288                    if *must_exist {
289                        assert!(validate_file_exists(&file_path, "data").is_ok());
290                    }
291                }
292                ValidationRule::Extensions { extensions } => {
293                    assert!(validate_file_extension(&file_path, "data", extensions).is_ok());
294                }
295                _ => {}
296            }
297        }
298    }
299
300    #[test]
301    fn test_validate_fails_at_first_invalid_rule() {
302        let temp_dir = TempDir::new().unwrap();
303        let file_path = create_temp_file(&temp_dir, "data.txt", "content");
304
305        let rules = vec![
306            ValidationRule::MustExist { must_exist: true },
307            ValidationRule::Extensions {
308                extensions: vec!["csv".to_string()], // Wrong extension!
309            },
310        ];
311
312        // First rule passes
313        if let ValidationRule::MustExist { must_exist } = &rules[0] {
314            if *must_exist {
315                assert!(validate_file_exists(&file_path, "data").is_ok());
316            }
317        }
318
319        // Second rule fails
320        if let ValidationRule::Extensions { extensions } = &rules[1] {
321            assert!(validate_file_extension(&file_path, "data", extensions).is_err());
322        }
323    }
324
325    // ========================================================================
326    // Testing validation with ArgumentDefinition structure
327    // ========================================================================
328
329    #[test]
330    fn test_validate_with_argument_definition() {
331        let temp_dir = TempDir::new().unwrap();
332        let file_path = create_temp_file(&temp_dir, "input.dat", "data");
333
334        // Create an ArgumentDefinition similar to config
335        let arg_def = ArgumentDefinition {
336            name: "input_file".to_string(),
337            arg_type: ArgumentType::Path,
338            required: true,
339            description: "Input data file".to_string(),
340            validation: vec![
341                ValidationRule::MustExist { must_exist: true },
342                ValidationRule::Extensions {
343                    extensions: vec!["dat".to_string(), "bin".to_string()],
344                },
345            ],
346        };
347
348        // Validate according to definition
349        let value = file_path.to_str().unwrap();
350
351        for rule in &arg_def.validation {
352            match rule {
353                ValidationRule::MustExist { must_exist } => {
354                    if *must_exist {
355                        let path = Path::new(value);
356                        assert!(validate_file_exists(path, &arg_def.name).is_ok());
357                    }
358                }
359                ValidationRule::Extensions { extensions } => {
360                    let path = Path::new(value);
361                    assert!(validate_file_extension(path, &arg_def.name, extensions).is_ok());
362                }
363                _ => {}
364            }
365        }
366    }
367
368    #[test]
369    fn test_validate_numeric_argument_with_definition() {
370        let arg_def = ArgumentDefinition {
371            name: "temperature".to_string(),
372            arg_type: ArgumentType::Float,
373            required: true,
374            description: "Temperature in Celsius".to_string(),
375            validation: vec![ValidationRule::Range {
376                min: Some(-273.15), // Absolute zero
377                max: None,
378            }],
379        };
380
381        // Valid temperatures
382        let values = vec!["0.0", "25.0", "100.0", "-273.15"];
383
384        for value in values {
385            let num_value: f64 = value.parse().unwrap();
386
387            for rule in &arg_def.validation {
388                if let ValidationRule::Range { min, max } = rule {
389                    assert!(validate_range(num_value, &arg_def.name, *min, *max).is_ok());
390                }
391            }
392        }
393
394        // Invalid temperature (below absolute zero)
395        let invalid_value: f64 = "-300.0".parse().unwrap();
396        if let ValidationRule::Range { min, max } = &arg_def.validation[0] {
397            assert!(validate_range(invalid_value, &arg_def.name, *min, *max).is_err());
398        }
399    }
400
401    // ========================================================================
402    // Cross-module integration tests
403    // ========================================================================
404
405    #[test]
406    fn test_validate_parsed_arguments() {
407        use std::collections::HashMap;
408
409        let temp_dir = TempDir::new().unwrap();
410        let input_path = create_temp_file(&temp_dir, "data.csv", "1,2,3");
411
412        // Simulated parsed arguments from parser module
413        let mut parsed_args = HashMap::new();
414        parsed_args.insert(
415            "input".to_string(),
416            input_path.to_str().unwrap().to_string(),
417        );
418        parsed_args.insert("threshold".to_string(), "0.75".to_string());
419
420        // Validation rules from config
421        let input_rules = vec![
422            ValidationRule::MustExist { must_exist: true },
423            ValidationRule::Extensions {
424                extensions: vec!["csv".to_string()],
425            },
426        ];
427
428        let threshold_rules = vec![ValidationRule::Range {
429            min: Some(0.0),
430            max: Some(1.0),
431        }];
432
433        // Validate input file
434        if let Some(value) = parsed_args.get("input") {
435            let path = Path::new(value);
436            for rule in &input_rules {
437                match rule {
438                    ValidationRule::MustExist { must_exist } if *must_exist => {
439                        assert!(validate_file_exists(path, "input").is_ok());
440                    }
441                    ValidationRule::Extensions { extensions } => {
442                        assert!(validate_file_extension(path, "input", extensions).is_ok());
443                    }
444                    _ => {}
445                }
446            }
447        }
448
449        // Validate threshold
450        if let Some(value) = parsed_args.get("threshold") {
451            let num_value: f64 = value.parse().unwrap();
452            for rule in &threshold_rules {
453                if let ValidationRule::Range { min, max } = rule {
454                    assert!(validate_range(num_value, "threshold", *min, *max).is_ok());
455                }
456            }
457        }
458    }
459
460    #[test]
461    fn test_error_messages_are_descriptive() {
462        // Test that error messages contain helpful information
463
464        // File not found error
465        let result = validate_file_exists(Path::new("/missing/file.txt"), "config");
466        assert!(result.is_err());
467        let error = result.unwrap_err();
468        let error_msg = format!("{}", error);
469        assert!(error_msg.contains("File not found"));
470        assert!(error_msg.contains("config"));
471        assert!(error_msg.contains("/missing/file.txt"));
472
473        // Invalid extension error
474        let result = validate_file_extension(
475            Path::new("file.txt"),
476            "data",
477            &vec!["csv".to_string(), "json".to_string()],
478        );
479        assert!(result.is_err());
480        let error = result.unwrap_err();
481        let error_msg = format!("{}", error);
482        assert!(error_msg.contains("Invalid file extension"));
483        assert!(error_msg.contains("data"));
484        assert!(error_msg.contains("csv"));
485
486        // Out of range error
487        let result = validate_range(150.0, "percentage", Some(0.0), Some(100.0));
488        assert!(result.is_err());
489        let error = result.unwrap_err();
490        let error_msg = format!("{}", error);
491        assert!(error_msg.contains("percentage"));
492        assert!(error_msg.contains("150"));
493        assert!(error_msg.contains("0"));
494        assert!(error_msg.contains("100"));
495    }
496}