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 parser::dependency_manifest_language_ids, 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 if filters.is_empty() {
340 return true;
341 }
342 if language_id(path).is_some_and(|language| filters.iter().any(|filter| filter == language)) {
343 return true;
344 }
345 dependency_manifest_language_ids(path).is_some_and(|languages| {
346 languages
347 .iter()
348 .any(|language| filters.iter().any(|filter| filter == language))
349 })
350}
351
352fn default_source_preset_excludes(path: &str) -> bool {
353 let normalized = normalize_path_filter(path);
354 if normalized
355 .rsplit('/')
356 .next()
357 .is_some_and(|file_name| DEFAULT_EXCLUDED_FILENAMES.contains(&file_name))
358 {
359 return true;
360 }
361 if normalized
362 .split('/')
363 .any(|segment| default_excluded_segment_excludes_path(segment, normalized))
364 {
365 return true;
366 }
367 normalized
368 .rsplit_once('.')
369 .map(|(_, extension)| {
370 DEFAULT_EXCLUDED_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str())
371 })
372 .unwrap_or(false)
373}
374
375fn default_excluded_segment_excludes_path(segment: &str, path: &str) -> bool {
376 DEFAULT_EXCLUDED_SEGMENTS.contains(&segment)
377 && (segment != DEFAULT_DISTRIBUTION_SEGMENT || distribution_segment_excludes_path(path))
378}
379
380fn distribution_segment_excludes_path(path: &str) -> bool {
381 let segments = path.split('/').collect::<Vec<_>>();
382
383 !distribution_runtime_source_path_is_indexable(path, &segments)
384}
385
386fn distribution_runtime_source_path_is_indexable(path: &str, segments: &[&str]) -> bool {
387 language_id(path).is_some()
388 && !path
389 .rsplit('/')
390 .next()
391 .is_some_and(|file_name| file_name.to_ascii_lowercase().contains(".min."))
392 && segments.windows(3).any(|window| {
393 window[0] == DEFAULT_DISTRIBUTION_SEGMENT
394 && DEFAULT_DISTRIBUTION_LANGUAGE_SEGMENTS.contains(&window[1])
395 && DEFAULT_DISTRIBUTION_RUNTIME_SEGMENTS.contains(&window[2])
396 })
397}
398
399fn explicit_path_filter_opts_into_default_exclusion<'a>(
400 path: &str,
401 filters: impl IntoIterator<Item = &'a String>,
402) -> bool {
403 let path_extension = path
404 .rsplit_once('.')
405 .map(|(_, extension)| extension.to_ascii_lowercase());
406 filters.into_iter().any(|filter| {
407 let filter = normalize_path_filter(filter);
408 if filter.is_empty() || filter == "." {
409 return false;
410 }
411 let filter_segments = filter.split('/').collect::<Vec<_>>();
412 let targets_default_exclusion = filter_segments.iter().any(|segment| {
413 DEFAULT_EXCLUDED_SEGMENTS.contains(segment)
414 || DEFAULT_EXCLUDED_FILENAMES.contains(segment)
415 || segment
416 .rsplit_once('.')
417 .map(|(_, ext)| ext.to_ascii_lowercase())
418 .is_some_and(|extension| {
419 DEFAULT_EXCLUDED_EXTENSIONS.contains(&extension.as_str())
420 })
421 });
422 if !targets_default_exclusion {
423 return false;
424 }
425 path_matches_filter(path, filter)
426 || filter.strip_prefix("*.").is_some_and(|extension| {
427 path_extension.as_deref() == Some(&extension.to_ascii_lowercase())
428 })
429 })
430}
431
432fn path_matches_filter(path: &str, filter: &str) -> bool {
433 let path = normalize_path_filter(path);
434 let filter = normalize_path_filter(filter);
435 if filter == "." {
436 return true;
437 }
438 !filter.is_empty() && (path == filter || path.starts_with(&format!("{filter}/")))
439}
440
441fn path_overlaps_filter(path: &str, filter: &str) -> bool {
442 let path = normalize_path_filter(path);
443 let filter = normalize_path_filter(filter);
444 if filter == "." {
445 return true;
446 }
447 !path.is_empty()
448 && !filter.is_empty()
449 && (path == filter
450 || path.starts_with(&format!("{filter}/"))
451 || filter.starts_with(&format!("{path}/")))
452}
453
454fn normalize_path_filter(filter: &str) -> &str {
455 let mut filter = filter.trim_end_matches(['/', '\\']);
456 while let Some(stripped) = filter.strip_prefix("./") {
457 filter = stripped;
458 }
459
460 filter
461}
462
463#[cfg(test)]
464mod tests {
465 use crate::domain::{CodeRepositoryRegistration, CodeRepositorySelector};
466
467 use super::*;
468
469 #[test]
470 fn source_preset_keeps_distribution_runtime_sources_indexable() {
471 assert!(!default_source_preset_excludes(
472 "frontend/dist/js/core/stream.js"
473 ));
474 assert!(!default_source_preset_excludes(
475 "frontend/dist/js/app/bootstrap.js"
476 ));
477 assert!(!default_source_preset_excludes(
478 "web/dist/src/runtime/session.ts"
479 ));
480 assert!(default_source_preset_excludes("dist/bundle.js"));
481 assert!(default_source_preset_excludes(
482 "frontend/dist/js/components/sidebar.js"
483 ));
484 assert!(default_source_preset_excludes(
485 "frontend/dist/js/core/highlight.min.js"
486 ));
487 assert!(default_source_preset_excludes("frontend/dist/css/app.css"));
488 assert!(default_source_preset_excludes(
489 "node_modules/pkg/dist/js/core/index.js"
490 ));
491 }
492
493 #[test]
494 fn explicit_default_exclusion_opt_in_normalizes_extension_case() {
495 let registration = CodeRepositoryRegistration::new(
496 "repo",
497 "alias",
498 "/tmp/repo",
499 vec!["assets/logo.SVG".to_owned()],
500 Vec::new(),
501 )
502 .expect("registration should validate");
503 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
504 .expect("selector should validate");
505
506 assert!(path_is_selected(
507 "assets/logo.SVG",
508 ®istration,
509 &selector
510 ));
511 }
512
513 #[test]
514 fn default_source_preset_excludes_dataset_dumps_and_uv_lock() {
515 let registration = CodeRepositoryRegistration::new(
516 "repo",
517 "alias",
518 "/tmp/repo",
519 vec![".".to_owned()],
520 Vec::new(),
521 )
522 .expect("registration should validate");
523 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
524 .expect("selector should validate");
525
526 assert!(!path_is_selected(
527 ".agent_teams/evals/datasets/swebench-verified-full.jsonl",
528 ®istration,
529 &selector
530 ));
531 assert!(!path_is_selected("uv.lock", ®istration, &selector));
532 }
533
534 #[test]
535 fn nonstandard_source_roots_are_selected_without_opt_in() {
536 let registration = CodeRepositoryRegistration::new(
537 "repo",
538 "alias",
539 "/tmp/repo",
540 vec![".".to_owned()],
541 Vec::new(),
542 )
543 .expect("registration should validate");
544 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
545 .expect("selector should validate");
546
547 for path in [
548 "external_deps/python_sdk/session_client.py",
549 "packages/ui/src/index.ts",
550 "modules/java_sdk/src/main/java/example/SessionClient.java",
551 "plugins/example.com/nonstandard/session/client.go",
552 "Sources/SwiftSdk/SessionClient.swift",
553 "lib/app/controller.rb",
554 ] {
555 assert!(path_is_selected(path, ®istration, &selector), "{path}");
556 }
557 assert!(!path_is_selected(
558 "vendor/pkg/session_client.py",
559 ®istration,
560 &selector
561 ));
562 assert!(!path_is_selected(
563 "third_party/pkg/session_client.py",
564 ®istration,
565 &selector
566 ));
567 }
568
569 #[test]
570 fn explicit_vendor_source_opt_in_stays_supported() {
571 let registration = CodeRepositoryRegistration::new(
572 "repo",
573 "alias",
574 "/tmp/repo",
575 vec![".".to_owned(), "vendor".to_owned()],
576 Vec::new(),
577 )
578 .expect("registration should validate");
579 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
580 .expect("selector should validate");
581
582 assert!(path_is_selected(
583 "vendor/pkg/session_client.py",
584 ®istration,
585 &selector
586 ));
587 }
588}