1use std::{
2 collections::BTreeMap,
3 fs,
4 path::{Path, PathBuf},
5};
6
7use crate::domain::{
8 CodeImpactPathGroups, CodeRepositoryExcludedPath, CodeRepositoryLanguagePreview,
9 CodeRepositoryLargestFile, CodeRepositoryRegistration, CodeRepositoryScopePreview,
10 CodeRepositorySelector,
11};
12
13use super::{
14 CodeIndexError, changes::tracked_entries, git_bytes, git_object_exists, languages::language_id,
15 resolve_ref, resolve_tree,
16};
17
18const PREVIEW_MAX_EXCLUDED_PATHS: usize = 50;
19const PREVIEW_MAX_LARGEST_FILES: usize = 10;
20const DEFAULT_TEXT_FILE_BUDGET_BYTES: usize = 512 * 1024;
21const DEFAULT_EXCLUDED_SEGMENTS: &[&str] = &[
22 ".git",
23 ".cache",
24 ".next",
25 ".nuxt",
26 ".parcel-cache",
27 ".pytest_cache",
28 ".ruff_cache",
29 ".tox",
30 ".venv",
31 "__pycache__",
32 "build",
33 "coverage",
34 "dist",
35 "node_modules",
36 "out",
37 "target",
38 "third_party",
39 "vendor",
40 "venv",
41];
42const DEFAULT_EXCLUDED_EXTENSIONS: &[&str] = &[
43 "7z", "avif", "bmp", "bz2", "class", "eot", "gif", "gz", "ico", "jar", "jpeg", "jpg", "jsonl",
44 "lockb", "map", "mov", "mp4", "otf", "pdf", "png", "svg", "tar", "tgz", "ttf", "wasm", "webm",
45 "woff", "woff2", "zip", "zst",
46];
47const DEFAULT_EXCLUDED_FILENAMES: &[&str] = &[".relay-knowledgeignore", "uv.lock"];
48
49pub fn preview_repository_scope(
51 registration: &CodeRepositoryRegistration,
52 selector: &CodeRepositorySelector,
53) -> Result<CodeRepositoryScopePreview, CodeIndexError> {
54 let root = PathBuf::from(®istration.root_path);
55 let commit = resolve_ref(&root, &selector.ref_selector)?;
56 let tree_hash = resolve_tree(&root, &commit)?;
57 let ignore_rules = load_ignore_rules_from_commit(&root, &commit)?;
58 let mut selected_byte_count = 0usize;
59 let mut selected_file_count = 0usize;
60 let mut unsupported_file_count = 0usize;
61 let mut generated_or_heavy_file_count = 0usize;
62 let mut expected_degraded_file_count = 0usize;
63 let mut language_distribution = BTreeMap::<String, (usize, usize)>::new();
64 let mut largest_files = Vec::<CodeRepositoryLargestFile>::new();
65 let mut excluded_paths = Vec::<CodeRepositoryExcludedPath>::new();
66
67 for entry in tracked_entries(&root, &commit)? {
68 if let Some(reason) =
69 selection_exclusion_reason(&entry.path, registration, selector, &ignore_rules)
70 {
71 if excluded_paths.len() < PREVIEW_MAX_EXCLUDED_PATHS {
72 excluded_paths.push(CodeRepositoryExcludedPath {
73 path: entry.path,
74 reason,
75 });
76 }
77 continue;
78 }
79 let language = language_id(&entry.path).unwrap_or("unknown");
80 selected_file_count += 1;
81 selected_byte_count = selected_byte_count.saturating_add(entry.byte_count);
82 let bucket = language_distribution
83 .entry(language.to_owned())
84 .or_insert((0, 0));
85 bucket.0 += 1;
86 bucket.1 = bucket.1.saturating_add(entry.byte_count);
87 let is_unsupported = language == "unknown";
88 let is_heavy = entry.byte_count > DEFAULT_TEXT_FILE_BUDGET_BYTES;
89 if is_unsupported {
90 unsupported_file_count += 1;
91 }
92 if is_heavy {
93 generated_or_heavy_file_count += 1;
94 }
95 if is_unsupported || is_heavy {
96 expected_degraded_file_count += 1;
97 }
98 largest_files.push(CodeRepositoryLargestFile {
99 path: entry.path,
100 byte_count: entry.byte_count,
101 });
102 }
103 largest_files.sort_by(|left, right| {
104 right
105 .byte_count
106 .cmp(&left.byte_count)
107 .then_with(|| left.path.cmp(&right.path))
108 });
109 largest_files.truncate(PREVIEW_MAX_LARGEST_FILES);
110
111 Ok(CodeRepositoryScopePreview {
112 repository_id: registration.repository_id.clone(),
113 alias: registration.alias.clone(),
114 requested_ref: selector.ref_selector.clone(),
115 resolved_commit_sha: commit,
116 tree_hash,
117 selected_file_count,
118 selected_byte_count,
119 unsupported_file_count,
120 generated_or_heavy_file_count,
121 expected_degraded_file_count,
122 language_distribution: language_distribution
123 .into_iter()
124 .map(
125 |(language_id, (file_count, byte_count))| CodeRepositoryLanguagePreview {
126 language_id,
127 file_count,
128 byte_count,
129 },
130 )
131 .collect(),
132 largest_files,
133 excluded_paths,
134 })
135}
136
137pub fn partition_changed_paths_for_selector(
139 registration: &CodeRepositoryRegistration,
140 selector: &CodeRepositorySelector,
141 paths: Vec<String>,
142) -> Result<CodeImpactPathGroups, CodeIndexError> {
143 let root = PathBuf::from(®istration.root_path);
144 let commit = resolve_ref(&root, &selector.ref_selector)?;
145 let ignore_rules = load_ignore_rules_from_commit(&root, &commit)?;
146 let mut in_scope_changed_paths = Vec::new();
147 let mut out_of_scope_changed_paths = Vec::new();
148 for path in paths {
149 if selection_exclusion_reason(&path, registration, selector, &ignore_rules).is_none() {
150 in_scope_changed_paths.push(path);
151 } else {
152 out_of_scope_changed_paths.push(path);
153 }
154 }
155 in_scope_changed_paths.sort();
156 in_scope_changed_paths.dedup();
157 out_of_scope_changed_paths.sort();
158 out_of_scope_changed_paths.dedup();
159
160 Ok(CodeImpactPathGroups {
161 in_scope_changed_paths,
162 out_of_scope_changed_paths,
163 })
164}
165
166#[cfg(test)]
167pub(super) fn path_is_selected(
168 path: &str,
169 registration: &CodeRepositoryRegistration,
170 selector: &CodeRepositorySelector,
171) -> bool {
172 let root = Path::new(®istration.root_path);
173 let ignore_rules = load_ignore_rules(root).expect("ignore rules should load in tests");
174
175 path_is_selected_with_rules(path, registration, selector, &ignore_rules)
176}
177
178pub(super) fn path_is_selected_with_rules(
179 path: &str,
180 registration: &CodeRepositoryRegistration,
181 selector: &CodeRepositorySelector,
182 ignore_rules: &[IgnoreRule],
183) -> bool {
184 selection_exclusion_reason(path, registration, selector, ignore_rules).is_none()
185}
186
187pub(super) fn selection_exclusion_reason(
188 path: &str,
189 registration: &CodeRepositoryRegistration,
190 selector: &CodeRepositorySelector,
191 ignore_rules: &[IgnoreRule],
192) -> Option<String> {
193 if !path_scope_allows(path, registration, selector) {
194 return Some("outside registered/requested path scope".to_owned());
195 }
196 if !language_filter_allows(path, ®istration.language_filters)
197 || !language_filter_allows(path, &selector.language_filters)
198 {
199 return Some("outside registered/requested language scope".to_owned());
200 }
201 if ignore_rules.iter().any(|rule| rule.matches(path)) {
202 return Some("excluded by .relay-knowledgeignore".to_owned());
203 }
204 if default_source_preset_excludes(path)
205 && !explicit_path_filter_opts_into_default_exclusion(
206 path,
207 registration
208 .path_filters
209 .iter()
210 .chain(selector.path_filters.iter()),
211 )
212 {
213 return Some("excluded by source preset".to_owned());
214 }
215
216 None
217}
218
219pub(super) fn path_scope_allows(
220 path: &str,
221 registration: &CodeRepositoryRegistration,
222 selector: &CodeRepositorySelector,
223) -> bool {
224 path_filter_allows(path, ®istration.path_filters)
225 && path_filter_allows(path, &selector.path_filters)
226}
227
228pub(super) fn path_scope_overlaps(
229 path: &str,
230 registration: &CodeRepositoryRegistration,
231 selector: &CodeRepositorySelector,
232) -> bool {
233 path_filter_overlaps(path, ®istration.path_filters)
234 && path_filter_overlaps(path, &selector.path_filters)
235}
236
237pub(super) fn load_ignore_rules(root: &Path) -> Result<Vec<IgnoreRule>, CodeIndexError> {
238 let path = root.join(".relay-knowledgeignore");
239 let content = match fs::read_to_string(path) {
240 Ok(content) => content,
241 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
242 Err(error) => return Err(error.into()),
243 };
244
245 Ok(parse_ignore_rules(&content))
246}
247
248pub(super) fn load_ignore_rules_from_commit(
249 root: &Path,
250 commit: &str,
251) -> Result<Vec<IgnoreRule>, CodeIndexError> {
252 let object = format!("{commit}:.relay-knowledgeignore");
253 if !git_object_exists(root, &object)? {
254 return Ok(Vec::new());
255 }
256 let content = String::from_utf8(git_bytes(root, ["show", &object])?).map_err(|error| {
257 CodeIndexError::InvalidInput(format!(
258 ".relay-knowledgeignore at {commit} is not valid UTF-8: {}",
259 error.utf8_error()
260 ))
261 })?;
262
263 Ok(parse_ignore_rules(&content))
264}
265
266fn parse_ignore_rules(content: &str) -> Vec<IgnoreRule> {
267 content
268 .lines()
269 .map(str::trim)
270 .filter(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with('!'))
271 .map(|line| IgnoreRule {
272 pattern: line.trim_start_matches('/').to_owned(),
273 anchored: line.starts_with('/'),
274 })
275 .collect()
276}
277
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub(super) struct IgnoreRule {
280 pattern: String,
281 anchored: bool,
282}
283
284impl IgnoreRule {
285 fn matches(&self, path: &str) -> bool {
286 let pattern = normalize_path_filter(&self.pattern);
287 let path = normalize_path_filter(path);
288 if pattern.is_empty() {
289 return false;
290 }
291 if let Some(extension) = pattern.strip_prefix("*.") {
292 return if self.anchored {
293 path.rsplit_once('/').is_none()
294 && path
295 .rsplit_once('.')
296 .is_some_and(|(_, path_extension)| path_extension == extension)
297 } else {
298 path.rsplit_once('.')
299 .is_some_and(|(_, path_extension)| path_extension == extension)
300 };
301 }
302 if pattern.contains('/') {
303 return path == pattern || path.starts_with(&format!("{pattern}/"));
304 }
305 if self.anchored {
306 return path == pattern || path.starts_with(&format!("{pattern}/"));
307 }
308 path.split('/').any(|segment| segment == pattern)
309 }
310}
311
312fn path_filter_allows(path: &str, filters: &[String]) -> bool {
313 filters.is_empty()
314 || filters
315 .iter()
316 .any(|filter| path_matches_filter(path, filter))
317}
318
319fn path_filter_overlaps(path: &str, filters: &[String]) -> bool {
320 filters.is_empty()
321 || filters
322 .iter()
323 .any(|filter| path_overlaps_filter(path, filter))
324}
325
326fn language_filter_allows(path: &str, filters: &[String]) -> bool {
327 filters.is_empty()
328 || language_id(path)
329 .map(|language| filters.iter().any(|filter| filter == language))
330 .unwrap_or(false)
331}
332
333fn default_source_preset_excludes(path: &str) -> bool {
334 let normalized = normalize_path_filter(path);
335 if normalized
336 .rsplit('/')
337 .next()
338 .is_some_and(|file_name| DEFAULT_EXCLUDED_FILENAMES.contains(&file_name))
339 {
340 return true;
341 }
342 if normalized
343 .split('/')
344 .any(|segment| DEFAULT_EXCLUDED_SEGMENTS.contains(&segment))
345 {
346 return true;
347 }
348 normalized
349 .rsplit_once('.')
350 .map(|(_, extension)| {
351 DEFAULT_EXCLUDED_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str())
352 })
353 .unwrap_or(false)
354}
355
356fn explicit_path_filter_opts_into_default_exclusion<'a>(
357 path: &str,
358 filters: impl IntoIterator<Item = &'a String>,
359) -> bool {
360 let path_extension = path
361 .rsplit_once('.')
362 .map(|(_, extension)| extension.to_ascii_lowercase());
363 filters.into_iter().any(|filter| {
364 let filter = normalize_path_filter(filter);
365 if filter.is_empty() || filter == "." {
366 return false;
367 }
368 let filter_segments = filter.split('/').collect::<Vec<_>>();
369 let targets_default_exclusion = filter_segments.iter().any(|segment| {
370 DEFAULT_EXCLUDED_SEGMENTS.contains(segment)
371 || DEFAULT_EXCLUDED_FILENAMES.contains(segment)
372 || segment
373 .rsplit_once('.')
374 .map(|(_, ext)| ext.to_ascii_lowercase())
375 .is_some_and(|extension| {
376 DEFAULT_EXCLUDED_EXTENSIONS.contains(&extension.as_str())
377 })
378 });
379 if !targets_default_exclusion {
380 return false;
381 }
382 path_matches_filter(path, filter)
383 || filter.strip_prefix("*.").is_some_and(|extension| {
384 path_extension.as_deref() == Some(&extension.to_ascii_lowercase())
385 })
386 })
387}
388
389fn path_matches_filter(path: &str, filter: &str) -> bool {
390 let path = normalize_path_filter(path);
391 let filter = normalize_path_filter(filter);
392 if filter == "." {
393 return true;
394 }
395 !filter.is_empty() && (path == filter || path.starts_with(&format!("{filter}/")))
396}
397
398fn path_overlaps_filter(path: &str, filter: &str) -> bool {
399 let path = normalize_path_filter(path);
400 let filter = normalize_path_filter(filter);
401 if filter == "." {
402 return true;
403 }
404 !path.is_empty()
405 && !filter.is_empty()
406 && (path == filter
407 || path.starts_with(&format!("{filter}/"))
408 || filter.starts_with(&format!("{path}/")))
409}
410
411fn normalize_path_filter(filter: &str) -> &str {
412 let mut filter = filter.trim_end_matches(['/', '\\']);
413 while let Some(stripped) = filter.strip_prefix("./") {
414 filter = stripped;
415 }
416
417 filter
418}