Skip to main content

dynamic_cli/validator/
file_validator.rs

1//! File validation utilities
2//!
3//! This module provides functions for validating file paths, including
4//! checking for file existence and validating file extensions.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use dynamic_cli::validator::{validate_file_exists, validate_file_extension};
10//! use std::path::Path;
11//!
12//! let path = Path::new("config.yaml");
13//!
14//! // Check if file exists
15//! validate_file_exists(path, "config")?;
16//!
17//! // Check if extension is allowed
18//! let allowed = vec!["yaml".to_string(), "yml".to_string()];
19//! validate_file_extension(path, "config", &allowed)?;
20//!
21//! # Ok::<(), dynamic_cli::error::DynamicCliError>(())
22//! ```
23
24use crate::error::{Result, ValidationError};
25use std::path::Path;
26
27/// Validate that a file or directory exists at the given path.
28///
29/// # Arguments
30///
31/// * `path` - The path to check
32/// * `arg_name` - The argument name (used in error messages)
33///
34/// # Returns
35///
36/// - `Ok(())` if the path exists
37/// - `Err(ValidationError::FileNotFound)` if the path does not exist
38///
39/// # Example
40///
41/// ```no_run
42/// use dynamic_cli::validator::validate_file_exists;
43/// use std::path::Path;
44///
45/// validate_file_exists(Path::new("config.yaml"), "config")?;
46/// # Ok::<(), dynamic_cli::error::DynamicCliError>(())
47/// ```
48pub fn validate_file_exists(path: &Path, arg_name: &str) -> Result<()> {
49    if !path.exists() {
50        return Err(ValidationError::FileNotFound {
51            path: path.to_path_buf(),
52            arg_name: arg_name.to_string(),
53            suggestion: Some(format!(
54                "Check that the file '{}' exists and the path is correct",
55                path.display()
56            )),
57        }
58        .into());
59    }
60    Ok(())
61}
62
63/// Validate that a file has an allowed extension.
64///
65/// The comparison is case-insensitive. Extensions should be provided
66/// without the leading dot (e.g., `"yaml"`, not `".yaml"`).
67///
68/// # Arguments
69///
70/// * `path` - The path whose extension to check
71/// * `arg_name` - The argument name (used in error messages)
72/// * `allowed` - List of allowed extensions (without leading dot)
73///
74/// # Returns
75///
76/// - `Ok(())` if the extension is in the allowed list
77/// - `Err(ValidationError::InvalidExtension)` otherwise
78///
79/// # Example
80///
81/// ```no_run
82/// use dynamic_cli::validator::validate_file_extension;
83/// use std::path::Path;
84///
85/// let allowed = vec!["yaml".to_string(), "yml".to_string()];
86/// validate_file_extension(Path::new("config.yaml"), "config", &allowed)?;
87/// # Ok::<(), dynamic_cli::error::DynamicCliError>(())
88/// ```
89pub fn validate_file_extension(path: &Path, arg_name: &str, allowed: &[String]) -> Result<()> {
90    if allowed.is_empty() {
91        return Err(ValidationError::InvalidExtension {
92            path: path.to_path_buf(),
93            arg_name: arg_name.to_string(),
94            expected: allowed.to_vec(),
95        }
96        .into());
97    }
98
99    let ext = path
100        .extension()
101        .and_then(|e| e.to_str())
102        .map(|e| e.to_lowercase());
103
104    match ext {
105        Some(ext) if allowed.iter().any(|a| a.to_lowercase() == ext) => Ok(()),
106        _ => Err(ValidationError::InvalidExtension {
107            path: path.to_path_buf(),
108            arg_name: arg_name.to_string(),
109            expected: allowed.to_vec(),
110        }
111        .into()),
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use std::io::Write;
119    use tempfile::NamedTempFile;
120
121    /// Helper: create a NamedTempFile with a given extension and content.
122    ///
123    /// Uses `NamedTempFile` directly instead of `TempDir` + `File::create`
124    /// to avoid the `File::create` race condition under parallel test execution.
125    fn temp_file_with_ext(ext: &str, content: &str) -> NamedTempFile {
126        let mut f = tempfile::Builder::new()
127            .suffix(&format!(".{}", ext))
128            .tempfile()
129            .expect("failed to create NamedTempFile");
130        f.write_all(content.as_bytes())
131            .expect("failed to write to NamedTempFile");
132        f
133    }
134
135    // ========================================================================
136    // Tests for validate_file_exists
137    // ========================================================================
138
139    #[test]
140    fn test_validate_file_exists_valid_file() {
141        let f = temp_file_with_ext("txt", "content");
142        assert!(validate_file_exists(f.path(), "test_file").is_ok());
143    }
144
145    #[test]
146    fn test_validate_file_exists_valid_directory() {
147        // std::env::temp_dir() always exists — no file creation needed.
148        let dir = std::env::temp_dir();
149        assert!(validate_file_exists(&dir, "test_dir").is_ok());
150    }
151
152    #[test]
153    fn test_validate_file_exists_nonexistent() {
154        let path = std::path::Path::new("/tmp/dynamic_cli_no_such_file_xyz.txt");
155        let result = validate_file_exists(path, "missing_file");
156        assert!(result.is_err());
157        match result.unwrap_err() {
158            crate::error::DynamicCliError::Validation(ValidationError::FileNotFound {
159                path: p,
160                arg_name,
161                ..
162            }) => {
163                assert_eq!(arg_name, "missing_file");
164                assert_eq!(p, path);
165            }
166            other => panic!("Expected FileNotFound error, got {:?}", other),
167        }
168    }
169
170    #[test]
171    fn test_validate_file_exists_relative_path() {
172        // Validates with a relative path guaranteed to exist at the project
173        // root. Avoids set_current_dir() which mutates the process-wide
174        // working directory and causes data races under parallel test execution.
175        let relative = std::path::Path::new("Cargo.toml");
176        assert!(validate_file_exists(relative, "cargo_manifest").is_ok());
177    }
178
179    // ========================================================================
180    // Tests for validate_file_extension
181    // ========================================================================
182
183    #[test]
184    fn test_validate_file_extension_valid_single() {
185        let path = Path::new("config.yaml");
186        let allowed = vec!["yaml".to_string()];
187        assert!(validate_file_extension(path, "config", &allowed).is_ok());
188    }
189
190    #[test]
191    fn test_validate_file_extension_valid_multiple() {
192        let path = Path::new("data.csv");
193        let allowed = vec!["csv".to_string(), "tsv".to_string(), "txt".to_string()];
194        assert!(validate_file_extension(path, "data_file", &allowed).is_ok());
195    }
196
197    #[test]
198    fn test_validate_file_extension_case_insensitive() {
199        let allowed = vec!["yaml".to_string()];
200        assert!(validate_file_extension(Path::new("config.YAML"), "config", &allowed).is_ok());
201        assert!(validate_file_extension(Path::new("config.YaML"), "config", &allowed).is_ok());
202        let allowed_upper = vec!["YAML".to_string()];
203        assert!(
204            validate_file_extension(Path::new("config.yaml"), "config", &allowed_upper).is_ok()
205        );
206    }
207
208    #[test]
209    fn test_validate_file_extension_invalid() {
210        let path = Path::new("document.txt");
211        let allowed = vec!["yaml".to_string(), "yml".to_string()];
212        let result = validate_file_extension(path, "doc", &allowed);
213        assert!(result.is_err());
214        match result.unwrap_err() {
215            crate::error::DynamicCliError::Validation(ValidationError::InvalidExtension {
216                arg_name,
217                path: error_path,
218                expected,
219            }) => {
220                assert_eq!(arg_name, "doc");
221                assert_eq!(error_path, path);
222                assert_eq!(expected, allowed);
223            }
224            other => panic!("Expected InvalidExtension error, got {:?}", other),
225        }
226    }
227
228    #[test]
229    fn test_validate_file_extension_no_extension() {
230        let path = Path::new("makefile");
231        let allowed = vec!["txt".to_string()];
232        let result = validate_file_extension(path, "build_file", &allowed);
233        assert!(result.is_err());
234        match result.unwrap_err() {
235            crate::error::DynamicCliError::Validation(ValidationError::InvalidExtension {
236                ..
237            }) => {}
238            other => panic!("Expected InvalidExtension error, got {:?}", other),
239        }
240    }
241
242    #[test]
243    fn test_validate_file_extension_hidden_file_with_extension() {
244        let path = Path::new(".hidden.txt");
245        let allowed = vec!["txt".to_string()];
246        assert!(validate_file_extension(path, "hidden_file", &allowed).is_ok());
247    }
248
249    #[test]
250    fn test_validate_file_extension_hidden_file_no_extension() {
251        let path = Path::new(".gitignore");
252        let allowed = vec!["txt".to_string()];
253        assert!(validate_file_extension(path, "git_file", &allowed).is_err());
254    }
255
256    #[test]
257    fn test_validate_file_extension_multiple_dots() {
258        let path = Path::new("archive.tar.gz");
259        let allowed = vec!["gz".to_string()];
260        assert!(validate_file_extension(path, "archive", &allowed).is_ok());
261    }
262
263    #[test]
264    fn test_validate_file_extension_empty_allowed_list() {
265        let path = Path::new("file.txt");
266        let allowed: Vec<String> = vec![];
267        assert!(validate_file_extension(path, "file", &allowed).is_err());
268    }
269
270    #[test]
271    fn test_validate_file_extension_with_leading_dot() {
272        let path = Path::new("config.yaml");
273        let allowed = vec!["yaml".to_string()];
274        assert!(validate_file_extension(path, "config", &allowed).is_ok());
275    }
276
277    // ========================================================================
278    // Integration tests
279    // ========================================================================
280
281    #[test]
282    fn test_validate_both_file_and_extension() {
283        let f = temp_file_with_ext("yaml", "key: value");
284        assert!(validate_file_exists(f.path(), "config_file").is_ok());
285        let allowed = vec!["yaml".to_string(), "yml".to_string()];
286        assert!(validate_file_extension(f.path(), "config_file", &allowed).is_ok());
287    }
288
289    #[test]
290    fn test_validate_wrong_extension_existing_file() {
291        let f = temp_file_with_ext("txt", "some data");
292        assert!(validate_file_exists(f.path(), "data_file").is_ok());
293        let allowed = vec!["csv".to_string()];
294        assert!(validate_file_extension(f.path(), "data_file", &allowed).is_err());
295    }
296
297    #[test]
298    fn test_validate_extension_nonexistent_file() {
299        let path = Path::new("nonexistent.yaml");
300        let allowed = vec!["yaml".to_string()];
301        assert!(validate_file_extension(path, "config", &allowed).is_ok());
302        assert!(validate_file_exists(path, "config").is_err());
303    }
304}