Skip to main content

dynamic_cli/validator/
file_validator.rs

1//! File validation functions
2//!
3//! This module provides functions to validate file paths according to
4//! [`ValidationRule::MustExist`] and [`ValidationRule::Extensions`].
5//!
6//! # Functions
7//!
8//! - [`validate_file_exists`] - Check if a file or directory exists
9//! - [`validate_file_extension`] - Check if a file has an allowed extension
10//!
11//! # Example
12//!
13//! ```no_run
14//! use dynamic_cli::validator::file_validator::{validate_file_exists, validate_file_extension};
15//! use std::path::Path;
16//!
17//! let path = Path::new("config.yaml");
18//!
19//! // Validate file exists
20//! validate_file_exists(path, "config_file")?;
21//!
22//! // Validate file extension
23//! let allowed = vec!["yaml".to_string(), "yml".to_string()];
24//! validate_file_extension(path, "config_file", &allowed)?;
25//! # Ok::<(), dynamic_cli::error::DynamicCliError>(())
26//! ```
27
28use crate::error::{Result, ValidationError};
29use std::path::Path;
30
31/// Validate that a file or directory exists
32///
33/// This function checks if the given path exists on the file system.
34/// It works for both files and directories.
35///
36/// # Arguments
37///
38/// * `path` - Path to validate
39/// * `arg_name` - Name of the argument (for error messages)
40///
41/// # Returns
42///
43/// - `Ok(())` if the path exists
44/// - `Err(ValidationError::FileNotFound)` if the path doesn't exist
45///
46/// # Example
47///
48/// ```no_run
49/// use dynamic_cli::validator::file_validator::validate_file_exists;
50/// use std::path::Path;
51///
52/// let path = Path::new("input.txt");
53/// validate_file_exists(path, "input_file")?;
54/// # Ok::<(), dynamic_cli::error::DynamicCliError>(())
55/// ```
56///
57/// # Error Messages
58///
59/// If the file doesn't exist, the error message includes:
60/// - The argument name
61/// - The full path that was checked
62///
63/// Example: `File not found for argument 'input_file': "/path/to/input.txt"`
64pub fn validate_file_exists(path: &Path, arg_name: &str) -> Result<()> {
65    // Check if the path exists on the file system
66    // This works for both files and directories
67    if !path.exists() {
68        return Err(ValidationError::FileNotFound {
69            path: path.to_path_buf(),
70            arg_name: arg_name.to_string(),
71            suggestion: Some("Check that the file exists and the path is correct.".to_string()),
72        }
73        .into());
74    }
75
76    Ok(())
77}
78
79/// Validate that a file has one of the expected extensions
80///
81/// This function checks if the file's extension matches one of the
82/// allowed extensions. The comparison is **case-insensitive**.
83///
84/// # Arguments
85///
86/// * `path` - Path to the file
87/// * `arg_name` - Name of the argument (for error messages)
88/// * `expected` - List of allowed extensions (without the leading dot)
89///
90/// # Returns
91///
92/// - `Ok(())` if the file has an allowed extension
93/// - `Err(ValidationError::InvalidExtension)` if the extension doesn't match
94///
95/// # Extension Format
96///
97/// Extensions should be specified **without** the leading dot:
98/// - ✅ Correct: `["yaml", "yml"]`
99/// - ❌ Incorrect: `[".yaml", ".yml"]`
100///
101/// # Case Sensitivity
102///
103/// The validation is **case-insensitive**:
104/// - `"config.YAML"` matches `["yaml"]` ✅
105/// - `"config.Yml"` matches `["yml"]` ✅
106///
107/// # Example
108///
109/// ```no_run
110/// use dynamic_cli::validator::file_validator::validate_file_extension;
111/// use std::path::Path;
112///
113/// let path = Path::new("config.yaml");
114/// let allowed = vec!["yaml".to_string(), "yml".to_string()];
115///
116/// validate_file_extension(path, "config_file", &allowed)?;
117/// # Ok::<(), dynamic_cli::error::DynamicCliError>(())
118/// ```
119///
120/// # Error Messages
121///
122/// If the extension is invalid, the error includes:
123/// - The argument name
124/// - The file path
125/// - The list of expected extensions
126///
127/// Example: `Invalid file extension for config_file: "config.txt". Expected: yaml, yml`
128pub fn validate_file_extension(path: &Path, arg_name: &str, expected: &[String]) -> Result<()> {
129    // Extract the file extension from the path
130    let extension = path
131        .extension()
132        .and_then(|ext| ext.to_str())
133        .map(|ext| ext.to_lowercase());
134
135    // Check if we have an extension
136    let ext = match extension {
137        Some(e) => e,
138        None => {
139            // File has no extension
140            return Err(ValidationError::InvalidExtension {
141                arg_name: arg_name.to_string(),
142                path: path.to_path_buf(),
143                expected: expected.to_vec(),
144            }
145            .into());
146        }
147    };
148
149    // Check if the extension is in the list of allowed extensions
150    // Convert expected extensions to lowercase for case-insensitive comparison
151    let is_valid = expected.iter().any(|allowed| allowed.to_lowercase() == ext);
152
153    if !is_valid {
154        return Err(ValidationError::InvalidExtension {
155            arg_name: arg_name.to_string(),
156            path: path.to_path_buf(),
157            expected: expected.to_vec(),
158        }
159        .into());
160    }
161
162    Ok(())
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use std::fs::File;
169    use std::io::Write;
170    use tempfile::TempDir;
171
172    /// Helper to create a temporary file with content
173    fn create_temp_file(dir: &TempDir, name: &str, content: &str) -> std::path::PathBuf {
174        let file_path = dir.path().join(name);
175        let mut file = File::create(&file_path).unwrap();
176        file.write_all(content.as_bytes()).unwrap();
177        file_path
178    }
179
180    // ========================================================================
181    // Tests for validate_file_exists
182    // ========================================================================
183
184    #[test]
185    fn test_validate_file_exists_valid_file() {
186        let temp_dir = TempDir::new().unwrap();
187        let file_path = create_temp_file(&temp_dir, "test.txt", "content");
188
189        let result = validate_file_exists(&file_path, "test_file");
190        assert!(result.is_ok());
191    }
192
193    #[test]
194    fn test_validate_file_exists_valid_directory() {
195        let temp_dir = TempDir::new().unwrap();
196
197        // The temp directory itself exists
198        let result = validate_file_exists(temp_dir.path(), "test_dir");
199        assert!(result.is_ok());
200    }
201
202    #[test]
203    fn test_validate_file_exists_nonexistent() {
204        let temp_dir = TempDir::new().unwrap();
205        let nonexistent = temp_dir.path().join("does_not_exist.txt");
206
207        let result = validate_file_exists(&nonexistent, "missing_file");
208
209        assert!(result.is_err());
210        match result.unwrap_err() {
211            crate::error::DynamicCliError::Validation(ValidationError::FileNotFound {
212                path,
213                arg_name,
214                ..
215            }) => {
216                assert_eq!(arg_name, "missing_file");
217                assert_eq!(path, nonexistent);
218            }
219            other => panic!("Expected FileNotFound error, got {:?}", other),
220        }
221    }
222
223    #[test]
224    fn test_validate_file_exists_relative_path() {
225        let temp_dir = TempDir::new().unwrap();
226        let file_path = create_temp_file(&temp_dir, "relative.txt", "content");
227
228        // Create a relative path by using only the filename
229        let relative = std::path::Path::new(file_path.file_name().unwrap());
230
231        // Change to the temp directory
232        let original_dir = std::env::current_dir().unwrap();
233        std::env::set_current_dir(temp_dir.path()).unwrap();
234
235        let result = validate_file_exists(relative, "relative_file");
236        assert!(result.is_ok());
237
238        // Restore original directory
239        std::env::set_current_dir(original_dir).unwrap();
240    }
241
242    // ========================================================================
243    // Tests for validate_file_extension
244    // ========================================================================
245
246    #[test]
247    fn test_validate_file_extension_valid_single() {
248        let path = Path::new("config.yaml");
249        let allowed = vec!["yaml".to_string()];
250
251        let result = validate_file_extension(path, "config", &allowed);
252        assert!(result.is_ok());
253    }
254
255    #[test]
256    fn test_validate_file_extension_valid_multiple() {
257        let path = Path::new("data.csv");
258        let allowed = vec!["csv".to_string(), "tsv".to_string(), "txt".to_string()];
259
260        let result = validate_file_extension(path, "data_file", &allowed);
261        assert!(result.is_ok());
262    }
263
264    #[test]
265    fn test_validate_file_extension_case_insensitive() {
266        // File with uppercase extension
267        let path1 = Path::new("config.YAML");
268        let allowed = vec!["yaml".to_string()];
269
270        assert!(validate_file_extension(path1, "config", &allowed).is_ok());
271
272        // File with mixed case extension
273        let path2 = Path::new("config.YaML");
274        assert!(validate_file_extension(path2, "config", &allowed).is_ok());
275
276        // Allowed extensions in uppercase
277        let path3 = Path::new("config.yaml");
278        let allowed_upper = vec!["YAML".to_string()];
279        assert!(validate_file_extension(path3, "config", &allowed_upper).is_ok());
280    }
281
282    #[test]
283    fn test_validate_file_extension_invalid() {
284        let path = Path::new("document.txt");
285        let allowed = vec!["yaml".to_string(), "yml".to_string()];
286
287        let result = validate_file_extension(path, "doc", &allowed);
288
289        assert!(result.is_err());
290        match result.unwrap_err() {
291            crate::error::DynamicCliError::Validation(ValidationError::InvalidExtension {
292                arg_name,
293                path: error_path,
294                expected,
295            }) => {
296                assert_eq!(arg_name, "doc");
297                assert_eq!(error_path, path);
298                assert_eq!(expected, allowed);
299            }
300            other => panic!("Expected InvalidExtension error, got {:?}", other),
301        }
302    }
303
304    #[test]
305    fn test_validate_file_extension_no_extension() {
306        let path = Path::new("makefile");
307        let allowed = vec!["txt".to_string()];
308
309        let result = validate_file_extension(path, "build_file", &allowed);
310
311        assert!(result.is_err());
312        match result.unwrap_err() {
313            crate::error::DynamicCliError::Validation(ValidationError::InvalidExtension {
314                ..
315            }) => {
316                // Expected
317            }
318            other => panic!("Expected InvalidExtension error, got {:?}", other),
319        }
320    }
321
322    #[test]
323    fn test_validate_file_extension_hidden_file_with_extension() {
324        // Hidden files WITH extensions work normally
325        // .hidden.txt has extension "txt"
326        let path = Path::new(".hidden.txt");
327        let allowed = vec!["txt".to_string()];
328
329        let result = validate_file_extension(path, "hidden_file", &allowed);
330        assert!(result.is_ok());
331    }
332
333    #[test]
334    fn test_validate_file_extension_hidden_file_no_extension() {
335        // Pure hidden files (like .gitignore) have NO extension
336        // This should fail validation
337        let path = Path::new(".gitignore");
338        let allowed = vec!["txt".to_string()];
339
340        let result = validate_file_extension(path, "git_file", &allowed);
341        // .gitignore has no extension, so validation should fail
342        assert!(result.is_err());
343    }
344
345    #[test]
346    fn test_validate_file_extension_multiple_dots() {
347        let path = Path::new("archive.tar.gz");
348        let allowed = vec!["gz".to_string()];
349
350        // Only the last extension is checked
351        let result = validate_file_extension(path, "archive", &allowed);
352        assert!(result.is_ok());
353    }
354
355    #[test]
356    fn test_validate_file_extension_empty_allowed_list() {
357        let path = Path::new("file.txt");
358        let allowed: Vec<String> = vec![];
359
360        let result = validate_file_extension(path, "file", &allowed);
361        assert!(result.is_err());
362    }
363
364    #[test]
365    fn test_validate_file_extension_with_leading_dot() {
366        // Even though we specify extensions without dots,
367        // the function should still work correctly
368        let path = Path::new("config.yaml");
369
370        // User mistakenly includes the dot - should still work
371        // because we compare lowercase extensions
372        let allowed = vec!["yaml".to_string()];
373
374        let result = validate_file_extension(path, "config", &allowed);
375        assert!(result.is_ok());
376    }
377
378    // ========================================================================
379    // Integration tests
380    // ========================================================================
381
382    #[test]
383    fn test_validate_both_file_and_extension() {
384        let temp_dir = TempDir::new().unwrap();
385        let file_path = create_temp_file(&temp_dir, "config.yaml", "key: value");
386
387        // First validate existence
388        let result1 = validate_file_exists(&file_path, "config_file");
389        assert!(result1.is_ok());
390
391        // Then validate extension
392        let allowed = vec!["yaml".to_string(), "yml".to_string()];
393        let result2 = validate_file_extension(&file_path, "config_file", &allowed);
394        assert!(result2.is_ok());
395    }
396
397    #[test]
398    fn test_validate_wrong_extension_existing_file() {
399        let temp_dir = TempDir::new().unwrap();
400        let file_path = create_temp_file(&temp_dir, "data.txt", "some data");
401
402        // File exists
403        assert!(validate_file_exists(&file_path, "data_file").is_ok());
404
405        // But extension is wrong
406        let allowed = vec!["csv".to_string()];
407        let result = validate_file_extension(&file_path, "data_file", &allowed);
408        assert!(result.is_err());
409    }
410
411    #[test]
412    fn test_validate_extension_nonexistent_file() {
413        // Extension validation doesn't check if file exists
414        let path = Path::new("nonexistent.yaml");
415        let allowed = vec!["yaml".to_string()];
416
417        // Extension validation succeeds (only checks extension)
418        let result = validate_file_extension(path, "config", &allowed);
419        assert!(result.is_ok());
420
421        // But existence validation fails
422        let result2 = validate_file_exists(path, "config");
423        assert!(result2.is_err());
424    }
425}