fsvalidator/
validate.rs

1//! # Validation
2//!
3//! Contains the logic for validating filesystem paths against the model.
4//!
5//! This module implements the core validation functionality, checking whether real filesystem
6//! paths match the expected structure defined in the model.
7
8use crate::model::{DirNode, FileNode, Node, NodeName};
9use anyhow::{Result, anyhow};
10use regex::Regex;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::fmt;
14use std::error::Error;
15
16/// Error category for classifying validation errors.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum ErrorCategory {
19    /// Missing file or directory that was required
20    Missing,
21    /// Path exists but is the wrong type (e.g., file instead of directory)
22    WrongType,
23    /// Name doesn't match expected literal or pattern
24    NameMismatch,
25    /// Unexpected entry in directory with allow_defined_only=true
26    Unexpected,
27    /// Invalid regex pattern
28    InvalidPattern,
29    /// Error accessing filesystem
30    IoError,
31    /// Other/miscellaneous errors
32    Other,
33}
34
35/// Represents a validation error encountered during filesystem validation.
36#[derive(Debug)]
37pub struct ValidationError {
38    /// Path where the validation error occurred
39    pub path: PathBuf,
40    /// Description of the validation error
41    pub message: String,
42    /// Category of the validation error
43    pub category: ErrorCategory,
44    /// Nested validation errors (for directory validation)
45    pub children: Vec<ValidationError>,
46}
47
48impl ValidationError {
49    /// Creates a new ValidationError with the given path, message, and category.
50    pub fn new(
51        path: impl AsRef<Path>,
52        message: impl Into<String>,
53        category: ErrorCategory
54    ) -> Self {
55        ValidationError {
56            path: path.as_ref().to_path_buf(),
57            message: message.into(),
58            category,
59            children: Vec::new(),
60        }
61    }
62
63    /// Creates a new ValidationError with the given path, message, category, and child errors.
64    pub fn with_children(
65        path: impl AsRef<Path>, 
66        message: impl Into<String>,
67        category: ErrorCategory,
68        children: Vec<ValidationError>
69    ) -> Self {
70        ValidationError {
71            path: path.as_ref().to_path_buf(),
72            message: message.into(),
73            category,
74            children,
75        }
76    }
77
78    /// Adds a child validation error to this error.
79    pub fn add_child(&mut self, error: ValidationError) {
80        self.children.push(error);
81    }
82}
83
84impl fmt::Display for ValidationError {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        writeln!(f, "At {}: {}", self.path.display(), self.message)?;
87        for child in &self.children {
88            write!(f, "  ")?;
89            write!(f, "{}", child)?;
90        }
91        Ok(())
92    }
93}
94
95impl Error for ValidationError {}
96
97/// Result type for validation operations that may return multiple validation errors.
98pub type ValidationResult = Result<(), ValidationError>;
99
100impl Node {
101    /// Validates a filesystem path against this node, collecting all validation errors.
102    ///
103    /// This method checks whether the given path matches the requirements specified by this node.
104    /// For file nodes, it verifies the file exists (if required) and matches the name/pattern.
105    /// For directory nodes, it checks the directory exists (if required), matches the name/pattern,
106    /// and contains the expected children.
107    ///
108    /// # Arguments
109    ///
110    /// * `path` - The direct filesystem path to the item (file or directory) to validate
111    ///
112    /// # Returns
113    ///
114    /// * `Ok(())` - If the path matches the requirements
115    /// * `Err(ValidationError)` - If the path doesn't match, with all validation errors found
116    ///
117    /// # Example
118    ///
119    /// ```rust,no_run
120    /// use fsvalidator::model::{DirNode, FileNode, NodeName};
121    ///
122    /// // Create a validation model
123    /// let file_node = FileNode::new(NodeName::Literal("README.md".to_string()), true);
124    ///
125    /// // Validate a path
126    /// let result = file_node.validate("./project/README.md");
127    /// assert!(result.is_ok());
128    /// ```
129    pub fn validate(&self, path: impl AsRef<Path>) -> ValidationResult {
130        let path = path.as_ref();
131        match self {
132            Node::File(file_rc) => {
133                let file = file_rc.borrow();
134                validate_file(&file, path)
135            }
136
137            Node::Dir(dir_rc) => {
138                let dir = dir_rc.borrow();
139                validate_dir(&dir, path)
140            }
141        }
142    }
143}
144
145/// Validates a filesystem path against a file node.
146///
147/// Checks if the file exists (when required) and matches the specified name or pattern.
148///
149/// # Arguments
150///
151/// * `file` - The file node to validate against
152/// * `file_path` - The direct path to the file to validate
153///
154/// # Returns
155///
156/// * `Ok(())` - If validation succeeds
157/// * `Err(ValidationError)` - If validation fails, containing all validation errors
158fn validate_file(file: &FileNode, file_path: &Path) -> ValidationResult {
159    // Check if the file exists and is actually a file
160    if file_path.exists() {
161        if !file_path.is_file() {
162            return Err(ValidationError::new(
163                file_path,
164                format!("Path exists but is not a file"),
165                ErrorCategory::WrongType,
166            ));
167        }
168
169        // Get the file name to check against the pattern
170        let file_name = match file_path.file_name() {
171            Some(name) => name.to_string_lossy().to_string(),
172            None => {
173                return Err(ValidationError::new(
174                    file_path,
175                    format!("Invalid file path (no filename)"),
176                    ErrorCategory::Other,
177                ))
178            }
179        };
180
181        match &file.name {
182            NodeName::Literal(name) => {
183                if &file_name != name {
184                    return Err(ValidationError::new(
185                        file_path,
186                        format!(
187                            "File name '{}' doesn't match expected name '{}'",
188                            file_name, name
189                        ),
190                        ErrorCategory::NameMismatch,
191                    ));
192                }
193                Ok(())
194            }
195            NodeName::Pattern(pattern) => {
196                let re = match Regex::new(pattern) {
197                    Ok(re) => re,
198                    Err(e) => {
199                        return Err(ValidationError::new(
200                            file_path,
201                            format!("Invalid regex pattern '{}': {}", pattern, e),
202                            ErrorCategory::InvalidPattern,
203                        ));
204                    }
205                };
206
207                if re.is_match(&file_name) {
208                    Ok(())
209                } else {
210                    Err(ValidationError::new(
211                        file_path,
212                        format!(
213                            "File name '{}' doesn't match expected pattern '{}'",
214                            file_name, pattern
215                        ),
216                        ErrorCategory::NameMismatch,
217                    ))
218                }
219            }
220        }
221    } else if file.required {
222        Err(ValidationError::new(
223            file_path,
224            format!("Missing required file"),
225            ErrorCategory::Missing,
226        ))
227    } else {
228        Ok(())
229    }
230}
231
232/// Validates a filesystem path against a directory node.
233///
234/// Checks if the directory exists (when required), matches the specified name or pattern,
235/// contains the expected children, and doesn't contain unexpected entries (when allow_defined_only is true).
236///
237/// # Arguments
238///
239/// * `dir` - The directory node to validate against
240/// * `dir_path` - The direct path to the directory to validate
241///
242/// # Returns
243///
244/// * `Ok(())` - If validation succeeds
245/// * `Err(ValidationError)` - If validation fails, containing all validation errors
246fn validate_dir(dir: &DirNode, dir_path: &Path) -> ValidationResult {
247    // Check if the directory exists and is actually a directory
248    if !dir_path.exists() {
249        return if dir.required {
250            Err(ValidationError::new(
251                dir_path,
252                format!("Missing required directory"),
253                ErrorCategory::Missing,
254            ))
255        } else {
256            Ok(())
257        };
258    }
259
260    if !dir_path.is_dir() {
261        return Err(ValidationError::new(
262            dir_path,
263            format!("Path exists but is not a directory"),
264            ErrorCategory::WrongType,
265        ));
266    }
267
268    // Get the directory name to check against pattern/literal
269    let dir_name = match dir_path.file_name() {
270        Some(name) => name.to_string_lossy().to_string(),
271        None => {
272            return Err(ValidationError::new(
273                dir_path,
274                format!("Invalid directory path (no directory name)"),
275                ErrorCategory::Other,
276            ));
277        }
278    };
279
280    let mut validation_errors = Vec::new();
281
282    // Validate directory name against expected name/pattern
283    match &dir.name {
284        NodeName::Literal(name) => {
285            if &dir_name != name {
286                validation_errors.push(ValidationError::new(
287                    dir_path,
288                    format!(
289                        "Directory name '{}' doesn't match expected name '{}'",
290                        dir_name, name
291                    ),
292                    ErrorCategory::NameMismatch,
293                ));
294            }
295        }
296        NodeName::Pattern(pattern) => {
297            let re = match Regex::new(pattern) {
298                Ok(re) => re,
299                Err(e) => {
300                    return Err(ValidationError::new(
301                        dir_path,
302                        format!("Invalid regex pattern '{}': {}", pattern, e),
303                        ErrorCategory::InvalidPattern,
304                    ));
305                }
306            };
307
308            if !re.is_match(&dir_name) {
309                validation_errors.push(ValidationError::new(
310                    dir_path,
311                    format!(
312                        "Directory name '{}' doesn't match expected pattern '{}'",
313                        dir_name, pattern
314                    ),
315                    ErrorCategory::NameMismatch,
316                ));
317            }
318        }
319    }
320
321    // Check for unexpected entries if allow_defined_only is true
322    if dir.allow_defined_only {
323        let expected_names: Vec<String> = dir
324            .children
325            .iter()
326            .map(|node| match node {
327                Node::File(f) => f.borrow().name_name_string(),
328                Node::Dir(d) => d.borrow().name_name_string(),
329            })
330            .collect();
331
332        let read_dir_result = match fs::read_dir(dir_path) {
333            Ok(entries) => entries,
334            Err(e) => {
335                validation_errors.push(ValidationError::new(
336                    dir_path,
337                    format!("Failed to read directory: {}", e),
338                    ErrorCategory::IoError,
339                ));
340                // Cannot continue checking entries
341                if !validation_errors.is_empty() {
342                    return Err(ValidationError::with_children(
343                        dir_path,
344                        format!("Directory validation failed with {} errors", validation_errors.len()),
345                        ErrorCategory::Other,
346                        validation_errors,
347                    ));
348                }
349                return Ok(());
350            }
351        };
352
353        for entry_result in read_dir_result {
354            let entry = match entry_result {
355                Ok(e) => e,
356                Err(e) => {
357                    validation_errors.push(ValidationError::new(
358                        dir_path,
359                        format!("Failed to read directory entry: {}", e),
360                        ErrorCategory::IoError,
361                    ));
362                    continue;
363                }
364            };
365
366            if dir
367                .excluded
368                .iter()
369                .any(|re| re.is_match(entry.file_name().to_string_lossy().to_string().as_str()))
370            {
371                continue;
372            }
373
374            let name = entry.file_name().to_string_lossy().to_string();
375            if !expected_names.iter().any(|p| pattern_match(p, &name)) {
376                validation_errors.push(ValidationError::new(
377                    entry.path(),
378                    format!("Unexpected entry in directory"),
379                    ErrorCategory::Unexpected,
380                ));
381            }
382        }
383    }
384
385    // Validate children
386    let mut child_errors = Vec::new();
387    for child in &dir.children {
388        // For child nodes, we need to find the actual path of the child
389        match child {
390            Node::File(f_rc) => {
391                let f = f_rc.borrow();
392                match &f.name {
393                    NodeName::Literal(name) => {
394                        let child_path = dir_path.join(name);
395                        if let Err(err) = child.validate(&child_path) {
396                            child_errors.push(err);
397                        }
398                    }
399                    NodeName::Pattern(pattern) => {
400                        let re = match Regex::new(pattern) {
401                            Ok(re) => re,
402                            Err(e) => {
403                                validation_errors.push(ValidationError::new(
404                                    dir_path,
405                                    format!("Invalid regex pattern '{}': {}", pattern, e),
406                                    ErrorCategory::InvalidPattern,
407                                ));
408                                continue;
409                            }
410                        };
411
412                        let read_dir_result = match fs::read_dir(dir_path) {
413                            Ok(entries) => entries,
414                            Err(e) => {
415                                validation_errors.push(ValidationError::new(
416                                    dir_path,
417                                    format!("Failed to read directory when matching pattern: {}", e),
418                                    ErrorCategory::IoError,
419                                ));
420                                continue;
421                            }
422                        };
423
424                        let mut matched_path = None;
425                        for entry_result in read_dir_result {
426                            let entry = match entry_result {
427                                Ok(e) => e,
428                                Err(e) => {
429                                    validation_errors.push(ValidationError::new(
430                                        dir_path,
431                                        format!("Failed to read directory entry: {}", e),
432                                        ErrorCategory::IoError,
433                                    ));
434                                    continue;
435                                }
436                            };
437
438                            if dir.excluded.iter().any(|re| {
439                                re.is_match(
440                                    entry.file_name().to_string_lossy().to_string().as_str(),
441                                )
442                            }) {
443                                continue;
444                            }
445
446                            if entry.path().is_file() {
447                                let name = entry.file_name().to_string_lossy().to_string();
448                                if re.is_match(&name) {
449                                    matched_path = Some(entry.path());
450                                    break;
451                                }
452                            }
453                        }
454
455                        match matched_path {
456                            Some(path) => {
457                                if let Err(err) = child.validate(&path) {
458                                    child_errors.push(err);
459                                }
460                            }
461                            None => {
462                                if f.required {
463                                    validation_errors.push(ValidationError::new(
464                                        dir_path,
465                                        format!(
466                                            "Missing required file matching pattern: {}",
467                                            pattern
468                                        ),
469                                        ErrorCategory::Missing,
470                                    ));
471                                }
472                            }
473                        }
474                    }
475                }
476            }
477
478            Node::Dir(d_rc) => {
479                let d = d_rc.borrow();
480                match &d.name {
481                    NodeName::Literal(name) => {
482                        let child_path = dir_path.join(name);
483                        if let Err(err) = child.validate(&child_path) {
484                            child_errors.push(err);
485                        }
486                    }
487                    NodeName::Pattern(pattern) => {
488                        let re = match Regex::new(pattern) {
489                            Ok(re) => re,
490                            Err(e) => {
491                                validation_errors.push(ValidationError::new(
492                                    dir_path,
493                                    format!("Invalid regex pattern '{}': {}", pattern, e),
494                                    ErrorCategory::InvalidPattern,
495                                ));
496                                continue;
497                            }
498                        };
499
500                        let read_dir_result = match fs::read_dir(dir_path) {
501                            Ok(entries) => entries,
502                            Err(e) => {
503                                validation_errors.push(ValidationError::new(
504                                    dir_path,
505                                    format!("Failed to read directory when matching pattern: {}", e),
506                                    ErrorCategory::IoError,
507                                ));
508                                continue;
509                            }
510                        };
511
512                        let mut matched_paths = vec![];
513                        for entry_result in read_dir_result {
514                            let entry = match entry_result {
515                                Ok(e) => e,
516                                Err(e) => {
517                                    validation_errors.push(ValidationError::new(
518                                        dir_path,
519                                        format!("Failed to read directory entry: {}", e),
520                                        ErrorCategory::IoError,
521                                    ));
522                                    continue;
523                                }
524                            };
525
526                            if dir.excluded.iter().any(|re| {
527                                re.is_match(
528                                    entry.file_name().to_string_lossy().to_string().as_str(),
529                                )
530                            }) {
531                                continue;
532                            }
533                            if entry.path().is_dir() {
534                                let name = entry.file_name().to_string_lossy().to_string();
535                                if re.is_match(&name) {
536                                    matched_paths.push(entry.path());
537                                }
538                            }
539                        }
540
541                        if matched_paths.is_empty() {
542                            if d.required {
543                                validation_errors.push(ValidationError::new(
544                                    dir_path,
545                                    format!(
546                                        "Missing required directory matching pattern: {}",
547                                        pattern
548                                    ),
549                                    ErrorCategory::Missing,
550                                ));
551                            }
552                        } else {
553                            // Validate all matching directories
554                            for matched_path in matched_paths {
555                                if let Err(err) = child.validate(&matched_path) {
556                                    child_errors.push(err);
557                                }
558                            }
559                        }
560                    }
561                }
562            }
563        };
564    }
565
566    // Combine all validation errors
567    validation_errors.extend(child_errors);
568
569    if validation_errors.is_empty() {
570        Ok(())
571    } else {
572        Err(ValidationError::with_children(
573            dir_path,
574            format!("Directory validation failed with {} errors", validation_errors.len()),
575            ErrorCategory::Other,
576            validation_errors,
577        ))
578    }
579}
580
581/// Checks if a name matches a pattern string.
582///
583/// If the pattern starts with "PATTERN(", it's treated as a regex pattern.
584/// Otherwise, it's treated as a literal string that must match exactly.
585///
586/// # Arguments
587///
588/// * `pattern` - The pattern string (either a regex pattern or literal)
589/// * `name` - The name to check against the pattern
590///
591/// # Returns
592///
593/// * `true` if the name matches the pattern
594/// * `false` if the name doesn't match
595fn pattern_match(pattern: &str, name: &str) -> bool {
596    if pattern.starts_with("PATTERN(") && pattern.ends_with(")") {
597        let inner = &pattern[8..pattern.len() - 1]; // Extract regex pattern
598        match Regex::new(inner) {
599            Ok(re) => re.is_match(name),
600            Err(e) => {
601                eprintln!("Error compiling regex pattern '{}': {}", inner, e);
602                false
603            }
604        }
605    } else {
606        pattern == name
607    }
608}
609
610/// Converts a `ValidationResult` to a standard `anyhow::Result`.
611///
612/// This helper function is useful for code that can't work with a ValidationResult directly.
613pub fn to_anyhow_result(result: ValidationResult) -> Result<()> {
614    match result {
615        Ok(()) => Ok(()),
616        Err(validation_err) => Err(anyhow!(validation_err.to_string())),
617    }
618}
619
620/// A trait for converting a NodeName to a string representation.
621///
622/// This is used internally for comparing node names during validation.
623trait NameString {
624    /// Converts a node name to a string representation.
625    fn name_name_string(&self) -> String;
626}
627
628impl NameString for DirNode {
629    fn name_name_string(&self) -> String {
630        match &self.name {
631            NodeName::Literal(s) => s.clone(),
632            NodeName::Pattern(p) => format!("PATTERN({})", p),
633        }
634    }
635}
636
637impl NameString for FileNode {
638    fn name_name_string(&self) -> String {
639        match &self.name {
640            NodeName::Literal(s) => s.clone(),
641            NodeName::Pattern(p) => format!("PATTERN({})", p),
642        }
643    }
644}