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