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| {
126                        p.file_name()
127                            .and_then(|n| n.to_str())
128                            .map(|s| s.to_string())
129                    })
130                    .unwrap_or_else(|| "unknown".to_string())
131            });
132
133        let metadata = ProjectMetadata {
134            project_name,
135            file_count: files.len(),
136            filters: config.filter.clone().unwrap_or_default(),
137            ignores: config.ignore.clone().unwrap_or_default(),
138            line_numbers,
139        };
140
141        Ok(ProjectState {
142            timestamp: Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
143            config_hash: Self::compute_config_hash(config),
144            files: file_states,
145            metadata,
146        })
147    }
148
149    /// Compare this state with a previous state
150    pub fn compare_with(&self, previous: &ProjectState) -> StateComparison {
151        // Convert file states to content maps for diff_file_contents
152        let previous_content: std::collections::HashMap<String, String> = previous
153            .files
154            .iter()
155            .map(|(path, state)| (path.to_string_lossy().to_string(), state.content.clone()))
156            .collect();
157
158        let current_content: std::collections::HashMap<String, String> = self
159            .files
160            .iter()
161            .map(|(path, state)| (path.to_string_lossy().to_string(), state.content.clone()))
162            .collect();
163
164        // Generate per-file diffs
165        let file_diffs = diff_file_contents(&previous_content, &current_content, true, None);
166
167        // Generate summary
168        let mut added = Vec::new();
169        let mut removed = Vec::new();
170        let mut modified = Vec::new();
171
172        for diff in &file_diffs {
173            let path = PathBuf::from(&diff.path);
174            match diff.status {
175                PerFileStatus::Added => added.push(path),
176                PerFileStatus::Removed => removed.push(path),
177                PerFileStatus::Modified => modified.push(path),
178                PerFileStatus::Unchanged => {}
179            }
180        }
181
182        let summary = ChangeSummary {
183            total_changes: added.len() + removed.len() + modified.len(),
184            added,
185            removed,
186            modified,
187        };
188
189        StateComparison {
190            file_diffs,
191            summary,
192        }
193    }
194
195    /// Check if this state has any content changes compared to another
196    pub fn has_changes(&self, other: &ProjectState) -> bool {
197        if self.files.len() != other.files.len() {
198            return true;
199        }
200
201        for (path, state) in &self.files {
202            match other.files.get(path) {
203                Some(other_state) => {
204                    if state.content_hash != other_state.content_hash {
205                        return true;
206                    }
207                }
208                None => return true,
209            }
210        }
211
212        false
213    }
214
215    /// Generate a configuration hash for cache validation
216    fn compute_config_hash(config: &Config) -> String {
217        // Build a stable string representation for hashing
218        let mut config_str = String::new();
219        if let Some(ref filters) = config.filter {
220            config_str.push_str(&filters.join(","));
221        }
222        config_str.push('|');
223        if let Some(ref ignores) = config.ignore {
224            config_str.push_str(&ignores.join(","));
225        }
226        config_str.push('|');
227        config_str.push_str(&format!(
228            "{:?}|{:?}|{:?}",
229            config.line_numbers, config.auto_diff, config.diff_context_lines
230        ));
231
232        let hash = xxhash_rust::xxh3::xxh3_64(config_str.as_bytes());
233        format!("{:x}", hash)
234    }
235}
236
237impl FileState {
238    /// Create a file state from a file path
239    pub fn from_path(path: &Path) -> std::io::Result<Self> {
240        use std::fs;
241        use std::io::ErrorKind;
242
243        let metadata = fs::metadata(path)?;
244
245        let content = match fs::read_to_string(path) {
246            Ok(content) => content,
247            Err(e) if e.kind() == ErrorKind::InvalidData => {
248                // Handle binary files gracefully
249                log::warn!("Skipping binary file in auto-diff mode: {}", path.display());
250                format!("<Binary file - {} bytes>", metadata.len())
251            }
252            Err(e) => return Err(e),
253        };
254
255        // Compute content hash using stable xxh3
256        let content_hash = format!("{:016x}", xxhash_rust::xxh3::xxh3_64(content.as_bytes()));
257
258        Ok(FileState {
259            content,
260            size: metadata.len(),
261            modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
262            content_hash,
263        })
264    }
265}
266
267impl ChangeSummary {
268    /// Check if there are any changes
269    pub fn has_changes(&self) -> bool {
270        self.total_changes > 0
271    }
272
273    /// Generate markdown representation of the change summary
274    pub fn to_markdown(&self) -> String {
275        if !self.has_changes() {
276            return String::new();
277        }
278
279        let mut output = String::new();
280        output.push_str("## Change Summary\n\n");
281
282        for path in &self.added {
283            output.push_str(&format!("- Added: `{}`\n", path.display()));
284        }
285
286        for path in &self.removed {
287            output.push_str(&format!("- Removed: `{}`\n", path.display()));
288        }
289
290        for path in &self.modified {
291            output.push_str(&format!("- Modified: `{}`\n", path.display()));
292        }
293
294        output.push('\n');
295        output
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use std::fs;
303    use tempfile::tempdir;
304
305    #[test]
306    fn test_file_state_creation() {
307        let temp_dir = tempdir().unwrap();
308        let file_path = temp_dir.path().join("test.txt");
309        fs::write(&file_path, "Hello, world!").unwrap();
310
311        let file_state = FileState::from_path(&file_path).unwrap();
312
313        assert_eq!(file_state.content, "Hello, world!");
314        assert_eq!(file_state.size, 13);
315        assert!(!file_state.content_hash.is_empty());
316    }
317
318    #[test]
319    fn test_project_state_comparison() {
320        let temp_dir = tempdir().unwrap();
321        let base_path = temp_dir.path();
322
323        // Create initial files
324        fs::write(base_path.join("file1.txt"), "content1").unwrap();
325        fs::write(base_path.join("file2.txt"), "content2").unwrap();
326
327        let mut state1_files = BTreeMap::new();
328        state1_files.insert(
329            PathBuf::from("file1.txt"),
330            FileState::from_path(&base_path.join("file1.txt")).unwrap(),
331        );
332        state1_files.insert(
333            PathBuf::from("file2.txt"),
334            FileState::from_path(&base_path.join("file2.txt")).unwrap(),
335        );
336
337        let state1 = ProjectState {
338            timestamp: "2023-01-01T00:00:00Z".to_string(),
339            config_hash: "test_hash".to_string(),
340            files: state1_files,
341            metadata: ProjectMetadata {
342                project_name: "test".to_string(),
343                file_count: 2,
344                filters: vec![],
345                ignores: vec![],
346                line_numbers: false,
347            },
348        };
349
350        // Modify and create new state
351        fs::write(base_path.join("file1.txt"), "modified_content1").unwrap();
352        fs::write(base_path.join("file3.txt"), "content3").unwrap();
353
354        let mut state2_files = BTreeMap::new();
355        state2_files.insert(
356            PathBuf::from("file1.txt"),
357            FileState::from_path(&base_path.join("file1.txt")).unwrap(),
358        );
359        state2_files.insert(
360            PathBuf::from("file2.txt"),
361            FileState::from_path(&base_path.join("file2.txt")).unwrap(),
362        );
363        state2_files.insert(
364            PathBuf::from("file3.txt"),
365            FileState::from_path(&base_path.join("file3.txt")).unwrap(),
366        );
367
368        let state2 = ProjectState {
369            timestamp: "2023-01-01T01:00:00Z".to_string(),
370            config_hash: "test_hash".to_string(),
371            files: state2_files,
372            metadata: ProjectMetadata {
373                project_name: "test".to_string(),
374                file_count: 3,
375                filters: vec![],
376                ignores: vec![],
377                line_numbers: false,
378            },
379        };
380
381        let comparison = state2.compare_with(&state1);
382
383        assert_eq!(comparison.summary.added.len(), 1);
384        assert_eq!(comparison.summary.modified.len(), 1);
385        assert_eq!(comparison.summary.removed.len(), 0);
386        assert!(
387            comparison
388                .summary
389                .added
390                .contains(&PathBuf::from("file3.txt"))
391        );
392        assert!(
393            comparison
394                .summary
395                .modified
396                .contains(&PathBuf::from("file1.txt"))
397        );
398    }
399
400    #[test]
401    fn test_change_summary_markdown() {
402        let summary = ChangeSummary {
403            added: vec![PathBuf::from("new.txt")],
404            removed: vec![PathBuf::from("old.txt")],
405            modified: vec![PathBuf::from("changed.txt")],
406            total_changes: 3,
407        };
408
409        let markdown = summary.to_markdown();
410
411        assert!(markdown.contains("## Change Summary"));
412        assert!(markdown.contains("- Added: `new.txt`"));
413        assert!(markdown.contains("- Removed: `old.txt`"));
414        assert!(markdown.contains("- Modified: `changed.txt`"));
415    }
416
417    #[test]
418    fn test_binary_file_handling() {
419        let temp_dir = tempdir().unwrap();
420        let binary_file = temp_dir.path().join("test.bin");
421
422        // Write binary data (non-UTF8)
423        let binary_data = vec![0u8, 255, 128, 42, 0, 1, 2, 3];
424        fs::write(&binary_file, &binary_data).unwrap();
425
426        // Should not crash and should handle gracefully
427        let file_state = FileState::from_path(&binary_file).unwrap();
428
429        // Content should be a placeholder for binary files
430        assert!(file_state.content.contains("Binary file"));
431        assert!(file_state.content.contains("8 bytes"));
432        assert_eq!(file_state.size, 8);
433        assert!(!file_state.content_hash.is_empty());
434    }
435
436    #[test]
437    fn test_has_changes_identical_states() {
438        let temp_dir = tempdir().unwrap();
439        let base_path = temp_dir.path();
440        fs::write(base_path.join("test.txt"), "content").unwrap();
441
442        let mut files = BTreeMap::new();
443        files.insert(
444            PathBuf::from("test.txt"),
445            FileState::from_path(&base_path.join("test.txt")).unwrap(),
446        );
447
448        let state1 = ProjectState {
449            timestamp: "2023-01-01T00:00:00Z".to_string(),
450            config_hash: "hash1".to_string(),
451            files: files.clone(),
452            metadata: ProjectMetadata {
453                project_name: "test".to_string(),
454                file_count: 1,
455                filters: vec![],
456                ignores: vec![],
457                line_numbers: false,
458            },
459        };
460
461        let state2 = ProjectState {
462            timestamp: "2023-01-01T01:00:00Z".to_string(),
463            config_hash: "hash1".to_string(),
464            files,
465            metadata: ProjectMetadata {
466                project_name: "test".to_string(),
467                file_count: 1,
468                filters: vec![],
469                ignores: vec![],
470                line_numbers: false,
471            },
472        };
473
474        assert!(!state1.has_changes(&state2));
475    }
476
477    #[test]
478    fn test_has_changes_different_file_count() {
479        let temp_dir = tempdir().unwrap();
480        let base_path = temp_dir.path();
481        fs::write(base_path.join("test1.txt"), "content1").unwrap();
482        fs::write(base_path.join("test2.txt"), "content2").unwrap();
483
484        let mut files1 = BTreeMap::new();
485        files1.insert(
486            PathBuf::from("test1.txt"),
487            FileState::from_path(&base_path.join("test1.txt")).unwrap(),
488        );
489
490        let mut files2 = BTreeMap::new();
491        files2.insert(
492            PathBuf::from("test1.txt"),
493            FileState::from_path(&base_path.join("test1.txt")).unwrap(),
494        );
495        files2.insert(
496            PathBuf::from("test2.txt"),
497            FileState::from_path(&base_path.join("test2.txt")).unwrap(),
498        );
499
500        let state1 = ProjectState {
501            timestamp: "2023-01-01T00:00:00Z".to_string(),
502            config_hash: "hash1".to_string(),
503            files: files1,
504            metadata: ProjectMetadata {
505                project_name: "test".to_string(),
506                file_count: 1,
507                filters: vec![],
508                ignores: vec![],
509                line_numbers: false,
510            },
511        };
512
513        let state2 = ProjectState {
514            timestamp: "2023-01-01T01:00:00Z".to_string(),
515            config_hash: "hash1".to_string(),
516            files: files2,
517            metadata: ProjectMetadata {
518                project_name: "test".to_string(),
519                file_count: 2,
520                filters: vec![],
521                ignores: vec![],
522                line_numbers: false,
523            },
524        };
525
526        assert!(state1.has_changes(&state2));
527    }
528
529    #[test]
530    fn test_has_changes_content_different() {
531        let temp_dir = tempdir().unwrap();
532        let base_path = temp_dir.path();
533        fs::write(base_path.join("test.txt"), "content1").unwrap();
534
535        let file_state1 = FileState::from_path(&base_path.join("test.txt")).unwrap();
536
537        fs::write(base_path.join("test.txt"), "content2").unwrap();
538        let file_state2 = FileState::from_path(&base_path.join("test.txt")).unwrap();
539
540        let mut files1 = BTreeMap::new();
541        files1.insert(PathBuf::from("test.txt"), file_state1);
542
543        let mut files2 = BTreeMap::new();
544        files2.insert(PathBuf::from("test.txt"), file_state2);
545
546        let state1 = ProjectState {
547            timestamp: "2023-01-01T00:00:00Z".to_string(),
548            config_hash: "hash1".to_string(),
549            files: files1,
550            metadata: ProjectMetadata {
551                project_name: "test".to_string(),
552                file_count: 1,
553                filters: vec![],
554                ignores: vec![],
555                line_numbers: false,
556            },
557        };
558
559        let state2 = ProjectState {
560            timestamp: "2023-01-01T01:00:00Z".to_string(),
561            config_hash: "hash1".to_string(),
562            files: files2,
563            metadata: ProjectMetadata {
564                project_name: "test".to_string(),
565                file_count: 1,
566                filters: vec![],
567                ignores: vec![],
568                line_numbers: false,
569            },
570        };
571
572        assert!(state1.has_changes(&state2));
573    }
574
575    #[test]
576    fn test_config_hash_generation() {
577        let config1 = Config {
578            filter: Some(vec!["rs".to_string()]),
579            ignore: Some(vec!["target".to_string()]),
580            line_numbers: Some(true),
581            auto_diff: Some(false),
582            diff_context_lines: Some(3),
583            ..Default::default()
584        };
585
586        let config2 = Config {
587            filter: Some(vec!["rs".to_string()]),
588            ignore: Some(vec!["target".to_string()]),
589            line_numbers: Some(true),
590            auto_diff: Some(false),
591            diff_context_lines: Some(3),
592            ..Default::default()
593        };
594
595        let config3 = Config {
596            filter: Some(vec!["py".to_string()]), // Different filter
597            ignore: Some(vec!["target".to_string()]),
598            line_numbers: Some(true),
599            auto_diff: Some(false),
600            diff_context_lines: Some(3),
601            ..Default::default()
602        };
603
604        let hash1 = ProjectState::compute_config_hash(&config1);
605        let hash2 = ProjectState::compute_config_hash(&config2);
606        let hash3 = ProjectState::compute_config_hash(&config3);
607
608        assert_eq!(hash1, hash2);
609        assert_ne!(hash1, hash3);
610    }
611
612    #[test]
613    fn test_change_summary_no_changes() {
614        let summary = ChangeSummary {
615            added: vec![],
616            removed: vec![],
617            modified: vec![],
618            total_changes: 0,
619        };
620
621        assert!(!summary.has_changes());
622        assert_eq!(summary.to_markdown(), "");
623    }
624
625    #[test]
626    fn test_from_files_with_config() {
627        let temp_dir = tempdir().unwrap();
628        let base_path = temp_dir.path();
629
630        fs::write(base_path.join("test.rs"), "fn main() {}").unwrap();
631        fs::write(base_path.join("README.md"), "# Test").unwrap();
632
633        let entries = vec![
634            create_mock_dir_entry(&base_path.join("test.rs")),
635            create_mock_dir_entry(&base_path.join("README.md")),
636        ];
637
638        let config = Config {
639            filter: Some(vec!["rs".to_string()]),
640            ignore: Some(vec!["target".to_string()]),
641            line_numbers: Some(true),
642            ..Default::default()
643        };
644
645        let state = ProjectState::from_files(&entries, base_path, &config, true).unwrap();
646
647        assert_eq!(state.files.len(), 2);
648        assert_eq!(state.metadata.file_count, 2);
649        assert_eq!(state.metadata.filters, vec!["rs"]);
650        assert_eq!(state.metadata.ignores, vec!["target"]);
651        assert!(state.metadata.line_numbers);
652        assert!(!state.timestamp.is_empty());
653        assert!(!state.config_hash.is_empty());
654    }
655
656    #[test]
657    fn test_from_files_absolute_path_fallback() {
658        let temp_dir = tempdir().unwrap();
659        let base_path = temp_dir.path();
660
661        // Create a file in the temp dir
662        fs::write(base_path.join("test.txt"), "test content").unwrap();
663        let file_path = base_path.join("test.txt");
664
665        // Create entry with the file
666        let entry = create_mock_dir_entry(&file_path);
667
668        // Use a completely different base_path to force the fallback
669        let different_base = PathBuf::from("/completely/different/path");
670
671        let config = Config::default();
672
673        let state = ProjectState::from_files(&[entry], &different_base, &config, false).unwrap();
674
675        // Should fall back to just the filename
676        assert_eq!(state.files.len(), 1);
677        assert!(state.files.contains_key(&PathBuf::from("test.txt")));
678    }
679
680    #[test]
681    fn test_change_summary_with_unchanged_files() {
682        let changes = vec![
683            PerFileDiff {
684                path: "added.txt".to_string(),
685                status: PerFileStatus::Added,
686                diff: "diff content".to_string(),
687            },
688            PerFileDiff {
689                path: "unchanged.txt".to_string(),
690                status: PerFileStatus::Unchanged,
691                diff: "".to_string(),
692            },
693        ];
694
695        // Manually create the summary like the actual code does
696        let mut added = Vec::new();
697        let mut removed = Vec::new();
698        let mut modified = Vec::new();
699
700        for diff in &changes {
701            let path = PathBuf::from(&diff.path);
702            match diff.status {
703                PerFileStatus::Added => added.push(path),
704                PerFileStatus::Removed => removed.push(path),
705                PerFileStatus::Modified => modified.push(path),
706                PerFileStatus::Unchanged => {} // This line should be covered now
707            }
708        }
709
710        let summary = ChangeSummary {
711            total_changes: added.len() + removed.len() + modified.len(),
712            added,
713            removed,
714            modified,
715        };
716
717        assert_eq!(summary.total_changes, 1); // Only the added file counts
718        assert_eq!(summary.added.len(), 1);
719        assert_eq!(summary.removed.len(), 0);
720        assert_eq!(summary.modified.len(), 0);
721    }
722
723    #[test]
724    fn test_has_changes_with_missing_file() {
725        let temp_dir = tempdir().unwrap();
726        let base_path = temp_dir.path();
727
728        // Create files for the first state
729        fs::write(base_path.join("file1.txt"), "content1").unwrap();
730        let entry1 = create_mock_dir_entry(&base_path.join("file1.txt"));
731
732        let config = Config::default();
733        let state1 = ProjectState::from_files(&[entry1], base_path, &config, false).unwrap();
734
735        // Create a different state with different files
736        fs::write(base_path.join("file2.txt"), "content2").unwrap();
737        let entry2 = create_mock_dir_entry(&base_path.join("file2.txt"));
738        let state2 = ProjectState::from_files(&[entry2], base_path, &config, false).unwrap();
739
740        // Should detect changes because files are completely different
741        assert!(state1.has_changes(&state2));
742    }
743
744    #[test]
745    fn test_file_state_with_invalid_data_error() {
746        // Create a temporary file with binary content that might trigger InvalidData
747        let temp_dir = tempdir().unwrap();
748        let binary_file = temp_dir.path().join("binary.dat");
749
750        // Write invalid UTF-8 bytes
751        let binary_data = vec![0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA];
752        fs::write(&binary_file, &binary_data).unwrap();
753
754        // This might trigger the InvalidData error path, but since we can't guarantee it,
755        // we at least verify the function can handle binary files
756        let result = FileState::from_path(&binary_file);
757        assert!(result.is_ok());
758    }
759
760    // Helper function to create a mock DirEntry for testing
761    fn create_mock_dir_entry(path: &std::path::Path) -> ignore::DirEntry {
762        // This is a bit of a hack since DirEntry doesn't have a public constructor
763        // We use the ignore crate's WalkBuilder to create a real DirEntry
764        let walker = ignore::WalkBuilder::new(path.parent().unwrap());
765        walker
766            .build()
767            .filter_map(Result::ok)
768            .find(|entry| entry.path() == path)
769            .expect("Failed to create DirEntry for test")
770    }
771}