1use crate::interner::StringInterner;
4use crate::types::{ChangeType, ChangedFile, InternedString};
5use std::collections::HashSet;
6
7pub struct DirNameExtractor<'a> {
9 interner: &'a StringInterner,
10}
11
12impl<'a> DirNameExtractor<'a> {
13 pub fn new(interner: &'a StringInterner) -> Self {
15 Self { interner }
16 }
17
18 pub fn extract(
22 &self,
23 files: &[ChangedFile],
24 indices: &[u32],
25 max_depth: Option<u32>,
26 exclude_current_dir: bool,
27 include_files: Option<&[&str]>,
28 deleted_only_dirs: bool,
29 ) -> Vec<InternedString> {
30 let mut dirs: HashSet<InternedString> = HashSet::new();
31
32 if deleted_only_dirs {
34 return self.extract_deleted_only_dirs(files, indices, max_depth);
35 }
36
37 for &idx in indices {
38 let file = &files[idx as usize];
39
40 if let Some(path) = self.interner.resolve(file.path) {
41 if let Some(patterns) = include_files {
43 let matches = patterns.iter().any(|p| path.contains(p));
44 if !matches {
45 continue;
46 }
47 }
48
49 if let Some(dir) = self.extract_dir(path, max_depth, exclude_current_dir) {
50 dirs.insert(self.interner.intern(dir));
51 }
52 }
53 }
54
55 dirs.into_iter().collect()
56 }
57
58 fn extract_dir<'b>(
60 &self,
61 path: &'b str,
62 max_depth: Option<u32>,
63 exclude_current_dir: bool,
64 ) -> Option<&'b str> {
65 let dir = match path.rfind('/') {
67 Some(pos) => &path[..pos],
68 None => {
69 if exclude_current_dir {
71 return None;
72 }
73 return Some(".");
74 }
75 };
76
77 if exclude_current_dir && dir == "." {
78 return None;
79 }
80
81 if let Some(max_depth) = max_depth {
83 let depth = dir.matches('/').count() as u32 + 1;
84 if depth > max_depth {
85 let mut slash_count = 0;
87 for (i, ch) in dir.char_indices() {
88 if ch == '/' {
89 slash_count += 1;
90 if slash_count >= max_depth {
91 return Some(&dir[..i]);
92 }
93 }
94 }
95 }
96 }
97
98 Some(dir)
99 }
100
101 fn extract_deleted_only_dirs(
103 &self,
104 files: &[ChangedFile],
105 indices: &[u32],
106 max_depth: Option<u32>,
107 ) -> Vec<InternedString> {
108 use std::collections::HashMap;
109
110 let mut dir_files: HashMap<String, (usize, usize)> = HashMap::new(); for &idx in indices {
114 let file = &files[idx as usize];
115 if let Some(path) = self.interner.resolve(file.path) {
116 let dir = match path.rfind('/') {
117 Some(pos) => path[..pos].to_string(),
118 None => ".".to_string(),
119 };
120
121 let entry = dir_files.entry(dir).or_insert((0, 0));
122 entry.0 += 1;
123 if file.change_type == ChangeType::Deleted {
124 entry.1 += 1;
125 }
126 }
127 }
128
129 let mut result = Vec::new();
131 for (dir, (total, deleted)) in dir_files {
132 if total == deleted && total > 0 {
133 let truncated = if let Some(max) = max_depth {
135 let parts: Vec<&str> = dir.split('/').collect();
136 if parts.len() > max as usize {
137 parts[..max as usize].join("/")
138 } else {
139 dir
140 }
141 } else {
142 dir
143 };
144 result.push(self.interner.intern(&truncated));
145 }
146 }
147
148 result
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::types::FileOrigin;
156
157 fn make_file(interner: &StringInterner, path: &str, change_type: ChangeType) -> ChangedFile {
158 ChangedFile {
159 path: interner.intern(path),
160 change_type,
161 previous_path: None,
162 is_symlink: false,
163 submodule_depth: 0,
164 origin: FileOrigin::default(),
165 }
166 }
167
168 #[test]
169 fn test_extract_basic() {
170 let interner = StringInterner::new();
171 let files = vec![
172 make_file(&interner, "src/main.rs", ChangeType::Modified),
173 make_file(&interner, "src/lib.rs", ChangeType::Added),
174 make_file(&interner, "tests/test.rs", ChangeType::Modified),
175 ];
176 let indices: Vec<u32> = (0..files.len() as u32).collect();
177
178 let extractor = DirNameExtractor::new(&interner);
179 let dirs = extractor.extract(&files, &indices, None, false, None, false);
180
181 let dir_names: HashSet<String> = dirs
182 .iter()
183 .filter_map(|d| interner.resolve(*d).map(String::from))
184 .collect();
185
186 assert!(dir_names.contains("src"));
187 assert!(dir_names.contains("tests"));
188 }
189
190 #[test]
191 fn test_extract_with_depth() {
192 let interner = StringInterner::new();
193 let files = vec![make_file(&interner, "a/b/c/file.rs", ChangeType::Modified)];
194 let indices = vec![0u32];
195
196 let extractor = DirNameExtractor::new(&interner);
197 let dirs = extractor.extract(&files, &indices, Some(2), false, None, false);
198
199 let dir_names: Vec<String> = dirs
200 .iter()
201 .filter_map(|d| interner.resolve(*d).map(String::from))
202 .collect();
203
204 assert_eq!(dir_names.len(), 1);
205 assert_eq!(dir_names[0], "a/b");
206 }
207
208 #[test]
209 fn test_extract_deleted_only_dirs() {
210 let interner = StringInterner::new();
211 let files = vec![
212 make_file(&interner, "old/a.rs", ChangeType::Deleted),
214 make_file(&interner, "old/b.rs", ChangeType::Deleted),
215 make_file(&interner, "mixed/a.rs", ChangeType::Deleted),
217 make_file(&interner, "mixed/b.rs", ChangeType::Modified),
218 ];
219 let indices: Vec<u32> = (0..files.len() as u32).collect();
220
221 let extractor = DirNameExtractor::new(&interner);
222 let dirs = extractor.extract(&files, &indices, None, false, None, true);
223
224 let dir_names: HashSet<String> = dirs
225 .iter()
226 .filter_map(|d| interner.resolve(*d).map(String::from))
227 .collect();
228
229 assert!(dir_names.contains("old"));
230 assert!(!dir_names.contains("mixed"));
231 }
232
233 #[test]
234 fn test_extract_deleted_only_dirs_with_depth() {
235 let interner = StringInterner::new();
236 let files = vec![make_file(&interner, "a/b/c/file.rs", ChangeType::Deleted)];
237 let indices = vec![0u32];
238
239 let extractor = DirNameExtractor::new(&interner);
240 let dirs = extractor.extract(&files, &indices, Some(2), false, None, true);
241
242 let dir_names: Vec<String> = dirs
243 .iter()
244 .filter_map(|d| interner.resolve(*d).map(String::from))
245 .collect();
246
247 assert_eq!(dir_names.len(), 1);
248 assert_eq!(dir_names[0], "a/b");
249 }
250
251 #[test]
252 fn test_extract_exclude_current_dir() {
253 let interner = StringInterner::new();
254 let files = vec![
255 make_file(&interner, "root_file.rs", ChangeType::Modified),
256 make_file(&interner, "src/main.rs", ChangeType::Modified),
257 ];
258 let indices: Vec<u32> = (0..files.len() as u32).collect();
259
260 let extractor = DirNameExtractor::new(&interner);
261 let dirs = extractor.extract(&files, &indices, None, true, None, false);
262
263 let dir_names: HashSet<String> = dirs
264 .iter()
265 .filter_map(|d| interner.resolve(*d).map(String::from))
266 .collect();
267
268 assert!(!dir_names.contains("."));
270 assert!(dir_names.contains("src"));
271 }
272
273 #[test]
274 fn test_extract_include_files_filter() {
275 let interner = StringInterner::new();
276 let files = vec![
277 make_file(&interner, "src/main.rs", ChangeType::Modified),
278 make_file(&interner, "src/lib.rs", ChangeType::Modified),
279 make_file(&interner, "tests/test.py", ChangeType::Modified),
280 ];
281 let indices: Vec<u32> = (0..files.len() as u32).collect();
282
283 let extractor = DirNameExtractor::new(&interner);
284 let include = vec![".rs"];
285 let dirs = extractor.extract(&files, &indices, None, false, Some(&include), false);
286
287 let dir_names: HashSet<String> = dirs
288 .iter()
289 .filter_map(|d| interner.resolve(*d).map(String::from))
290 .collect();
291
292 assert!(dir_names.contains("src"));
293 assert!(!dir_names.contains("tests"));
295 }
296}