Skip to main content

termesh_filesystem/
reader.rs

1//! Reading one directory level *the way the explorer wants it*: listed, ignore-filtered,
2//! and capped.
3//!
4//! Exists so the worker thread and the synchronous headless path (`--dump-frame`, tests)
5//! run identical logic. Without it, ignore rules would silently apply in one and not the
6//! other, and the snapshot tests would be asserting something the app never does.
7
8use std::path::Path;
9
10use crate::ignore_rules::{IgnoreOptions, IgnoreRules};
11use crate::service::{DirEntryInfo, FileSystemService, FsResult};
12
13/// Entries materialised per directory level before we stop and summarise.
14///
15/// One pathological directory (a cache with 200k files) must not stall a render or
16/// balloon the tree (ADR-0005 §6).
17pub const MAX_ENTRIES_PER_DIR: usize = 10_000;
18
19/// Lists directories on behalf of the tree, applying ignore rules as it goes.
20pub struct DirReader<'a> {
21    fs: &'a dyn FileSystemService,
22    rules: IgnoreRules,
23}
24
25impl<'a> DirReader<'a> {
26    pub fn new(fs: &'a dyn FileSystemService, root: &Path, options: IgnoreOptions) -> Self {
27        let rules = IgnoreRules::for_root(fs, root, options);
28        Self { fs, rules }
29    }
30
31    /// A reader that hides nothing.
32    pub fn unfiltered(fs: &'a dyn FileSystemService) -> Self {
33        Self { fs, rules: IgnoreRules::disabled() }
34    }
35
36    pub fn rules(&self) -> &IgnoreRules {
37        &self.rules
38    }
39
40    /// The underlying service, for callers that need to mutate as well as list.
41    pub fn service(&self) -> &dyn FileSystemService {
42        self.fs
43    }
44
45    /// List one level: the service's entries, minus anything ignored, capped.
46    ///
47    /// Picks up `path`'s own `.gitignore` first, so nesting is honoured lazily — we only
48    /// pay for the rules of directories the user actually opened.
49    pub fn read(&mut self, path: &Path) -> FsResult<Vec<DirEntryInfo>> {
50        self.rules.load_dir(self.fs, path);
51        let entries = self.fs.read_dir(path)?;
52        let mut kept = self.rules.filter(entries);
53        kept.truncate(MAX_ENTRIES_PER_DIR);
54        Ok(kept)
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use std::path::PathBuf;
62
63    use crate::service::EntryKind;
64    use termesh_core::{FsError, FsResult};
65
66    struct Fs {
67        entries: Vec<DirEntryInfo>,
68        gitignore: Option<&'static str>,
69    }
70
71    impl FileSystemService for Fs {
72        fn read_dir(&self, _: &Path) -> FsResult<Vec<DirEntryInfo>> {
73            Ok(self.entries.clone())
74        }
75        fn read_file(&self, path: &Path) -> FsResult<Vec<u8>> {
76            match (self.gitignore, path.file_name().and_then(|n| n.to_str())) {
77                (Some(c), Some(".gitignore")) => Ok(c.as_bytes().to_vec()),
78                _ => Err(FsError::NotFound(path.to_path_buf())),
79            }
80        }
81        fn create_file(&self, _: &Path) -> FsResult<()> {
82            Ok(())
83        }
84        fn write_file(&self, _: &Path, _: &[u8]) -> FsResult<()> {
85            Ok(())
86        }
87        fn create_dir(&self, _: &Path) -> FsResult<()> {
88            Ok(())
89        }
90        fn rename(&self, _: &Path, _: &Path) -> FsResult<()> {
91            Ok(())
92        }
93        fn remove_file(&self, _: &Path) -> FsResult<()> {
94            Ok(())
95        }
96        fn remove_dir_all(&self, _: &Path) -> FsResult<()> {
97            Ok(())
98        }
99        fn canonicalize(&self, p: &Path) -> FsResult<PathBuf> {
100            Ok(p.to_path_buf())
101        }
102    }
103
104    fn entry(name: &str, kind: EntryKind) -> DirEntryInfo {
105        DirEntryInfo { name: name.into(), path: PathBuf::from("/r").join(name), kind }
106    }
107
108    #[test]
109    fn ignored_and_hidden_entries_are_filtered_out() {
110        let fs = Fs {
111            entries: vec![
112                entry("src", EntryKind::Dir),
113                entry("target", EntryKind::Dir),
114                entry(".git", EntryKind::Dir),
115                entry("README.md", EntryKind::File),
116            ],
117            gitignore: Some("target\n"),
118        };
119        let mut reader = DirReader::new(&fs, Path::new("/r"), IgnoreOptions::default());
120        let names: Vec<String> = reader
121            .read(Path::new("/r"))
122            .unwrap()
123            .iter()
124            .map(|e| e.name.to_string_lossy().into_owned())
125            .collect();
126        assert_eq!(names, ["src", "README.md"]);
127    }
128
129    #[test]
130    fn an_unfiltered_reader_keeps_everything() {
131        let fs = Fs {
132            entries: vec![entry(".git", EntryKind::Dir), entry("target", EntryKind::Dir)],
133            gitignore: Some("target\n"),
134        };
135        let mut reader = DirReader::unfiltered(&fs);
136        assert_eq!(reader.read(Path::new("/r")).unwrap().len(), 2);
137    }
138
139    #[test]
140    fn oversized_directories_are_capped() {
141        let entries =
142            (0..MAX_ENTRIES_PER_DIR + 500).map(|i| entry(&format!("f{i}"), EntryKind::File));
143        let fs = Fs { entries: entries.collect(), gitignore: None };
144        let mut reader = DirReader::unfiltered(&fs);
145        assert_eq!(reader.read(Path::new("/r")).unwrap().len(), MAX_ENTRIES_PER_DIR);
146    }
147
148    #[test]
149    fn read_errors_propagate_rather_than_becoming_an_empty_listing() {
150        struct Denied;
151        impl FileSystemService for Denied {
152            fn read_dir(&self, p: &Path) -> FsResult<Vec<DirEntryInfo>> {
153                Err(FsError::PermissionDenied(p.to_path_buf()))
154            }
155            fn read_file(&self, p: &Path) -> FsResult<Vec<u8>> {
156                Err(FsError::NotFound(p.to_path_buf()))
157            }
158            fn create_file(&self, _: &Path) -> FsResult<()> {
159                Ok(())
160            }
161            fn write_file(&self, _: &Path, _: &[u8]) -> FsResult<()> {
162                Ok(())
163            }
164            fn create_dir(&self, _: &Path) -> FsResult<()> {
165                Ok(())
166            }
167            fn rename(&self, _: &Path, _: &Path) -> FsResult<()> {
168                Ok(())
169            }
170            fn remove_file(&self, _: &Path) -> FsResult<()> {
171                Ok(())
172            }
173            fn remove_dir_all(&self, _: &Path) -> FsResult<()> {
174                Ok(())
175            }
176            fn canonicalize(&self, p: &Path) -> FsResult<PathBuf> {
177                Ok(p.to_path_buf())
178            }
179        }
180        let mut reader = DirReader::unfiltered(&Denied);
181        assert!(reader.read(Path::new("/r")).is_err(), "an empty list would look like success");
182    }
183}