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}