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}