Skip to main content

context_builder/
state.rs

1//! Project state representation for context-builder.
2//!
3//! This module provides structured data types to represent the state of a project
4//! at a point in time. This replaces the previous approach of caching generated
5//! markdown and enables more robust diff generation.
6
7use chrono::Utc;
8use ignore::DirEntry;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12use std::time::SystemTime;
13
14use crate::config::Config;
15use crate::diff::{PerFileDiff, PerFileStatus, diff_file_contents};
16
17/// Complete state representation of a project at a point in time
18#[derive(Serialize, Deserialize, Debug, Clone)]
19pub struct ProjectState {
20    /// Timestamp when this state was captured
21    pub timestamp: String,
22    /// Hash of the configuration used to generate this state
23    pub config_hash: String,
24    /// Map of file paths to their state information
25    pub files: BTreeMap<PathBuf, FileState>,
26    /// Project metadata
27    pub metadata: ProjectMetadata,
28}
29
30/// State information for a single file
31#[derive(Serialize, Deserialize, Debug, Clone)]
32pub struct FileState {
33    /// Raw file content as string
34    pub content: String,
35    /// File size in bytes
36    pub size: u64,
37    /// Last modified time
38    pub modified: SystemTime,
39    /// Content hash for quick comparison
40    pub content_hash: String,
41}
42
43/// Metadata about the project
44#[derive(Serialize, Deserialize, Debug, Clone)]
45pub struct ProjectMetadata {
46    /// Project directory name
47    pub project_name: String,
48    /// Total number of files processed
49    pub file_count: usize,
50    /// Filters applied during processing
51    pub filters: Vec<String>,
52    /// Ignore patterns applied
53    pub ignores: Vec<String>,
54    /// Whether line numbers were enabled
55    pub line_numbers: bool,
56}
57
58/// Result of comparing two project states
59#[derive(Debug, Clone)]
60pub struct StateComparison {
61    /// Per-file differences
62    pub file_diffs: Vec<PerFileDiff>,
63    /// Summary of changes
64    pub summary: ChangeSummary,
65}
66
67/// Summary of changes between two states
68#[derive(Debug, Clone)]
69pub struct ChangeSummary {
70    /// Files that were added
71    pub added: Vec<PathBuf>,
72    /// Files that were removed
73    pub removed: Vec<PathBuf>,
74    /// Files that were modified
75    pub modified: Vec<PathBuf>,
76    /// Total number of changed files
77    pub total_changes: usize,
78}
79
80impl ProjectState {
81    /// Create a new project state from collected files
82    pub fn from_files(
83        files: &[DirEntry],
84        base_path: &Path,
85        config: &Config,
86        line_numbers: bool,
87    ) -> std::io::Result<Self> {
88        let mut file_states = BTreeMap::new();
89
90        // Ensure paths stored in the state are *always* relative (never absolute).
91        // This keeps cache stable across different launch contexts and matches
92        // test expectations. We attempt a few strategies to derive a relative path.
93        let cwd = std::env::current_dir().unwrap_or_else(|_| base_path.to_path_buf());
94        for entry in files {
95            let entry_path = entry.path();
96
97            let relative_path = entry_path
98                // Preferred: relative to provided base_path (common case when input is absolute)
99                .strip_prefix(base_path)
100                .or_else(|_| entry_path.strip_prefix(&cwd))
101                .map(|p| p.to_path_buf())
102                .unwrap_or_else(|_| {
103                    // Fallback: last component (file name) to avoid leaking absolute paths
104                    entry_path
105                        .file_name()
106                        .map(PathBuf::from)
107                        .unwrap_or_else(|| entry_path.to_path_buf())
108                });
109
110            let file_state = FileState::from_path(entry_path)?;
111            file_states.insert(relative_path, file_state);
112        }
113
114        // Resolve project name robustly: canonicalize to handle "." and relative paths
115        let canonical = base_path.canonicalize().ok();
116        let resolved = canonical.as_deref().unwrap_or(base_path);
117        let project_name = resolved
118            .file_name()
119            .and_then(|n| n.to_str())
120            .map(|s| s.to_string())
121            .unwrap_or_else(|| {
122                // Fallback: try CWD if base_path has no file_name (e.g., root path)
123                std::env::current_dir()
124                    .ok()
125                    .and_then(|p| p.file_name().and_then(|n| n.to_str()).map(|s| s.to_string()))
126                    .unwrap_or_else(|| "unknown".to_string())
127            });
128
129        let metadata = ProjectMetadata {
130            project_name,
131            file_count: files.len(),
132            filters: config.filter.clone().unwrap_or_default(),
133            ignores: config.ignore.clone().unwrap_or_default(),
134            line_numbers,
135        };
136
137        Ok(ProjectState {
138            timestamp: Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
139            config_hash: Self::compute_config_hash(config),
140            files: file_states,
141            metadata,
142        })
143    }
144
145    /// Compare this state with a previous state
146    pub fn compare_with(&self, previous: &ProjectState) -> StateComparison {
147        // Convert file states to content maps for diff_file_contents
148        let previous_content: std::collections::HashMap<String, String> = previous
149            .files
150            .iter()
151            .map(|(path, state)| (path.to_string_lossy().to_string(), state.content.clone()))
152            .collect();
153
154        let current_content: std::collections::HashMap<String, String> = self
155            .files
156            .iter()
157            .map(|(path, state)| (path.to_string_lossy().to_string(), state.content.clone()))
158            .collect();
159
160        // Generate per-file diffs
161        let file_diffs = diff_file_contents(&previous_content, &current_content, true, None);
162
163        // Generate summary
164        let mut added = Vec::new();
165        let mut removed = Vec::new();
166        let mut modified = Vec::new();
167
168        for diff in &file_diffs {
169            let path = PathBuf::from(&diff.path);
170            match diff.status {
171                PerFileStatus::Added => added.push(path),
172                PerFileStatus::Removed => removed.push(path),
173                PerFileStatus::Modified => modified.push(path),
174                PerFileStatus::Unchanged => {}
175            }
176        }
177
178        let summary = ChangeSummary {
179            total_changes: added.len() + removed.len() + modified.len(),
180            added,
181            removed,
182            modified,
183        };
184
185        StateComparison {
186            file_diffs,
187            summary,
188        }
189    }
190
191    /// Check if this state has any content changes compared to another
192    pub fn has_changes(&self, other: &ProjectState) -> bool {
193        if self.files.len() != other.files.len() {
194            return true;
195        }
196
197        for (path, state) in &self.files {
198            match other.files.get(path) {
199                Some(other_state) => {
200                    if state.content_hash != other_state.content_hash {
201                        return true;
202                    }
203                }
204                None => return true,
205            }
206        }
207
208        false
209    }
210
211    /// Generate a configuration hash for cache validation
212    fn compute_config_hash(config: &Config) -> String {
213        use std::collections::hash_map::DefaultHasher;
214        use std::hash::{Hash, Hasher};
215
216        let mut hasher = DefaultHasher::new();
217        config.filter.hash(&mut hasher);
218        config.ignore.hash(&mut hasher);
219        config.line_numbers.hash(&mut hasher);
220        config.auto_diff.hash(&mut hasher);
221        config.diff_context_lines.hash(&mut hasher);
222
223        format!("{:x}", hasher.finish())
224    }
225}
226
227impl FileState {
228    /// Create a file state from a file path
229    pub fn from_path(path: &Path) -> std::io::Result<Self> {
230        use std::collections::hash_map::DefaultHasher;
231        use std::fs;
232        use std::hash::{Hash, Hasher};
233        use std::io::ErrorKind;
234
235        let metadata = fs::metadata(path)?;
236
237        let content = match fs::read_to_string(path) {
238            Ok(content) => content,
239            Err(e) if e.kind() == ErrorKind::InvalidData => {
240                // Handle binary files gracefully
241                log::warn!("Skipping binary file in auto-diff mode: {}", path.display());
242                format!("<Binary file - {} bytes>", metadata.len())
243            }
244            Err(e) => return Err(e),
245        };
246
247        // Compute content hash
248        let mut hasher = DefaultHasher::new();
249        content.hash(&mut hasher);
250        let content_hash = format!("{:x}", hasher.finish());
251
252        Ok(FileState {
253            content,
254            size: metadata.len(),
255            modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
256            content_hash,
257        })
258    }
259}
260
261impl ChangeSummary {
262    /// Check if there are any changes
263    pub fn has_changes(&self) -> bool {
264        self.total_changes > 0
265    }
266
267    /// Generate markdown representation of the change summary
268    pub fn to_markdown(&self) -> String {
269        if !self.has_changes() {
270            return String::new();
271        }
272
273        let mut output = String::new();
274        output.push_str("## Change Summary\n\n");
275
276        for path in &self.added {
277            output.push_str(&format!("- Added: `{}`\n", path.display()));
278        }
279
280        for path in &self.removed {
281            output.push_str(&format!("- Removed: `{}`\n", path.display()));
282        }
283
284        for path in &self.modified {
285            output.push_str(&format!("- Modified: `{}`\n", path.display()));
286        }
287
288        output.push('\n');
289        output
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use std::fs;
297    use tempfile::tempdir;
298
299    #[test]
300    fn test_file_state_creation() {
301        let temp_dir = tempdir().unwrap();
302        let file_path = temp_dir.path().join("test.txt");
303        fs::write(&file_path, "Hello, world!").unwrap();
304
305        let file_state = FileState::from_path(&file_path).unwrap();
306
307        assert_eq!(file_state.content, "Hello, world!");
308        assert_eq!(file_state.size, 13);
309        assert!(!file_state.content_hash.is_empty());
310    }
311
312    #[test]
313    fn test_project_state_comparison() {
314        let temp_dir = tempdir().unwrap();
315        let base_path = temp_dir.path();
316
317        // Create initial files
318        fs::write(base_path.join("file1.txt"), "content1").unwrap();
319        fs::write(base_path.join("file2.txt"), "content2").unwrap();
320
321        let mut state1_files = BTreeMap::new();
322        state1_files.insert(
323            PathBuf::from("file1.txt"),
324            FileState::from_path(&base_path.join("file1.txt")).unwrap(),
325        );
326        state1_files.insert(
327            PathBuf::from("file2.txt"),
328            FileState::from_path(&base_path.join("file2.txt")).unwrap(),
329        );
330
331        let state1 = ProjectState {
332            timestamp: "2023-01-01T00:00:00Z".to_string(),
333            config_hash: "test_hash".to_string(),
334            files: state1_files,
335            metadata: ProjectMetadata {
336                project_name: "test".to_string(),
337                file_count: 2,
338                filters: vec![],
339                ignores: vec![],
340                line_numbers: false,
341            },
342        };
343
344        // Modify and create new state
345        fs::write(base_path.join("file1.txt"), "modified_content1").unwrap();
346        fs::write(base_path.join("file3.txt"), "content3").unwrap();
347
348        let mut state2_files = BTreeMap::new();
349        state2_files.insert(
350            PathBuf::from("file1.txt"),
351            FileState::from_path(&base_path.join("file1.txt")).unwrap(),
352        );
353        state2_files.insert(
354            PathBuf::from("file2.txt"),
355            FileState::from_path(&base_path.join("file2.txt")).unwrap(),
356        );
357        state2_files.insert(
358            PathBuf::from("file3.txt"),
359            FileState::from_path(&base_path.join("file3.txt")).unwrap(),
360        );
361
362        let state2 = ProjectState {
363            timestamp: "2023-01-01T01:00:00Z".to_string(),
364            config_hash: "test_hash".to_string(),
365            files: state2_files,
366            metadata: ProjectMetadata {
367                project_name: "test".to_string(),
368                file_count: 3,
369                filters: vec![],
370                ignores: vec![],
371                line_numbers: false,
372            },
373        };
374
375        let comparison = state2.compare_with(&state1);
376
377        assert_eq!(comparison.summary.added.len(), 1);
378        assert_eq!(comparison.summary.modified.len(), 1);
379        assert_eq!(comparison.summary.removed.len(), 0);
380        assert!(
381            comparison
382                .summary
383                .added
384                .contains(&PathBuf::from("file3.txt"))
385        );
386        assert!(
387            comparison
388                .summary
389                .modified
390                .contains(&PathBuf::from("file1.txt"))
391        );
392    }
393
394    #[test]
395    fn test_change_summary_markdown() {
396        let summary = ChangeSummary {
397            added: vec![PathBuf::from("new.txt")],
398            removed: vec![PathBuf::from("old.txt")],
399            modified: vec![PathBuf::from("changed.txt")],
400            total_changes: 3,
401        };
402
403        let markdown = summary.to_markdown();
404
405        assert!(markdown.contains("## Change Summary"));
406        assert!(markdown.contains("- Added: `new.txt`"));
407        assert!(markdown.contains("- Removed: `old.txt`"));
408        assert!(markdown.contains("- Modified: `changed.txt`"));
409    }
410
411    #[test]
412    fn test_binary_file_handling() {
413        let temp_dir = tempdir().unwrap();
414        let binary_file = temp_dir.path().join("test.bin");
415
416        // Write binary data (non-UTF8)
417        let binary_data = vec![0u8, 255, 128, 42, 0, 1, 2, 3];
418        fs::write(&binary_file, &binary_data).unwrap();
419
420        // Should not crash and should handle gracefully
421        let file_state = FileState::from_path(&binary_file).unwrap();
422
423        // Content should be a placeholder for binary files
424        assert!(file_state.content.contains("Binary file"));
425        assert!(file_state.content.contains("8 bytes"));
426        assert_eq!(file_state.size, 8);
427        assert!(!file_state.content_hash.is_empty());
428    }
429
430    #[test]
431    fn test_has_changes_identical_states() {
432        let temp_dir = tempdir().unwrap();
433        let base_path = temp_dir.path();
434        fs::write(base_path.join("test.txt"), "content").unwrap();
435
436        let mut files = BTreeMap::new();
437        files.insert(
438            PathBuf::from("test.txt"),
439            FileState::from_path(&base_path.join("test.txt")).unwrap(),
440        );
441
442        let state1 = ProjectState {
443            timestamp: "2023-01-01T00:00:00Z".to_string(),
444            config_hash: "hash1".to_string(),
445            files: files.clone(),
446            metadata: ProjectMetadata {
447                project_name: "test".to_string(),
448                file_count: 1,
449                filters: vec![],
450                ignores: vec![],
451                line_numbers: false,
452            },
453        };
454
455        let state2 = ProjectState {
456            timestamp: "2023-01-01T01:00:00Z".to_string(),
457            config_hash: "hash1".to_string(),
458            files,
459            metadata: ProjectMetadata {
460                project_name: "test".to_string(),
461                file_count: 1,
462                filters: vec![],
463                ignores: vec![],
464                line_numbers: false,
465            },
466        };
467
468        assert!(!state1.has_changes(&state2));
469    }
470
471    #[test]
472    fn test_has_changes_different_file_count() {
473        let temp_dir = tempdir().unwrap();
474        let base_path = temp_dir.path();
475        fs::write(base_path.join("test1.txt"), "content1").unwrap();
476        fs::write(base_path.join("test2.txt"), "content2").unwrap();
477
478        let mut files1 = BTreeMap::new();
479        files1.insert(
480            PathBuf::from("test1.txt"),
481            FileState::from_path(&base_path.join("test1.txt")).unwrap(),
482        );
483
484        let mut files2 = BTreeMap::new();
485        files2.insert(
486            PathBuf::from("test1.txt"),
487            FileState::from_path(&base_path.join("test1.txt")).unwrap(),
488        );
489        files2.insert(
490            PathBuf::from("test2.txt"),
491            FileState::from_path(&base_path.join("test2.txt")).unwrap(),
492        );
493
494        let state1 = ProjectState {
495            timestamp: "2023-01-01T00:00:00Z".to_string(),
496            config_hash: "hash1".to_string(),
497            files: files1,
498            metadata: ProjectMetadata {
499                project_name: "test".to_string(),
500                file_count: 1,
501                filters: vec![],
502                ignores: vec![],
503                line_numbers: false,
504            },
505        };
506
507        let state2 = ProjectState {
508            timestamp: "2023-01-01T01:00:00Z".to_string(),
509            config_hash: "hash1".to_string(),
510            files: files2,
511            metadata: ProjectMetadata {
512                project_name: "test".to_string(),
513                file_count: 2,
514                filters: vec![],
515                ignores: vec![],
516                line_numbers: false,
517            },
518        };
519
520        assert!(state1.has_changes(&state2));
521    }
522
523    #[test]
524    fn test_has_changes_content_different() {
525        let temp_dir = tempdir().unwrap();
526        let base_path = temp_dir.path();
527        fs::write(base_path.join("test.txt"), "content1").unwrap();
528
529        let file_state1 = FileState::from_path(&base_path.join("test.txt")).unwrap();
530
531        fs::write(base_path.join("test.txt"), "content2").unwrap();
532        let file_state2 = FileState::from_path(&base_path.join("test.txt")).unwrap();
533
534        let mut files1 = BTreeMap::new();
535        files1.insert(PathBuf::from("test.txt"), file_state1);
536
537        let mut files2 = BTreeMap::new();
538        files2.insert(PathBuf::from("test.txt"), file_state2);
539
540        let state1 = ProjectState {
541            timestamp: "2023-01-01T00:00:00Z".to_string(),
542            config_hash: "hash1".to_string(),
543            files: files1,
544            metadata: ProjectMetadata {
545                project_name: "test".to_string(),
546                file_count: 1,
547                filters: vec![],
548                ignores: vec![],
549                line_numbers: false,
550            },
551        };
552
553        let state2 = ProjectState {
554            timestamp: "2023-01-01T01:00:00Z".to_string(),
555            config_hash: "hash1".to_string(),
556            files: files2,
557            metadata: ProjectMetadata {
558                project_name: "test".to_string(),
559                file_count: 1,
560                filters: vec![],
561                ignores: vec![],
562                line_numbers: false,
563            },
564        };
565
566        assert!(state1.has_changes(&state2));
567    }
568
569    #[test]
570    fn test_config_hash_generation() {
571        let config1 = Config {
572            filter: Some(vec!["rs".to_string()]),
573            ignore: Some(vec!["target".to_string()]),
574            line_numbers: Some(true),
575            auto_diff: Some(false),
576            diff_context_lines: Some(3),
577            ..Default::default()
578        };
579
580        let config2 = Config {
581            filter: Some(vec!["rs".to_string()]),
582            ignore: Some(vec!["target".to_string()]),
583            line_numbers: Some(true),
584            auto_diff: Some(false),
585            diff_context_lines: Some(3),
586            ..Default::default()
587        };
588
589        let config3 = Config {
590            filter: Some(vec!["py".to_string()]), // Different filter
591            ignore: Some(vec!["target".to_string()]),
592            line_numbers: Some(true),
593            auto_diff: Some(false),
594            diff_context_lines: Some(3),
595            ..Default::default()
596        };
597
598        let hash1 = ProjectState::compute_config_hash(&config1);
599        let hash2 = ProjectState::compute_config_hash(&config2);
600        let hash3 = ProjectState::compute_config_hash(&config3);
601
602        assert_eq!(hash1, hash2);
603        assert_ne!(hash1, hash3);
604    }
605
606    #[test]
607    fn test_change_summary_no_changes() {
608        let summary = ChangeSummary {
609            added: vec![],
610            removed: vec![],
611            modified: vec![],
612            total_changes: 0,
613        };
614
615        assert!(!summary.has_changes());
616        assert_eq!(summary.to_markdown(), "");
617    }
618
619    #[test]
620    fn test_from_files_with_config() {
621        let temp_dir = tempdir().unwrap();
622        let base_path = temp_dir.path();
623
624        fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
625        fs::write(base_path.join("README.md"), "# Test").unwrap();
626
627        let entries = vec![
628            create_mock_dir_entry(&base_path.join("test.rs")),
629            create_mock_dir_entry(&base_path.join("README.md")),
630        ];
631
632        let config = Config {
633            filter: Some(vec!["rs".to_string()]),
634            ignore: Some(vec!["target".to_string()]),
635            line_numbers: Some(true),
636            ..Default::default()
637        };
638
639        let state = ProjectState::from_files(&entries, base_path, &config, true).unwrap();
640
641        assert_eq!(state.files.len(), 2);
642        assert_eq!(state.metadata.file_count, 2);
643        assert_eq!(state.metadata.filters, vec!["rs"]);
644        assert_eq!(state.metadata.ignores, vec!["target"]);
645        assert!(state.metadata.line_numbers);
646        assert!(!state.timestamp.is_empty());
647        assert!(!state.config_hash.is_empty());
648    }
649
650    #[test]
651    fn test_from_files_absolute_path_fallback() {
652        let temp_dir = tempdir().unwrap();
653        let base_path = temp_dir.path();
654
655        // Create a file in the temp dir
656        fs::write(base_path.join("test.txt"), "test content").unwrap();
657        let file_path = base_path.join("test.txt");
658
659        // Create entry with the file
660        let entry = create_mock_dir_entry(&file_path);
661
662        // Use a completely different base_path to force the fallback
663        let different_base = PathBuf::from("/completely/different/path");
664
665        let config = Config::default();
666
667        let state = ProjectState::from_files(&[entry], &different_base, &config, false).unwrap();
668
669        // Should fall back to just the filename
670        assert_eq!(state.files.len(), 1);
671        assert!(state.files.contains_key(&PathBuf::from("test.txt")));
672    }
673
674    #[test]
675    fn test_change_summary_with_unchanged_files() {
676        let changes = vec![
677            PerFileDiff {
678                path: "added.txt".to_string(),
679                status: PerFileStatus::Added,
680                diff: "diff content".to_string(),
681            },
682            PerFileDiff {
683                path: "unchanged.txt".to_string(),
684                status: PerFileStatus::Unchanged,
685                diff: "".to_string(),
686            },
687        ];
688
689        // Manually create the summary like the actual code does
690        let mut added = Vec::new();
691        let mut removed = Vec::new();
692        let mut modified = Vec::new();
693
694        for diff in &changes {
695            let path = PathBuf::from(&diff.path);
696            match diff.status {
697                PerFileStatus::Added => added.push(path),
698                PerFileStatus::Removed => removed.push(path),
699                PerFileStatus::Modified => modified.push(path),
700                PerFileStatus::Unchanged => {} // This line should be covered now
701            }
702        }
703
704        let summary = ChangeSummary {
705            total_changes: added.len() + removed.len() + modified.len(),
706            added,
707            removed,
708            modified,
709        };
710
711        assert_eq!(summary.total_changes, 1); // Only the added file counts
712        assert_eq!(summary.added.len(), 1);
713        assert_eq!(summary.removed.len(), 0);
714        assert_eq!(summary.modified.len(), 0);
715    }
716
717    #[test]
718    fn test_has_changes_with_missing_file() {
719        let temp_dir = tempdir().unwrap();
720        let base_path = temp_dir.path();
721
722        // Create files for the first state
723        fs::write(base_path.join("file1.txt"), "content1").unwrap();
724        let entry1 = create_mock_dir_entry(&base_path.join("file1.txt"));
725
726        let config = Config::default();
727        let state1 = ProjectState::from_files(&[entry1], base_path, &config, false).unwrap();
728
729        // Create a different state with different files
730        fs::write(base_path.join("file2.txt"), "content2").unwrap();
731        let entry2 = create_mock_dir_entry(&base_path.join("file2.txt"));
732        let state2 = ProjectState::from_files(&[entry2], base_path, &config, false).unwrap();
733
734        // Should detect changes because files are completely different
735        assert!(state1.has_changes(&state2));
736    }
737
738    #[test]
739    fn test_file_state_with_invalid_data_error() {
740        // Create a temporary file with binary content that might trigger InvalidData
741        let temp_dir = tempdir().unwrap();
742        let binary_file = temp_dir.path().join("binary.dat");
743
744        // Write invalid UTF-8 bytes
745        let binary_data = vec![0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA];
746        fs::write(&binary_file, &binary_data).unwrap();
747
748        // This might trigger the InvalidData error path, but since we can't guarantee it,
749        // we at least verify the function can handle binary files
750        let result = FileState::from_path(&binary_file);
751        assert!(result.is_ok());
752    }
753
754    // Helper function to create a mock DirEntry for testing
755    fn create_mock_dir_entry(path: &std::path::Path) -> ignore::DirEntry {
756        // This is a bit of a hack since DirEntry doesn't have a public constructor
757        // We use the ignore crate's WalkBuilder to create a real DirEntry
758        let walker = ignore::WalkBuilder::new(path.parent().unwrap());
759        walker
760            .build()
761            .filter_map(Result::ok)
762            .find(|entry| entry.path() == path)
763            .expect("Failed to create DirEntry for test")
764    }
765}