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"];
48const DEFAULT_DISTRIBUTION_SEGMENT: &str = "dist";
49const DEFAULT_DISTRIBUTION_LANGUAGE_SEGMENTS: &[&str] = &[
50 "javascript",
51 "js",
52 "src",
53 "source",
54 "sources",
55 "ts",
56 "typescript",
57];
58const DEFAULT_DISTRIBUTION_RUNTIME_SEGMENTS: &[&str] =
59 &["app", "client", "core", "runtime", "server"];
60
61pub fn preview_repository_scope(
63 registration: &CodeRepositoryRegistration,
64 selector: &CodeRepositorySelector,
65) -> Result<CodeRepositoryScopePreview, CodeIndexError> {
66 let root = PathBuf::from(®istration.root_path);
67 let commit = resolve_ref(&root, &selector.ref_selector)?;
68 let tree_hash = resolve_tree(&root, &commit)?;
69 let ignore_rules = load_ignore_rules_from_commit(&root, &commit)?;
70 let mut selected_byte_count = 0usize;
71 let mut selected_file_count = 0usize;
72 let mut unsupported_file_count = 0usize;
73 let mut generated_or_heavy_file_count = 0usize;
74 let mut expected_degraded_file_count = 0usize;
75 let mut language_distribution = BTreeMap::<String, (usize, usize)>::new();
76 let mut largest_files = Vec::<CodeRepositoryLargestFile>::new();
77 let mut excluded_paths = Vec::<CodeRepositoryExcludedPath>::new();
78
79 for entry in tracked_entries(&root, &commit)? {
80 if let Some(reason) =
81 selection_exclusion_reason(&entry.path, registration, selector, &ignore_rules)
82 {
83 if excluded_paths.len() < PREVIEW_MAX_EXCLUDED_PATHS {
84 excluded_paths.push(CodeRepositoryExcludedPath {
85 path: entry.path,
86 reason,
87 });
88 }
89 continue;
90 }
91 let language = language_id(&entry.path).unwrap_or("unknown");
92 selected_file_count += 1;
93 selected_byte_count = selected_byte_count.saturating_add(entry.byte_count);
94 let bucket = language_distribution
95 .entry(language.to_owned())
96 .or_insert((0, 0));
97 bucket.0 += 1;
98 bucket.1 = bucket.1.saturating_add(entry.byte_count);
99 let is_unsupported = language == "unknown";
100 let is_heavy = entry.byte_count > DEFAULT_TEXT_FILE_BUDGET_BYTES;
101 if is_unsupported {
102 unsupported_file_count += 1;
103 }
104 if is_heavy {
105 generated_or_heavy_file_count += 1;
106 }
107 if is_unsupported || is_heavy {
108 expected_degraded_file_count += 1;
109 }
110 largest_files.push(CodeRepositoryLargestFile {
111 path: entry.path,
112 byte_count: entry.byte_count,
113 });
114 }
115 largest_files.sort_by(|left, right| {
116 right
117 .byte_count
118 .cmp(&left.byte_count)
119 .then_with(|| left.path.cmp(&right.path))
120 });
121 largest_files.truncate(PREVIEW_MAX_LARGEST_FILES);
122
123 Ok(CodeRepositoryScopePreview {
124 repository_id: registration.repository_id.clone(),
125 alias: registration.alias.clone(),
126 requested_ref: selector.ref_selector.clone(),
127 resolved_commit_sha: commit,
128 tree_hash,
129 selected_file_count,
130 selected_byte_count,
131 unsupported_file_count,
132 generated_or_heavy_file_count,
133 expected_degraded_file_count,
134 language_distribution: language_distribution
135 .into_iter()
136 .map(
137 |(language_id, (file_count, byte_count))| CodeRepositoryLanguagePreview {
138 language_id,
139 file_count,
140 byte_count,
141 },
142 )
143 .collect(),
144 largest_files,
145 excluded_paths,
146 })
147}
148
149pub fn partition_changed_paths_for_selector(
151 registration: &CodeRepositoryRegistration,
152 selector: &CodeRepositorySelector,
153 paths: Vec<String>,
154) -> Result<CodeImpactPathGroups, CodeIndexError> {
155 let root = PathBuf::from(®istration.root_path);
156 let commit = resolve_ref(&root, &selector.ref_selector)?;
157 let ignore_rules = load_ignore_rules_from_commit(&root, &commit)?;
158 let mut in_scope_changed_paths = Vec::new();
159 let mut out_of_scope_changed_paths = Vec::new();
160 for path in paths {
161 if selection_exclusion_reason(&path, registration, selector, &ignore_rules).is_none() {
162 in_scope_changed_paths.push(path);
163 } else {
164 out_of_scope_changed_paths.push(path);
165 }
166 }
167 in_scope_changed_paths.sort();
168 in_scope_changed_paths.dedup();
169 out_of_scope_changed_paths.sort();
170 out_of_scope_changed_paths.dedup();
171
172 Ok(CodeImpactPathGroups {
173 in_scope_changed_paths,
174 out_of_scope_changed_paths,
175 })
176}
177
178#[cfg(test)]
179pub(super) fn path_is_selected(
180 path: &str,
181 registration: &CodeRepositoryRegistration,
182 selector: &CodeRepositorySelector,
183) -> bool {
184 let root = Path::new(®istration.root_path);
185 let ignore_rules = load_ignore_rules(root).expect("ignore rules should load in tests");
186
187 path_is_selected_with_rules(path, registration, selector, &ignore_rules)
188}
189
190pub(super) fn path_is_selected_with_rules(
191 path: &str,
192 registration: &CodeRepositoryRegistration,
193 selector: &CodeRepositorySelector,
194 ignore_rules: &[IgnoreRule],
195) -> bool {
196 selection_exclusion_reason(path, registration, selector, ignore_rules).is_none()
197}
198
199pub(super) fn selection_exclusion_reason(
200 path: &str,
201 registration: &CodeRepositoryRegistration,
202 selector: &CodeRepositorySelector,
203 ignore_rules: &[IgnoreRule],
204) -> Option<String> {
205 if !path_scope_allows(path, registration, selector) {
206 return Some("outside registered/requested path scope".to_owned());
207 }
208 if !language_filter_allows(path, ®istration.language_filters)
209 || !language_filter_allows(path, &selector.language_filters)
210 {
211 return Some("outside registered/requested language scope".to_owned());
212 }
213 if ignore_rules.iter().any(|rule| rule.matches(path)) {
214 return Some("excluded by .relay-knowledgeignore".to_owned());
215 }
216 if default_source_preset_excludes(path)
217 && !explicit_path_filter_opts_into_default_exclusion(
218 path,
219 registration
220 .path_filters
221 .iter()
222 .chain(selector.path_filters.iter()),
223 )
224 {
225 return Some("excluded by source preset".to_owned());
226 }
227
228 None
229}
230
231pub(super) fn path_scope_allows(
232 path: &str,
233 registration: &CodeRepositoryRegistration,
234 selector: &CodeRepositorySelector,
235) -> bool {
236 path_filter_allows(path, ®istration.path_filters)
237 && path_filter_allows(path, &selector.path_filters)
238}
239
240pub(super) fn path_scope_overlaps(
241 path: &str,
242 registration: &CodeRepositoryRegistration,
243 selector: &CodeRepositorySelector,
244) -> bool {
245 path_filter_overlaps(path, ®istration.path_filters)
246 && path_filter_overlaps(path, &selector.path_filters)
247}
248
249pub(super) fn load_ignore_rules(root: &Path) -> Result<Vec<IgnoreRule>, CodeIndexError> {
250 let path = root.join(".relay-knowledgeignore");
251 let content = match fs::read_to_string(path) {
252 Ok(content) => content,
253 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
254 Err(error) => return Err(error.into()),
255 };
256
257 Ok(parse_ignore_rules(&content))
258}
259
260pub(super) fn load_ignore_rules_from_commit(
261 root: &Path,
262 commit: &str,
263) -> Result<Vec<IgnoreRule>, CodeIndexError> {
264 let object = format!("{commit}:.relay-knowledgeignore");
265 if !git_object_exists(root, &object)? {
266 return Ok(Vec::new());
267 }
268 let content = String::from_utf8(git_bytes(root, ["show", &object])?).map_err(|error| {
269 CodeIndexError::InvalidInput(format!(
270 ".relay-knowledgeignore at {commit} is not valid UTF-8: {}",
271 error.utf8_error()
272 ))
273 })?;
274
275 Ok(parse_ignore_rules(&content))
276}
277
278fn parse_ignore_rules(content: &str) -> Vec<IgnoreRule> {
279 content
280 .lines()
281 .map(str::trim)
282 .filter(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with('!'))
283 .map(|line| IgnoreRule {
284 pattern: line.trim_start_matches('/').to_owned(),
285 anchored: line.starts_with('/'),
286 })
287 .collect()
288}
289
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub(super) struct IgnoreRule {
292 pattern: String,
293 anchored: bool,
294}
295
296impl IgnoreRule {
297 fn matches(&self, path: &str) -> bool {
298 let pattern = normalize_path_filter(&self.pattern);
299 let path = normalize_path_filter(path);
300 if pattern.is_empty() {
301 return false;
302 }
303 if let Some(extension) = pattern.strip_prefix("*.") {
304 return if self.anchored {
305 path.rsplit_once('/').is_none()
306 && path
307 .rsplit_once('.')
308 .is_some_and(|(_, path_extension)| path_extension == extension)
309 } else {
310 path.rsplit_once('.')
311 .is_some_and(|(_, path_extension)| path_extension == extension)
312 };
313 }
314 if pattern.contains('/') {
315 return path == pattern || path.starts_with(&format!("{pattern}/"));
316 }
317 if self.anchored {
318 return path == pattern || path.starts_with(&format!("{pattern}/"));
319 }
320 path.split('/').any(|segment| segment == pattern)
321 }
322}
323
324fn path_filter_allows(path: &str, filters: &[String]) -> bool {
325 filters.is_empty()
326 || filters
327 .iter()
328 .any(|filter| path_matches_filter(path, filter))
329}
330
331fn path_filter_overlaps(path: &str, filters: &[String]) -> bool {
332 filters.is_empty()
333 || filters
334 .iter()
335 .any(|filter| path_overlaps_filter(path, filter))
336}
337
338fn language_filter_allows(path: &str, filters: &[String]) -> bool {
339 filters.is_empty()
340 || language_id(path)
341 .map(|language| filters.iter().any(|filter| filter == language))
342 .unwrap_or(false)
343}
344
345fn default_source_preset_excludes(path: &str) -> bool {
346 let normalized = normalize_path_filter(path);
347 if normalized
348 .rsplit('/')
349 .next()
350 .is_some_and(|file_name| DEFAULT_EXCLUDED_FILENAMES.contains(&file_name))
351 {
352 return true;
353 }
354 if normalized
355 .split('/')
356 .any(|segment| default_excluded_segment_excludes_path(segment, normalized))
357 {
358 return true;
359 }
360 normalized
361 .rsplit_once('.')
362 .map(|(_, extension)| {
363 DEFAULT_EXCLUDED_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str())
364 })
365 .unwrap_or(false)
366}
367
368fn default_excluded_segment_excludes_path(segment: &str, path: &str) -> bool {
369 DEFAULT_EXCLUDED_SEGMENTS.contains(&segment)
370 && (segment != DEFAULT_DISTRIBUTION_SEGMENT || distribution_segment_excludes_path(path))
371}
372
373fn distribution_segment_excludes_path(path: &str) -> bool {
374 let segments = path.split('/').collect::<Vec<_>>();
375
376 !distribution_runtime_source_path_is_indexable(path, &segments)
377}
378
379fn distribution_runtime_source_path_is_indexable(path: &str, segments: &[&str]) -> bool {
380 language_id(path).is_some()
381 && !path
382 .rsplit('/')
383 .next()
384 .is_some_and(|file_name| file_name.to_ascii_lowercase().contains(".min."))
385 && segments.windows(3).any(|window| {
386 window[0] == DEFAULT_DISTRIBUTION_SEGMENT
387 && DEFAULT_DISTRIBUTION_LANGUAGE_SEGMENTS.contains(&window[1])
388 && DEFAULT_DISTRIBUTION_RUNTIME_SEGMENTS.contains(&window[2])
389 })
390}
391
392fn explicit_path_filter_opts_into_default_exclusion<'a>(
393 path: &str,
394 filters: impl IntoIterator<Item = &'a String>,
395) -> bool {
396 let path_extension = path
397 .rsplit_once('.')
398 .map(|(_, extension)| extension.to_ascii_lowercase());
399 filters.into_iter().any(|filter| {
400 let filter = normalize_path_filter(filter);
401 if filter.is_empty() || filter == "." {
402 return false;
403 }
404 let filter_segments = filter.split('/').collect::<Vec<_>>();
405 let targets_default_exclusion = filter_segments.iter().any(|segment| {
406 DEFAULT_EXCLUDED_SEGMENTS.contains(segment)
407 || DEFAULT_EXCLUDED_FILENAMES.contains(segment)
408 || segment
409 .rsplit_once('.')
410 .map(|(_, ext)| ext.to_ascii_lowercase())
411 .is_some_and(|extension| {
412 DEFAULT_EXCLUDED_EXTENSIONS.contains(&extension.as_str())
413 })
414 });
415 if !targets_default_exclusion {
416 return false;
417 }
418 path_matches_filter(path, filter)
419 || filter.strip_prefix("*.").is_some_and(|extension| {
420 path_extension.as_deref() == Some(&extension.to_ascii_lowercase())
421 })
422 })
423}
424
425fn path_matches_filter(path: &str, filter: &str) -> bool {
426 let path = normalize_path_filter(path);
427 let filter = normalize_path_filter(filter);
428 if filter == "." {
429 return true;
430 }
431 !filter.is_empty() && (path == filter || path.starts_with(&format!("{filter}/")))
432}
433
434fn path_overlaps_filter(path: &str, filter: &str) -> bool {
435 let path = normalize_path_filter(path);
436 let filter = normalize_path_filter(filter);
437 if filter == "." {
438 return true;
439 }
440 !path.is_empty()
441 && !filter.is_empty()
442 && (path == filter
443 || path.starts_with(&format!("{filter}/"))
444 || filter.starts_with(&format!("{path}/")))
445}
446
447fn normalize_path_filter(filter: &str) -> &str {
448 let mut filter = filter.trim_end_matches(['/', '\\']);
449 while let Some(stripped) = filter.strip_prefix("./") {
450 filter = stripped;
451 }
452
453 filter
454}
455
456#[cfg(test)]
457mod tests {
458 use crate::domain::{CodeRepositoryRegistration, CodeRepositorySelector};
459
460 use super::*;
461
462 #[test]
463 fn source_preset_keeps_distribution_runtime_sources_indexable() {
464 assert!(!default_source_preset_excludes(
465 "frontend/dist/js/core/stream.js"
466 ));
467 assert!(!default_source_preset_excludes(
468 "frontend/dist/js/app/bootstrap.js"
469 ));
470 assert!(!default_source_preset_excludes(
471 "web/dist/src/runtime/session.ts"
472 ));
473 assert!(default_source_preset_excludes("dist/bundle.js"));
474 assert!(default_source_preset_excludes(
475 "frontend/dist/js/components/sidebar.js"
476 ));
477 assert!(default_source_preset_excludes(
478 "frontend/dist/js/core/highlight.min.js"
479 ));
480 assert!(default_source_preset_excludes("frontend/dist/css/app.css"));
481 assert!(default_source_preset_excludes(
482 "node_modules/pkg/dist/js/core/index.js"
483 ));
484 }
485
486 #[test]
487 fn explicit_default_exclusion_opt_in_normalizes_extension_case() {
488 let registration = CodeRepositoryRegistration::new(
489 "repo",
490 "alias",
491 "/tmp/repo",
492 vec!["assets/logo.SVG".to_owned()],
493 Vec::new(),
494 )
495 .expect("registration should validate");
496 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
497 .expect("selector should validate");
498
499 assert!(path_is_selected(
500 "assets/logo.SVG",
501 ®istration,
502 &selector
503 ));
504 }
505
506 #[test]
507 fn default_source_preset_excludes_dataset_dumps_and_uv_lock() {
508 let registration = CodeRepositoryRegistration::new(
509 "repo",
510 "alias",
511 "/tmp/repo",
512 vec![".".to_owned()],
513 Vec::new(),
514 )
515 .expect("registration should validate");
516 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
517 .expect("selector should validate");
518
519 assert!(!path_is_selected(
520 ".agent_teams/evals/datasets/swebench-verified-full.jsonl",
521 ®istration,
522 &selector
523 ));
524 assert!(!path_is_selected("uv.lock", ®istration, &selector));
525 }
526}