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}