agentic_navigation_guide/
verifier.rs

1//! Filesystem verification for navigation guides
2
3use crate::errors::{Result, SemanticError};
4use crate::types::{FilesystemItem, NavigationGuide, NavigationGuideLine};
5use std::path::{Path, PathBuf};
6
7/// Verifier for navigation guides against filesystem
8pub struct Verifier {
9    /// Root path for verification
10    root_path: PathBuf,
11}
12
13impl Verifier {
14    /// Create a new verifier with the given root path
15    pub fn new(root_path: &Path) -> Self {
16        Self {
17            root_path: root_path.to_path_buf(),
18        }
19    }
20
21    /// Verify a navigation guide against the filesystem
22    pub fn verify(&self, guide: &NavigationGuide) -> Result<()> {
23        // First validate syntax (should already be done, but double-check)
24        crate::validator::Validator::new().validate_syntax(guide)?;
25
26        // Collect mentioned root-level names for placeholder verification
27        let mut mentioned_names = std::collections::HashSet::new();
28        for item in &guide.items {
29            if !item.is_placeholder() {
30                mentioned_names.insert(item.path().to_string());
31            }
32        }
33
34        // Then verify each item against the filesystem
35        for item in &guide.items {
36            if item.is_placeholder() {
37                self.verify_placeholder_with_context(item, &self.root_path, &mentioned_names)?;
38            } else {
39                self.verify_item(item, &self.root_path)?;
40            }
41        }
42
43        Ok(())
44    }
45
46    /// Verify a single item against the filesystem
47    fn verify_item(&self, item: &NavigationGuideLine, parent_path: &Path) -> Result<()> {
48        // Handle placeholders specially
49        if item.is_placeholder() {
50            return self.verify_placeholder(item, parent_path);
51        }
52
53        let item_path = parent_path.join(item.path());
54
55        // Check if the item exists
56        if !item_path.exists() {
57            return Err(SemanticError::ItemNotFound {
58                line: item.line_number,
59                item_type: self.get_item_type_string(item),
60                path: item.path().to_string(),
61                full_path: item_path,
62            }
63            .into());
64        }
65
66        // Check if the item type matches
67        match &item.item {
68            FilesystemItem::Directory { children, .. } => {
69                if !item_path.is_dir() {
70                    return Err(SemanticError::TypeMismatch {
71                        line: item.line_number,
72                        expected: "directory".to_string(),
73                        found: if item_path.is_file() {
74                            "file".to_string()
75                        } else {
76                            "symlink".to_string()
77                        },
78                        path: item.path().to_string(),
79                    }
80                    .into());
81                }
82
83                // Verify children recursively
84                let mut mentioned_names = std::collections::HashSet::new();
85                for child in children {
86                    if !child.is_placeholder() {
87                        mentioned_names.insert(child.path().to_string());
88                    }
89                }
90
91                for child in children {
92                    if child.is_placeholder() {
93                        self.verify_placeholder_with_context(child, &item_path, &mentioned_names)?;
94                    } else {
95                        self.verify_item(child, &item_path)?;
96                    }
97                }
98            }
99            FilesystemItem::File { .. } => {
100                if !item_path.is_file() {
101                    return Err(SemanticError::TypeMismatch {
102                        line: item.line_number,
103                        expected: "file".to_string(),
104                        found: if item_path.is_dir() {
105                            "directory".to_string()
106                        } else {
107                            "symlink".to_string()
108                        },
109                        path: item.path().to_string(),
110                    }
111                    .into());
112                }
113            }
114            FilesystemItem::Symlink { target, .. } => {
115                let metadata = match std::fs::symlink_metadata(&item_path) {
116                    Ok(m) => m,
117                    Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
118                        return Err(SemanticError::PermissionDenied {
119                            line: item.line_number,
120                            path: item.path().to_string(),
121                        }
122                        .into());
123                    }
124                    Err(e) => return Err(e.into()),
125                };
126
127                if !metadata.is_symlink() {
128                    return Err(SemanticError::TypeMismatch {
129                        line: item.line_number,
130                        expected: "symlink".to_string(),
131                        found: if item_path.is_dir() {
132                            "directory".to_string()
133                        } else {
134                            "file".to_string()
135                        },
136                        path: item.path().to_string(),
137                    }
138                    .into());
139                }
140
141                // Verify symlink target if specified
142                if let Some(expected_target) = target {
143                    if let Ok(actual_target) = std::fs::read_link(&item_path) {
144                        if actual_target.to_string_lossy() != *expected_target {
145                            return Err(SemanticError::SymlinkTargetMismatch {
146                                line: item.line_number,
147                                path: item.path().to_string(),
148                                expected: expected_target.clone(),
149                                actual: actual_target.to_string_lossy().to_string(),
150                            }
151                            .into());
152                        }
153                    }
154                }
155            }
156            FilesystemItem::Placeholder { .. } => {
157                // This case should never be reached because we handle placeholders
158                // at the beginning of the function, but we need it for exhaustiveness
159                unreachable!("Placeholder should have been handled earlier");
160            }
161        }
162
163        Ok(())
164    }
165
166    /// Get a human-readable string for the item type
167    fn get_item_type_string(&self, item: &NavigationGuideLine) -> String {
168        match &item.item {
169            FilesystemItem::Directory { .. } => "directory".to_string(),
170            FilesystemItem::File { .. } => "file".to_string(),
171            FilesystemItem::Symlink { .. } => "symlink".to_string(),
172            FilesystemItem::Placeholder { .. } => "placeholder".to_string(),
173        }
174    }
175
176    /// Verify a placeholder at the root level
177    fn verify_placeholder(&self, item: &NavigationGuideLine, parent_path: &Path) -> Result<()> {
178        // For root-level placeholders, we need to check that there's at least one item
179        // in the parent directory that isn't mentioned in the guide
180        let mentioned_names = std::collections::HashSet::new();
181        self.verify_placeholder_with_context(item, parent_path, &mentioned_names)
182    }
183
184    /// Verify a placeholder with context of mentioned sibling items
185    fn verify_placeholder_with_context(
186        &self,
187        item: &NavigationGuideLine,
188        parent_path: &Path,
189        mentioned_names: &std::collections::HashSet<String>,
190    ) -> Result<()> {
191        // Check that the parent directory has at least one item not in mentioned_names
192        let entries = match std::fs::read_dir(parent_path) {
193            Ok(entries) => entries,
194            Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
195                return Err(SemanticError::PermissionDenied {
196                    line: item.line_number,
197                    path: parent_path.to_string_lossy().to_string(),
198                }
199                .into());
200            }
201            Err(e) => return Err(e.into()),
202        };
203
204        // Count unmentioned items
205        let mut unmentioned_count = 0;
206        for entry in entries.flatten() {
207            if let Some(name) = entry.file_name().to_str() {
208                if !mentioned_names.contains(name) {
209                    unmentioned_count += 1;
210                }
211            }
212        }
213
214        if unmentioned_count == 0 {
215            // Only require unmentioned items if the placeholder has no comment.
216            // Placeholders with comments can represent future items that don't yet exist.
217            if item.comment().is_none() {
218                return Err(SemanticError::PlaceholderNoUnmentionedItems {
219                    line: item.line_number,
220                    parent: parent_path.to_string_lossy().to_string(),
221                }
222                .into());
223            }
224            // Placeholders with comments are allowed even without unmentioned items
225        }
226
227        Ok(())
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use tempfile::TempDir;
235
236    #[test]
237    fn test_verify_missing_file() {
238        let temp_dir = TempDir::new().unwrap();
239        let verifier = Verifier::new(temp_dir.path());
240
241        let guide = NavigationGuide {
242            items: vec![NavigationGuideLine {
243                line_number: 1,
244                indent_level: 0,
245                item: FilesystemItem::File {
246                    path: "missing.txt".to_string(),
247                    comment: None,
248                },
249            }],
250            prologue: None,
251            epilogue: None,
252            ignore: false,
253        };
254
255        let result = verifier.verify(&guide);
256        assert!(matches!(
257            result,
258            Err(crate::errors::AppError::Semantic(
259                SemanticError::ItemNotFound { .. }
260            ))
261        ));
262    }
263
264    #[test]
265    fn test_verify_placeholder_with_unmentioned_items() {
266        let temp_dir = TempDir::new().unwrap();
267
268        // Create files in temp directory
269        std::fs::write(temp_dir.path().join("main.rs"), "").unwrap();
270        std::fs::write(temp_dir.path().join("lib.rs"), "").unwrap();
271        std::fs::write(temp_dir.path().join("mod.rs"), "").unwrap();
272
273        let verifier = Verifier::new(temp_dir.path());
274
275        let guide = NavigationGuide {
276            items: vec![
277                NavigationGuideLine {
278                    line_number: 1,
279                    indent_level: 0,
280                    item: FilesystemItem::File {
281                        path: "main.rs".to_string(),
282                        comment: None,
283                    },
284                },
285                NavigationGuideLine {
286                    line_number: 2,
287                    indent_level: 0,
288                    item: FilesystemItem::Placeholder {
289                        comment: Some("other source files".to_string()),
290                    },
291                },
292            ],
293            prologue: None,
294            epilogue: None,
295            ignore: false,
296        };
297
298        // Should succeed because lib.rs and mod.rs are unmentioned
299        let result = verifier.verify(&guide);
300        assert!(result.is_ok());
301    }
302
303    #[test]
304    fn test_verify_placeholder_with_comment_no_items() {
305        let temp_dir = TempDir::new().unwrap();
306
307        // Create only one file
308        std::fs::write(temp_dir.path().join("main.rs"), "").unwrap();
309
310        let verifier = Verifier::new(temp_dir.path());
311
312        let guide = NavigationGuide {
313            items: vec![
314                NavigationGuideLine {
315                    line_number: 1,
316                    indent_level: 0,
317                    item: FilesystemItem::File {
318                        path: "main.rs".to_string(),
319                        comment: None,
320                    },
321                },
322                NavigationGuideLine {
323                    line_number: 2,
324                    indent_level: 0,
325                    item: FilesystemItem::Placeholder {
326                        comment: Some("future files will appear here".to_string()),
327                    },
328                },
329            ],
330            prologue: None,
331            epilogue: None,
332            ignore: false,
333        };
334
335        // Should succeed because placeholder has a comment (represents future items)
336        let result = verifier.verify(&guide);
337        assert!(result.is_ok());
338    }
339
340    #[test]
341    fn test_verify_placeholder_without_comment_no_items() {
342        let temp_dir = TempDir::new().unwrap();
343
344        // Create only one file
345        std::fs::write(temp_dir.path().join("main.rs"), "").unwrap();
346
347        let verifier = Verifier::new(temp_dir.path());
348
349        let guide = NavigationGuide {
350            items: vec![
351                NavigationGuideLine {
352                    line_number: 1,
353                    indent_level: 0,
354                    item: FilesystemItem::File {
355                        path: "main.rs".to_string(),
356                        comment: None,
357                    },
358                },
359                NavigationGuideLine {
360                    line_number: 2,
361                    indent_level: 0,
362                    item: FilesystemItem::Placeholder { comment: None },
363                },
364            ],
365            prologue: None,
366            epilogue: None,
367            ignore: false,
368        };
369
370        // Should fail because placeholder has no comment and all items are mentioned
371        let result = verifier.verify(&guide);
372        assert!(matches!(
373            result,
374            Err(crate::errors::AppError::Semantic(
375                SemanticError::PlaceholderNoUnmentionedItems { .. }
376            ))
377        ));
378    }
379
380    #[test]
381    fn test_verify_placeholder_in_directory() {
382        let temp_dir = TempDir::new().unwrap();
383        let src_dir = temp_dir.path().join("src");
384        std::fs::create_dir(&src_dir).unwrap();
385
386        // Create files in src directory
387        std::fs::write(src_dir.join("main.rs"), "").unwrap();
388        std::fs::write(src_dir.join("lib.rs"), "").unwrap();
389        std::fs::write(src_dir.join("utils.rs"), "").unwrap();
390
391        let verifier = Verifier::new(temp_dir.path());
392
393        let guide = NavigationGuide {
394            items: vec![NavigationGuideLine {
395                line_number: 1,
396                indent_level: 0,
397                item: FilesystemItem::Directory {
398                    path: "src".to_string(),
399                    comment: None,
400                    children: vec![
401                        NavigationGuideLine {
402                            line_number: 2,
403                            indent_level: 1,
404                            item: FilesystemItem::File {
405                                path: "main.rs".to_string(),
406                                comment: None,
407                            },
408                        },
409                        NavigationGuideLine {
410                            line_number: 3,
411                            indent_level: 1,
412                            item: FilesystemItem::Placeholder {
413                                comment: Some("other modules".to_string()),
414                            },
415                        },
416                    ],
417                },
418            }],
419            prologue: None,
420            epilogue: None,
421            ignore: false,
422        };
423
424        // Should succeed because lib.rs and utils.rs are unmentioned
425        let result = verifier.verify(&guide);
426        assert!(result.is_ok());
427    }
428
429    #[test]
430    fn test_verify_placeholder_in_empty_directory() {
431        let temp_dir = TempDir::new().unwrap();
432        let src_dir = temp_dir.path().join("src");
433        std::fs::create_dir(&src_dir).unwrap();
434
435        let verifier = Verifier::new(temp_dir.path());
436
437        let guide = NavigationGuide {
438            items: vec![NavigationGuideLine {
439                line_number: 1,
440                indent_level: 0,
441                item: FilesystemItem::Directory {
442                    path: "src".to_string(),
443                    comment: None,
444                    children: vec![NavigationGuideLine {
445                        line_number: 2,
446                        indent_level: 1,
447                        item: FilesystemItem::Placeholder {
448                            comment: Some("future files".to_string()),
449                        },
450                    }],
451                },
452            }],
453            prologue: None,
454            epilogue: None,
455            ignore: false,
456        };
457
458        // Should succeed because placeholder has a comment (represents future files)
459        let result = verifier.verify(&guide);
460        assert!(result.is_ok());
461    }
462
463    #[test]
464    fn test_verify_placeholder_in_empty_directory_no_comment() {
465        let temp_dir = TempDir::new().unwrap();
466        let src_dir = temp_dir.path().join("src");
467        std::fs::create_dir(&src_dir).unwrap();
468
469        let verifier = Verifier::new(temp_dir.path());
470
471        let guide = NavigationGuide {
472            items: vec![NavigationGuideLine {
473                line_number: 1,
474                indent_level: 0,
475                item: FilesystemItem::Directory {
476                    path: "src".to_string(),
477                    comment: None,
478                    children: vec![NavigationGuideLine {
479                        line_number: 2,
480                        indent_level: 1,
481                        item: FilesystemItem::Placeholder { comment: None },
482                    }],
483                },
484            }],
485            prologue: None,
486            epilogue: None,
487            ignore: false,
488        };
489
490        // Should fail because directory is empty and placeholder has no comment
491        let result = verifier.verify(&guide);
492        assert!(matches!(
493            result,
494            Err(crate::errors::AppError::Semantic(
495                SemanticError::PlaceholderNoUnmentionedItems { .. }
496            ))
497        ));
498    }
499
500    #[test]
501    fn test_multiple_placeholders_mixed_comments() {
502        let temp_dir = TempDir::new().unwrap();
503        let src_dir = temp_dir.path().join("src");
504        std::fs::create_dir(&src_dir).unwrap();
505
506        // Create some files
507        std::fs::write(src_dir.join("main.rs"), "").unwrap();
508        std::fs::write(src_dir.join("lib.rs"), "").unwrap();
509        std::fs::write(src_dir.join("utils.rs"), "").unwrap();
510
511        let verifier = Verifier::new(temp_dir.path());
512
513        let guide = NavigationGuide {
514            items: vec![NavigationGuideLine {
515                line_number: 1,
516                indent_level: 0,
517                item: FilesystemItem::Directory {
518                    path: "src".to_string(),
519                    comment: None,
520                    children: vec![
521                        NavigationGuideLine {
522                            line_number: 2,
523                            indent_level: 1,
524                            item: FilesystemItem::File {
525                                path: "main.rs".to_string(),
526                                comment: None,
527                            },
528                        },
529                        NavigationGuideLine {
530                            line_number: 3,
531                            indent_level: 1,
532                            item: FilesystemItem::Placeholder {
533                                comment: Some("other modules".to_string()),
534                            },
535                        },
536                        NavigationGuideLine {
537                            line_number: 4,
538                            indent_level: 1,
539                            item: FilesystemItem::File {
540                                path: "lib.rs".to_string(),
541                                comment: None,
542                            },
543                        },
544                        NavigationGuideLine {
545                            line_number: 5,
546                            indent_level: 1,
547                            item: FilesystemItem::Placeholder {
548                                comment: Some("future expansion files".to_string()),
549                            },
550                        },
551                    ],
552                },
553            }],
554            prologue: None,
555            epilogue: None,
556            ignore: false,
557        };
558
559        // Should succeed - both placeholders have comments, and there's an unmentioned file (utils.rs)
560        let result = verifier.verify(&guide);
561        assert!(result.is_ok());
562    }
563
564    #[test]
565    fn test_placeholder_with_comment_in_nested_directory() {
566        let temp_dir = TempDir::new().unwrap();
567        let nested_dir = temp_dir.path().join("src/modules/auth");
568        std::fs::create_dir_all(&nested_dir).unwrap();
569
570        // Create only one file in the nested directory
571        std::fs::write(nested_dir.join("login.rs"), "").unwrap();
572
573        let verifier = Verifier::new(temp_dir.path());
574
575        let guide = NavigationGuide {
576            items: vec![NavigationGuideLine {
577                line_number: 1,
578                indent_level: 0,
579                item: FilesystemItem::Directory {
580                    path: "src".to_string(),
581                    comment: None,
582                    children: vec![NavigationGuideLine {
583                        line_number: 2,
584                        indent_level: 1,
585                        item: FilesystemItem::Directory {
586                            path: "modules".to_string(),
587                            comment: None,
588                            children: vec![NavigationGuideLine {
589                                line_number: 3,
590                                indent_level: 2,
591                                item: FilesystemItem::Directory {
592                                    path: "auth".to_string(),
593                                    comment: None,
594                                    children: vec![
595                                        NavigationGuideLine {
596                                            line_number: 4,
597                                            indent_level: 3,
598                                            item: FilesystemItem::File {
599                                                path: "login.rs".to_string(),
600                                                comment: None,
601                                            },
602                                        },
603                                        NavigationGuideLine {
604                                            line_number: 5,
605                                            indent_level: 3,
606                                            item: FilesystemItem::Placeholder {
607                                                comment: Some(
608                                                    "additional auth features coming soon"
609                                                        .to_string(),
610                                                ),
611                                            },
612                                        },
613                                    ],
614                                },
615                            }],
616                        },
617                    }],
618                },
619            }],
620            prologue: None,
621            epilogue: None,
622            ignore: false,
623        };
624
625        // Should succeed - placeholder has a comment even in deeply nested directory
626        let result = verifier.verify(&guide);
627        assert!(result.is_ok());
628    }
629
630    #[test]
631    fn test_placeholder_without_comment_with_unmentioned() {
632        let temp_dir = TempDir::new().unwrap();
633
634        // Create multiple files
635        std::fs::write(temp_dir.path().join("main.rs"), "").unwrap();
636        std::fs::write(temp_dir.path().join("lib.rs"), "").unwrap();
637        std::fs::write(temp_dir.path().join("utils.rs"), "").unwrap();
638
639        let verifier = Verifier::new(temp_dir.path());
640
641        let guide = NavigationGuide {
642            items: vec![
643                NavigationGuideLine {
644                    line_number: 1,
645                    indent_level: 0,
646                    item: FilesystemItem::File {
647                        path: "main.rs".to_string(),
648                        comment: None,
649                    },
650                },
651                NavigationGuideLine {
652                    line_number: 2,
653                    indent_level: 0,
654                    item: FilesystemItem::Placeholder { comment: None },
655                },
656            ],
657            prologue: None,
658            epilogue: None,
659            ignore: false,
660        };
661
662        // Should succeed - placeholder without comment is ok when unmentioned items exist
663        let result = verifier.verify(&guide);
664        assert!(result.is_ok());
665    }
666}