Skip to main content

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//!     secure: false,
120//! };
121//!
122//! // Parse arguments
123//! let mut args = HashMap::new();
124//! args.insert("input_file".to_string(), "data.csv".to_string());
125//!
126//! // Validate (would check if file exists in real scenario)
127//! if let Some(value) = args.get(&arg_def.name) {
128//!     let path = Path::new(value);
129//!     for rule in &arg_def.validation {
130//!         match rule {
131//!             ValidationRule::MustExist { must_exist } if *must_exist => {
132//!                 validate_file_exists(path, &arg_def.name)?;
133//!             },
134//!             ValidationRule::Extensions { extensions } => {
135//!                 validate_file_extension(path, &arg_def.name, extensions)?;
136//!             },
137//!             _ => {}
138//!         }
139//!     }
140//! }
141//! # Ok::<(), dynamic_cli::error::DynamicCliError>(())
142//! ```
143//!
144//! # Design Philosophy
145//!
146//! ## Fail Fast
147//!
148//! Validation happens early, before command execution, to catch
149//! errors as soon as possible. This prevents wasted computation
150//! and provides immediate feedback to users.
151//!
152//! ## Clear Error Messages
153//!
154//! All validation errors include:
155//! - The argument name that failed
156//! - The actual value provided
157//! - The expected constraint
158//! - Helpful context for fixing the issue
159//!
160//! ## Type-Appropriate Validation
161//!
162//! Validation rules are matched to argument types:
163//! - `Path` arguments: file existence, extensions
164//! - `Integer`/`Float` arguments: range constraints
165//! - All types: custom constraints
166//!
167//! ## No Side Effects
168//!
169//! Validators only check values - they don't modify files,
170//! create directories, or perform any side effects.
171//!
172//! [`ValidationRule`]: crate::config::schema::ValidationRule
173//! [`ValidationError`]: crate::error::ValidationError
174//! [`CommandDefinition`]: crate::config::schema::CommandDefinition
175
176// Public submodules
177pub mod file_validator;
178pub mod range_validator;
179
180// Re-export commonly used functions for convenience
181pub use file_validator::{validate_file_exists, validate_file_extension};
182pub use range_validator::validate_range;
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use crate::config::schema::{ArgumentDefinition, ArgumentType, ValidationRule};
188    use std::io::Write;
189    use std::path::Path;
190    use tempfile::NamedTempFile;
191
192    /// Helper: create a NamedTempFile with a given extension and content.
193    ///
194    /// Uses `NamedTempFile` directly instead of `TempDir` + `File::create`
195    /// to avoid the `File::create` race condition under parallel test execution.
196    fn temp_file_with_ext(ext: &str, content: &str) -> NamedTempFile {
197        let mut f = tempfile::Builder::new()
198            .suffix(&format!(".{}", ext))
199            .tempfile()
200            .expect("failed to create NamedTempFile");
201        f.write_all(content.as_bytes())
202            .expect("failed to write to NamedTempFile");
203        f
204    }
205
206    // ========================================================================
207    // Integration tests - Real-world scenarios
208    // ========================================================================
209
210    #[test]
211    fn test_validate_configuration_file_argument() {
212        let f = temp_file_with_ext("yaml", "test: value");
213        let extensions = vec!["yaml".to_string(), "yml".to_string()];
214        assert!(validate_file_exists(f.path(), "config").is_ok());
215        assert!(validate_file_extension(f.path(), "config", &extensions).is_ok());
216    }
217
218    #[test]
219    fn test_validate_data_file_with_wrong_extension() {
220        let f = temp_file_with_ext("txt", "some data");
221        let extensions = vec!["csv".to_string(), "tsv".to_string()];
222        assert!(validate_file_exists(f.path(), "data").is_ok());
223        assert!(validate_file_extension(f.path(), "data", &extensions).is_err());
224    }
225
226    #[test]
227    fn test_validate_missing_file() {
228        let missing_path = Path::new("/nonexistent/file.dat");
229        assert!(validate_file_exists(missing_path, "input").is_err());
230        let extensions = vec!["dat".to_string()];
231        assert!(validate_file_extension(missing_path, "input", &extensions).is_ok());
232    }
233
234    #[test]
235    fn test_validate_percentage_argument() {
236        assert!(validate_range(0.0, "percentage", Some(0.0), Some(100.0)).is_ok());
237        assert!(validate_range(50.0, "percentage", Some(0.0), Some(100.0)).is_ok());
238        assert!(validate_range(100.0, "percentage", Some(0.0), Some(100.0)).is_ok());
239        assert!(validate_range(-1.0, "percentage", Some(0.0), Some(100.0)).is_err());
240        assert!(validate_range(101.0, "percentage", Some(0.0), Some(100.0)).is_err());
241    }
242
243    #[test]
244    fn test_validate_threshold_argument() {
245        assert!(validate_range(0.0, "threshold", Some(0.0), Some(1.0)).is_ok());
246        assert!(validate_range(0.5, "threshold", Some(0.0), Some(1.0)).is_ok());
247        assert!(validate_range(1.0, "threshold", Some(0.0), Some(1.0)).is_ok());
248        assert!(validate_range(-0.1, "threshold", Some(0.0), Some(1.0)).is_err());
249        assert!(validate_range(1.1, "threshold", Some(0.0), Some(1.0)).is_err());
250    }
251
252    // ========================================================================
253    // Multiple validation rules on same argument
254    // ========================================================================
255
256    #[test]
257    fn test_validate_argument_with_multiple_rules() {
258        let f = temp_file_with_ext("csv", "col1,col2\n1,2\n");
259        let rules = vec![
260            ValidationRule::MustExist { must_exist: true },
261            ValidationRule::Extensions {
262                extensions: vec!["csv".to_string(), "tsv".to_string()],
263            },
264        ];
265        for rule in &rules {
266            match rule {
267                ValidationRule::MustExist { must_exist } => {
268                    if *must_exist {
269                        assert!(validate_file_exists(f.path(), "data").is_ok());
270                    }
271                }
272                ValidationRule::Extensions { extensions } => {
273                    assert!(validate_file_extension(f.path(), "data", extensions).is_ok());
274                }
275                _ => {}
276            }
277        }
278    }
279
280    #[test]
281    fn test_validate_fails_at_first_invalid_rule() {
282        let f = temp_file_with_ext("txt", "content");
283        let rules = vec![
284            ValidationRule::MustExist { must_exist: true },
285            ValidationRule::Extensions {
286                extensions: vec!["csv".to_string()], // Wrong extension!
287            },
288        ];
289        if let ValidationRule::MustExist { must_exist } = &rules[0] {
290            if *must_exist {
291                assert!(validate_file_exists(f.path(), "data").is_ok());
292            }
293        }
294        if let ValidationRule::Extensions { extensions } = &rules[1] {
295            assert!(validate_file_extension(f.path(), "data", extensions).is_err());
296        }
297    }
298
299    // ========================================================================
300    // Testing validation with ArgumentDefinition structure
301    // ========================================================================
302
303    #[test]
304    fn test_validate_with_argument_definition() {
305        let f = temp_file_with_ext("dat", "data");
306        let arg_def = ArgumentDefinition {
307            name: "input_file".to_string(),
308            arg_type: ArgumentType::Path,
309            required: true,
310            description: "Input data file".to_string(),
311            validation: vec![
312                ValidationRule::MustExist { must_exist: true },
313                ValidationRule::Extensions {
314                    extensions: vec!["dat".to_string(), "bin".to_string()],
315                },
316            ],
317            secure: false,
318        };
319        let value = f.path().to_str().unwrap();
320        for rule in &arg_def.validation {
321            match rule {
322                ValidationRule::MustExist { must_exist } => {
323                    if *must_exist {
324                        assert!(validate_file_exists(Path::new(value), &arg_def.name).is_ok());
325                    }
326                }
327                ValidationRule::Extensions { extensions } => {
328                    assert!(
329                        validate_file_extension(Path::new(value), &arg_def.name, extensions)
330                            .is_ok()
331                    );
332                }
333                _ => {}
334            }
335        }
336    }
337
338    #[test]
339    fn test_validate_numeric_argument_with_definition() {
340        let arg_def = ArgumentDefinition {
341            name: "temperature".to_string(),
342            arg_type: ArgumentType::Float,
343            required: true,
344            description: "Temperature in Celsius".to_string(),
345            validation: vec![ValidationRule::Range {
346                min: Some(-273.15),
347                max: None,
348            }],
349            secure: false,
350        };
351        for value in &["0.0", "25.0", "100.0", "-273.15"] {
352            let num: f64 = value.parse().unwrap();
353            for rule in &arg_def.validation {
354                if let ValidationRule::Range { min, max } = rule {
355                    assert!(validate_range(num, &arg_def.name, *min, *max).is_ok());
356                }
357            }
358        }
359        let invalid: f64 = "-300.0".parse().unwrap();
360        if let ValidationRule::Range { min, max } = &arg_def.validation[0] {
361            assert!(validate_range(invalid, &arg_def.name, *min, *max).is_err());
362        }
363    }
364
365    // ========================================================================
366    // Cross-module integration tests
367    // ========================================================================
368
369    #[test]
370    fn test_validate_parsed_arguments() {
371        use std::collections::HashMap;
372
373        let f = temp_file_with_ext("csv", "1,2,3");
374        let mut parsed_args = HashMap::new();
375        parsed_args.insert("input".to_string(), f.path().to_str().unwrap().to_string());
376        parsed_args.insert("threshold".to_string(), "0.75".to_string());
377
378        let input_rules = vec![
379            ValidationRule::MustExist { must_exist: true },
380            ValidationRule::Extensions {
381                extensions: vec!["csv".to_string()],
382            },
383        ];
384        let threshold_rules = vec![ValidationRule::Range {
385            min: Some(0.0),
386            max: Some(1.0),
387        }];
388
389        if let Some(value) = parsed_args.get("input") {
390            let path = Path::new(value);
391            for rule in &input_rules {
392                match rule {
393                    ValidationRule::MustExist { must_exist } if *must_exist => {
394                        assert!(validate_file_exists(path, "input").is_ok());
395                    }
396                    ValidationRule::Extensions { extensions } => {
397                        assert!(validate_file_extension(path, "input", extensions).is_ok());
398                    }
399                    _ => {}
400                }
401            }
402        }
403
404        if let Some(value) = parsed_args.get("threshold") {
405            let num: f64 = value.parse().unwrap();
406            for rule in &threshold_rules {
407                if let ValidationRule::Range { min, max } = rule {
408                    assert!(validate_range(num, "threshold", *min, *max).is_ok());
409                }
410            }
411        }
412    }
413
414    #[test]
415    fn test_error_messages_are_descriptive() {
416        let result = validate_file_exists(Path::new("/missing/file.txt"), "config");
417        assert!(result.is_err());
418        let msg = format!("{}", result.unwrap_err());
419        assert!(msg.contains("File not found"));
420        assert!(msg.contains("config"));
421        assert!(msg.contains("/missing/file.txt"));
422
423        let result = validate_file_extension(
424            Path::new("file.txt"),
425            "data",
426            &["csv".to_string(), "json".to_string()],
427        );
428        assert!(result.is_err());
429        let msg = format!("{}", result.unwrap_err());
430        assert!(msg.contains("Invalid file extension"));
431        assert!(msg.contains("data"));
432        assert!(msg.contains("csv"));
433
434        let result = validate_range(150.0, "percentage", Some(0.0), Some(100.0));
435        assert!(result.is_err());
436        let msg = format!("{}", result.unwrap_err());
437        assert!(msg.contains("percentage"));
438        assert!(msg.contains("150"));
439        assert!(msg.contains("0"));
440        assert!(msg.contains("100"));
441    }
442}