Skip to main content

lechange_core/output/
dir_names.rs

1//! Directory name extraction from file paths
2
3use crate::interner::StringInterner;
4use crate::types::{ChangeType, ChangedFile, InternedString};
5use std::collections::HashSet;
6
7/// Extract unique directory names from file paths
8pub struct DirNameExtractor<'a> {
9    interner: &'a StringInterner,
10}
11
12impl<'a> DirNameExtractor<'a> {
13    /// Create a new directory name extractor
14    pub fn new(interner: &'a StringInterner) -> Self {
15        Self { interner }
16    }
17
18    /// Extract directory names from files with depth limiting
19    ///
20    /// Uses zero-copy `rfind('/')` for directory extraction.
21    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, collect all directories and check if all files in each are deleted
33        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                // Skip if include_files is set and path doesn't match
42                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    /// Extract directory from path with depth limiting
59    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        // Find directory component via rfind
66        let dir = match path.rfind('/') {
67            Some(pos) => &path[..pos],
68            None => {
69                // File is in root directory
70                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        // Apply depth limiting
82        if let Some(max_depth) = max_depth {
83            let depth = dir.matches('/').count() as u32 + 1;
84            if depth > max_depth {
85                // Truncate to max_depth
86                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    /// Extract directories where ALL files are deleted
102    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        // Group files by directory
111        let mut dir_files: HashMap<String, (usize, usize)> = HashMap::new(); // (total, deleted)
112
113        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        // Only include directories where all files are deleted
130        let mut result = Vec::new();
131        for (dir, (total, deleted)) in dir_files {
132            if total == deleted && total > 0 {
133                // Apply depth limiting
134                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            // dir "old" has all files deleted
213            make_file(&interner, "old/a.rs", ChangeType::Deleted),
214            make_file(&interner, "old/b.rs", ChangeType::Deleted),
215            // dir "mixed" has some deleted, some modified
216            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        // Root file excluded with exclude_current_dir=true
269        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        // tests dir should not appear because .py doesn't match .rs filter
294        assert!(!dir_names.contains("tests"));
295    }
296}