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