1use std::{
2 collections::{BTreeMap, BTreeSet},
3 path::PathBuf,
4};
5
6use crate::domain::{
7 CodeImpactPathGroups, CodeRepositoryExcludedPath, CodeRepositoryLanguagePreview,
8 CodeRepositoryLargestFile, CodeRepositoryRegistration, CodeRepositoryScopePreview,
9 CodeRepositorySelector,
10};
11
12use super::{
13 CodeIndexError,
14 changes::GitTreeEntry,
15 languages::language_id,
16 parser::dependency_manifest_language_ids,
17 parser::dependency_manifest_overrides_default_exclusion,
18 snapshot,
19 source::{
20 FileSystemScanPolicy, RepositorySourceKind, RepositorySourceSnapshot,
21 filesystem_source_snapshot, source_snapshot,
22 },
23 source::{
24 explicit_path_filter_opts_into_default_file_exclusion, filesystem_content_hashes_for_paths,
25 filesystem_default_source_allows, filesystem_registration_identity,
26 filesystem_tree_hash_from_path_hashes, source_commit_is_filesystem,
27 source_default_file_preset_excludes, source_kind, source_language_filter_allows,
28 source_path_has_indexable_content,
29 },
30 source_roots::{NESTED_SOURCE_MARKERS, STRIPPABLE_SOURCE_ROOTS},
31};
32
33const PREVIEW_MAX_EXCLUDED_PATHS: usize = 50;
34const PREVIEW_MAX_LARGEST_FILES: usize = 10;
35const DEFAULT_TEXT_FILE_BUDGET_BYTES: usize = 512 * 1024;
36const SOURCE_LAYOUT_DISCOVERY_MAX_PATHS: usize = 200_000;
37const SOURCE_LAYOUT_DISCOVERY_MAX_ROOTS: usize = 512;
38const AUTO_SOURCE_SCOPE_FILTERS: &[&str] = &[".", "src", "include", "lib", "Sources"];
39
40#[derive(Debug, Clone)]
41pub(super) struct ScopedSourceSnapshot {
42 pub(super) kind: RepositorySourceKind,
43 pub(super) root: PathBuf,
44 pub(super) resolved_commit_sha: String,
45 pub(super) tree_hash: String,
46 pub(super) entries: Vec<GitTreeEntry>,
47 pub(super) content_hashes: BTreeMap<String, String>,
48 pub(super) path_filters: Vec<String>,
49 pub(super) language_filters: Vec<String>,
50}
51
52pub(super) fn scoped_source_snapshot(
53 registration: &CodeRepositoryRegistration,
54 selector: &CodeRepositorySelector,
55 root: &std::path::Path,
56 ref_selector: &str,
57) -> Result<ScopedSourceSnapshot, CodeIndexError> {
58 let allow_filesystem_ref =
59 registration_allows_filesystem_ref(registration, root, ref_selector)?;
60 scoped_source_snapshot_inner(
61 registration,
62 selector,
63 root,
64 ref_selector,
65 allow_filesystem_ref,
66 )
67}
68
69pub(super) fn scoped_source_snapshot_for_filters(
70 root: &std::path::Path,
71 ref_selector: &str,
72 path_filters: &[String],
73 language_filters: &[String],
74) -> Result<ScopedSourceSnapshot, CodeIndexError> {
75 let registration = CodeRepositoryRegistration {
76 repository_id: "repo".to_owned(),
77 alias: "alias".to_owned(),
78 root_path: root.display().to_string(),
79 path_filters: path_filters.to_vec(),
80 language_filters: language_filters.to_vec(),
81 };
82 let selector = CodeRepositorySelector {
83 repository: "alias".to_owned(),
84 ref_selector: ref_selector.to_owned(),
85 path_filters: Vec::new(),
86 language_filters: Vec::new(),
87 };
88
89 scoped_source_snapshot_inner(®istration, &selector, root, ref_selector, true)
90}
91
92pub(super) fn scoped_source_snapshot_for_registration(
93 registration: &CodeRepositoryRegistration,
94 ref_selector: &str,
95) -> Result<ScopedSourceSnapshot, CodeIndexError> {
96 let root = PathBuf::from(®istration.root_path);
97 let selector = CodeRepositorySelector {
98 repository: registration.alias.clone(),
99 ref_selector: ref_selector.to_owned(),
100 path_filters: Vec::new(),
101 language_filters: Vec::new(),
102 };
103
104 scoped_source_snapshot(registration, &selector, &root, ref_selector)
105}
106
107pub(super) fn scoped_source_snapshot_for_registration_filters(
108 registration: &CodeRepositoryRegistration,
109 ref_selector: &str,
110 path_filters: &[String],
111 language_filters: &[String],
112) -> Result<ScopedSourceSnapshot, CodeIndexError> {
113 let root = PathBuf::from(®istration.root_path);
114 let selector = CodeRepositorySelector {
115 repository: registration.alias.clone(),
116 ref_selector: ref_selector.to_owned(),
117 path_filters: path_filters.to_vec(),
118 language_filters: language_filters.to_vec(),
119 };
120
121 scoped_source_snapshot(registration, &selector, &root, ref_selector)
122}
123
124fn scoped_source_snapshot_inner(
125 registration: &CodeRepositoryRegistration,
126 selector: &CodeRepositorySelector,
127 root: &std::path::Path,
128 ref_selector: &str,
129 allow_filesystem_ref: bool,
130) -> Result<ScopedSourceSnapshot, CodeIndexError> {
131 let filesystem_policy = FileSystemScanPolicy::from_path_and_language_filters(
132 registration
133 .path_filters
134 .iter()
135 .chain(selector.path_filters.iter()),
136 ®istration.language_filters,
137 &selector.language_filters,
138 );
139 let snapshot =
140 source_snapshot_for_scope(root, ref_selector, filesystem_policy, allow_filesystem_ref)?;
141 let source_layout = discover_source_layout(&snapshot.entries);
142 let path_filters = effective_index_path_filters(registration, selector, &source_layout);
143 let language_filters =
144 snapshot::merged_filters(®istration.language_filters, &selector.language_filters);
145 let entries = snapshot
146 .entries
147 .into_iter()
148 .filter(|entry| {
149 selection_exclusion_reason_for_source(
150 &entry.path,
151 registration,
152 selector,
153 &source_layout,
154 snapshot.kind,
155 )
156 .is_none()
157 })
158 .collect::<Vec<_>>();
159 let (resolved_commit_sha, tree_hash, content_hashes) = if snapshot.kind.is_filesystem() {
160 scoped_filesystem_tree_hash(&snapshot.root, &entries, ref_selector)?
161 } else {
162 (
163 snapshot.resolved_commit_sha,
164 snapshot.tree_hash,
165 BTreeMap::new(),
166 )
167 };
168
169 Ok(ScopedSourceSnapshot {
170 kind: snapshot.kind,
171 root: snapshot.root,
172 resolved_commit_sha,
173 tree_hash,
174 entries,
175 content_hashes,
176 path_filters,
177 language_filters,
178 })
179}
180
181fn source_snapshot_for_scope(
182 root: &std::path::Path,
183 ref_selector: &str,
184 filesystem_policy: FileSystemScanPolicy,
185 allow_filesystem_ref: bool,
186) -> Result<RepositorySourceSnapshot, CodeIndexError> {
187 if source_commit_is_filesystem(ref_selector) && allow_filesystem_ref {
188 return filesystem_source_snapshot(root, filesystem_policy);
189 }
190
191 source_snapshot(root, ref_selector, filesystem_policy)
192}
193
194fn registration_allows_filesystem_ref(
195 registration: &CodeRepositoryRegistration,
196 root: &std::path::Path,
197 ref_selector: &str,
198) -> Result<bool, CodeIndexError> {
199 if !source_commit_is_filesystem(ref_selector) {
200 return Ok(false);
201 }
202 if registration.repository_id == filesystem_registration_identity(root)? {
203 return Ok(true);
204 }
205
206 Ok(source_kind(root)?.is_filesystem())
207}
208
209pub(super) fn scoped_filesystem_tree_hash(
210 root: &std::path::Path,
211 entries: &[GitTreeEntry],
212 ref_selector: &str,
213) -> Result<(String, String, BTreeMap<String, String>), CodeIndexError> {
214 let paths = entries
215 .iter()
216 .map(|entry| entry.path.clone())
217 .collect::<Vec<_>>();
218 let content_hashes = filesystem_content_hashes_for_paths(root, &paths)?;
219 let tree_hash = filesystem_tree_hash_from_path_hashes(&content_hashes);
220 if source_commit_is_filesystem(ref_selector) && ref_selector != tree_hash {
221 return Err(CodeIndexError::InvalidInput(format!(
222 "filesystem source snapshot {ref_selector} no longer matches live indexed scope {tree_hash}"
223 )));
224 }
225
226 Ok((tree_hash.clone(), tree_hash, content_hashes))
227}
228
229pub fn preview_repository_scope(
231 registration: &CodeRepositoryRegistration,
232 selector: &CodeRepositorySelector,
233) -> Result<CodeRepositoryScopePreview, CodeIndexError> {
234 let root = PathBuf::from(®istration.root_path);
235 let filesystem_policy = FileSystemScanPolicy::from_path_and_language_filters(
236 registration
237 .path_filters
238 .iter()
239 .chain(selector.path_filters.iter()),
240 ®istration.language_filters,
241 &selector.language_filters,
242 );
243 let allow_filesystem_ref =
244 registration_allows_filesystem_ref(registration, &root, &selector.ref_selector)?;
245 let snapshot = source_snapshot_for_scope(
246 &root,
247 &selector.ref_selector,
248 filesystem_policy,
249 allow_filesystem_ref,
250 )?;
251 let mut selected_byte_count = 0usize;
252 let mut selected_file_count = 0usize;
253 let mut unsupported_file_count = 0usize;
254 let mut generated_or_heavy_file_count = 0usize;
255 let mut expected_degraded_file_count = 0usize;
256 let mut language_distribution = BTreeMap::<String, (usize, usize)>::new();
257 let mut largest_files = Vec::<CodeRepositoryLargestFile>::new();
258 let mut excluded_paths = Vec::<CodeRepositoryExcludedPath>::new();
259
260 let entries = snapshot.entries;
261 let source_layout = discover_source_layout(&entries);
262 let mut selected_entries = Vec::new();
263 for entry in entries {
264 if let Some(reason) = selection_exclusion_reason_for_source(
265 &entry.path,
266 registration,
267 selector,
268 &source_layout,
269 snapshot.kind,
270 ) {
271 if excluded_paths.len() < PREVIEW_MAX_EXCLUDED_PATHS {
272 excluded_paths.push(CodeRepositoryExcludedPath {
273 path: entry.path,
274 reason,
275 });
276 }
277 continue;
278 }
279 let language = preview_language_id(&entry.path);
280 selected_file_count += 1;
281 selected_byte_count = selected_byte_count.saturating_add(entry.byte_count);
282 let bucket = language_distribution
283 .entry(language.to_owned())
284 .or_insert((0, 0));
285 bucket.0 += 1;
286 bucket.1 = bucket.1.saturating_add(entry.byte_count);
287 let is_unsupported = language == "unknown";
288 let is_heavy = entry.byte_count > DEFAULT_TEXT_FILE_BUDGET_BYTES;
289 if is_unsupported {
290 unsupported_file_count += 1;
291 }
292 if is_heavy {
293 generated_or_heavy_file_count += 1;
294 }
295 if is_unsupported || is_heavy {
296 expected_degraded_file_count += 1;
297 }
298 largest_files.push(CodeRepositoryLargestFile {
299 path: entry.path.clone(),
300 byte_count: entry.byte_count,
301 });
302 selected_entries.push(entry);
303 }
304 let (resolved_commit_sha, tree_hash, _) = if snapshot.kind.is_filesystem() {
305 scoped_filesystem_tree_hash(&snapshot.root, &selected_entries, &selector.ref_selector)?
306 } else {
307 (
308 snapshot.resolved_commit_sha,
309 snapshot.tree_hash,
310 BTreeMap::new(),
311 )
312 };
313 largest_files.sort_by(|left, right| {
314 right
315 .byte_count
316 .cmp(&left.byte_count)
317 .then_with(|| left.path.cmp(&right.path))
318 });
319 largest_files.truncate(PREVIEW_MAX_LARGEST_FILES);
320
321 Ok(CodeRepositoryScopePreview {
322 repository_id: registration.repository_id.clone(),
323 alias: registration.alias.clone(),
324 requested_ref: selector.ref_selector.clone(),
325 resolved_commit_sha,
326 tree_hash,
327 selected_file_count,
328 selected_byte_count,
329 unsupported_file_count,
330 generated_or_heavy_file_count,
331 expected_degraded_file_count,
332 language_distribution: language_distribution
333 .into_iter()
334 .map(
335 |(language_id, (file_count, byte_count))| CodeRepositoryLanguagePreview {
336 language_id,
337 file_count,
338 byte_count,
339 },
340 )
341 .collect(),
342 largest_files,
343 excluded_paths,
344 })
345}
346
347pub fn partition_changed_paths_for_selector(
349 registration: &CodeRepositoryRegistration,
350 selector: &CodeRepositorySelector,
351 paths: Vec<String>,
352) -> Result<CodeImpactPathGroups, CodeIndexError> {
353 if paths.is_empty() {
354 return Ok(CodeImpactPathGroups {
355 in_scope_changed_paths: Vec::new(),
356 out_of_scope_changed_paths: Vec::new(),
357 });
358 }
359 let root = PathBuf::from(®istration.root_path);
360 let filesystem_policy = FileSystemScanPolicy::from_path_and_language_filters(
361 registration
362 .path_filters
363 .iter()
364 .chain(selector.path_filters.iter()),
365 ®istration.language_filters,
366 &selector.language_filters,
367 );
368 let (source_layout, source_kind) = if source_commit_is_filesystem(&selector.ref_selector) {
369 let snapshot =
370 scoped_source_snapshot(registration, selector, &root, &selector.ref_selector)?;
371 (discover_source_layout(&snapshot.entries), snapshot.kind)
372 } else {
373 let snapshot = source_snapshot(&root, &selector.ref_selector, filesystem_policy)?;
374 (discover_source_layout(&snapshot.entries), snapshot.kind)
375 };
376 let mut in_scope_changed_paths = Vec::new();
377 let mut out_of_scope_changed_paths = Vec::new();
378 for path in paths {
379 if selection_exclusion_reason_for_source(
380 &path,
381 registration,
382 selector,
383 &source_layout,
384 source_kind,
385 )
386 .is_none()
387 {
388 in_scope_changed_paths.push(path);
389 } else {
390 out_of_scope_changed_paths.push(path);
391 }
392 }
393 in_scope_changed_paths.sort();
394 in_scope_changed_paths.dedup();
395 out_of_scope_changed_paths.sort();
396 out_of_scope_changed_paths.dedup();
397
398 Ok(CodeImpactPathGroups {
399 in_scope_changed_paths,
400 out_of_scope_changed_paths,
401 })
402}
403
404pub(super) fn path_is_selected(
405 path: &str,
406 registration: &CodeRepositoryRegistration,
407 selector: &CodeRepositorySelector,
408) -> bool {
409 selection_exclusion_reason(path, registration, selector).is_none()
410}
411
412pub(super) fn path_is_selected_with_layout(
413 path: &str,
414 registration: &CodeRepositoryRegistration,
415 selector: &CodeRepositorySelector,
416 source_layout: &SourceLayoutDiscovery,
417) -> bool {
418 selection_exclusion_reason_with_layout(path, registration, selector, source_layout).is_none()
419}
420
421pub(super) fn selection_exclusion_reason(
422 path: &str,
423 registration: &CodeRepositoryRegistration,
424 selector: &CodeRepositorySelector,
425) -> Option<String> {
426 selection_exclusion_reason_with_layout(
427 path,
428 registration,
429 selector,
430 &SourceLayoutDiscovery::default(),
431 )
432}
433
434pub(super) fn selection_exclusion_reason_with_layout(
435 path: &str,
436 registration: &CodeRepositoryRegistration,
437 selector: &CodeRepositorySelector,
438 source_layout: &SourceLayoutDiscovery,
439) -> Option<String> {
440 selection_exclusion_reason_for_source(
441 path,
442 registration,
443 selector,
444 source_layout,
445 RepositorySourceKind::Git,
446 )
447}
448
449pub(super) fn selection_exclusion_reason_for_source(
450 path: &str,
451 registration: &CodeRepositoryRegistration,
452 selector: &CodeRepositorySelector,
453 source_layout: &SourceLayoutDiscovery,
454 source_kind: RepositorySourceKind,
455) -> Option<String> {
456 if !path_scope_allows(path, registration, selector)
457 && !source_layout.extends_path_scope(path, registration, selector)
458 {
459 return Some("outside registered/requested path scope".to_owned());
460 }
461 if source_kind.is_filesystem()
462 && filesystem_default_scope_excludes(path, registration, selector)
463 {
464 return Some("outside non-git default source whitelist".to_owned());
465 }
466 if !source_language_filter_allows(path, ®istration.language_filters)
467 || !source_language_filter_allows(path, &selector.language_filters)
468 {
469 return Some("outside registered/requested language scope".to_owned());
470 }
471 if source_default_file_preset_excludes(path)
472 && !dependency_manifest_overrides_default_exclusion(path)
473 && !source_layout.keeps_default_excluded_source(path)
474 && !explicit_path_filter_opts_into_default_file_exclusion(
475 path,
476 registration
477 .path_filters
478 .iter()
479 .chain(selector.path_filters.iter()),
480 )
481 {
482 return Some("excluded by file preset".to_owned());
483 }
484
485 None
486}
487
488#[derive(Debug, Clone, Default, PartialEq, Eq)]
489pub(super) struct SourceLayoutDiscovery {
490 source_roots: BTreeSet<String>,
491}
492
493impl SourceLayoutDiscovery {
494 fn keeps_default_excluded_source(&self, path: &str) -> bool {
495 source_path_has_indexable_content(path)
496 && !path_contains_broad_dependency_segment(path)
497 && self
498 .source_roots
499 .iter()
500 .any(|root| path_matches_filter(path, root))
501 }
502
503 fn extends_path_scope(
504 &self,
505 path: &str,
506 registration: &CodeRepositoryRegistration,
507 selector: &CodeRepositorySelector,
508 ) -> bool {
509 registration_scope_can_discover_source_roots(®istration.path_filters)
510 && selector_path_scope_allows_discovered_root(path, &selector.path_filters)
511 && self.keeps_default_excluded_source(path)
512 }
513}
514
515pub(super) fn discover_source_layout(entries: &[GitTreeEntry]) -> SourceLayoutDiscovery {
516 let mut source_roots = BTreeSet::new();
517 for entry in entries.iter().take(SOURCE_LAYOUT_DISCOVERY_MAX_PATHS) {
518 if !source_path_has_indexable_content(&entry.path)
519 || path_contains_broad_dependency_segment(&entry.path)
520 || source_default_file_preset_excludes(&entry.path)
521 {
522 continue;
523 }
524 for root in source_layout_roots_for_path(&entry.path) {
525 source_roots.insert(root);
526 if source_roots.len() >= SOURCE_LAYOUT_DISCOVERY_MAX_ROOTS {
527 return SourceLayoutDiscovery { source_roots };
528 }
529 }
530 }
531
532 SourceLayoutDiscovery { source_roots }
533}
534
535pub(super) fn effective_index_path_filters(
536 registration: &CodeRepositoryRegistration,
537 selector: &CodeRepositorySelector,
538 source_layout: &SourceLayoutDiscovery,
539) -> Vec<String> {
540 effective_index_path_filters_for_layouts(registration, selector, &[source_layout])
541}
542
543pub(super) fn effective_index_path_filters_for_layouts(
544 registration: &CodeRepositoryRegistration,
545 selector: &CodeRepositorySelector,
546 source_layouts: &[&SourceLayoutDiscovery],
547) -> Vec<String> {
548 let mut filters = merged_path_filters(®istration.path_filters, &selector.path_filters);
549 if !registration_scope_can_discover_source_roots(®istration.path_filters) {
550 return filters;
551 }
552 for source_layout in source_layouts {
553 for root in &source_layout.source_roots {
554 if !selector_filter_allows_root(root, &selector.path_filters) {
555 continue;
556 }
557 push_filter_if_uncovered(&mut filters, root);
558 }
559 }
560
561 filters
562}
563
564fn filesystem_default_scope_excludes(
565 path: &str,
566 registration: &CodeRepositoryRegistration,
567 selector: &CodeRepositorySelector,
568) -> bool {
569 if !registration.path_filters.is_empty() || !selector.path_filters.is_empty() {
570 return false;
571 }
572
573 !filesystem_default_source_allows(path)
574}
575
576fn preview_language_id(path: &str) -> &'static str {
577 language_id(path).unwrap_or_else(|| {
578 dependency_manifest_language_ids(path)
579 .and_then(|languages| languages.first().copied())
580 .unwrap_or("unknown")
581 })
582}
583
584fn path_contains_broad_dependency_segment(path: &str) -> bool {
585 normalize_path_filter(path)
586 .split('/')
587 .any(|segment| matches!(segment, "vendor" | "third_party" | "node_modules"))
588}
589
590fn registration_scope_can_discover_source_roots(filters: &[String]) -> bool {
591 !filters.is_empty()
592 && filters.iter().all(|filter| {
593 let filter = normalize_path_filter(filter);
594 AUTO_SOURCE_SCOPE_FILTERS.contains(&filter)
595 })
596}
597
598fn selector_path_scope_allows_discovered_root(path: &str, filters: &[String]) -> bool {
599 filters.is_empty()
600 || filters
601 .iter()
602 .any(|filter| path_matches_filter(path, filter))
603}
604
605fn selector_filter_allows_root(root: &str, filters: &[String]) -> bool {
606 filters.is_empty()
607 || filters
608 .iter()
609 .any(|filter| path_matches_filter(root, filter) || path_overlaps_filter(root, filter))
610}
611
612fn merged_path_filters(left: &[String], right: &[String]) -> Vec<String> {
613 let mut merged = Vec::new();
614 for filter in left.iter().chain(right.iter()) {
615 let normalized = normalize_path_filter(filter);
616 if !normalized.is_empty() && !merged.iter().any(|existing| existing == normalized) {
617 merged.push(normalized.to_owned());
618 }
619 }
620
621 merged
622}
623
624fn push_filter_if_uncovered(filters: &mut Vec<String>, root: &str) {
625 if filters
626 .iter()
627 .any(|filter| path_filter_covers(filter, root))
628 {
629 return;
630 }
631 filters.retain(|filter| !path_filter_covers(root, filter));
632 filters.push(root.to_owned());
633}
634
635fn path_filter_covers(filter: &str, path: &str) -> bool {
636 let filter = normalize_path_filter(filter);
637 filter == "." || path_matches_filter(path, filter)
638}
639
640fn source_layout_roots_for_path(path: &str) -> Vec<String> {
641 let path = normalize_path_filter(path);
642 let mut roots = Vec::new();
643 if path_matches_filter(path, "include") {
644 push_source_root(&mut roots, "include".to_owned());
645 }
646 for marker in NESTED_SOURCE_MARKERS {
647 if let Some((prefix, _)) = path.split_once(marker) {
648 push_source_root(&mut roots, format!("{prefix}{marker}"));
649 }
650 }
651 for root in STRIPPABLE_SOURCE_ROOTS {
652 if let Some(suffix) = path.strip_prefix(root) {
653 let mut segments = suffix.split('/').filter(|segment| !segment.is_empty());
654 if let Some(first) = segments.next() {
655 push_source_root(&mut roots, format!("{root}{first}"));
656 } else {
657 push_source_root(&mut roots, root.trim_end_matches('/').to_owned());
658 }
659 }
660 }
661 roots
662}
663
664fn push_source_root(roots: &mut Vec<String>, root: String) {
665 let root = root.trim_end_matches('/').to_owned();
666 if !root.is_empty() && !roots.contains(&root) {
667 roots.push(root);
668 }
669}
670
671pub(super) fn path_scope_allows(
672 path: &str,
673 registration: &CodeRepositoryRegistration,
674 selector: &CodeRepositorySelector,
675) -> bool {
676 path_filter_allows(path, ®istration.path_filters)
677 && path_filter_allows(path, &selector.path_filters)
678}
679
680pub(super) fn path_scope_overlaps(
681 path: &str,
682 registration: &CodeRepositoryRegistration,
683 selector: &CodeRepositorySelector,
684) -> bool {
685 path_filter_overlaps(path, ®istration.path_filters)
686 && path_filter_overlaps(path, &selector.path_filters)
687}
688
689fn path_filter_allows(path: &str, filters: &[String]) -> bool {
690 filters.is_empty()
691 || filters
692 .iter()
693 .any(|filter| path_matches_filter(path, filter))
694}
695
696fn path_filter_overlaps(path: &str, filters: &[String]) -> bool {
697 filters.is_empty()
698 || filters
699 .iter()
700 .any(|filter| path_overlaps_filter(path, filter))
701}
702
703fn path_matches_filter(path: &str, filter: &str) -> bool {
704 let path = normalize_path_filter(path);
705 let filter = normalize_path_filter(filter);
706 if filter == "." {
707 return true;
708 }
709 !filter.is_empty() && (path == filter || path.starts_with(&format!("{filter}/")))
710}
711
712fn path_overlaps_filter(path: &str, filter: &str) -> bool {
713 let path = normalize_path_filter(path);
714 let filter = normalize_path_filter(filter);
715 if filter == "." {
716 return true;
717 }
718 !path.is_empty()
719 && !filter.is_empty()
720 && (path == filter
721 || path.starts_with(&format!("{filter}/"))
722 || filter.starts_with(&format!("{path}/")))
723}
724
725fn normalize_path_filter(filter: &str) -> &str {
726 let mut filter = filter.trim_end_matches(['/', '\\']);
727 while let Some(stripped) = filter.strip_prefix("./") {
728 filter = stripped;
729 }
730
731 filter
732}
733
734#[cfg(test)]
735mod tests {
736 use crate::domain::{CodeRepositoryRegistration, CodeRepositorySelector};
737
738 use super::*;
739
740 #[test]
741 fn source_preset_does_not_exclude_tracked_directory_names() {
742 for path in [
743 "build/workflow.yaml",
744 ".cloudbuild/cloudbuild.yaml",
745 ".cid/pipeline.yml",
746 ".build_config/settings.toml",
747 "dist/bundle.js",
748 "frontend/dist/js/components/sidebar.js",
749 "node_modules/pkg/dist/js/core/index.js",
750 "target/generated.rs",
751 "vendor/pkg/lib.rs",
752 "third_party/pkg/lib.rs",
753 ] {
754 assert!(!source_default_file_preset_excludes(path), "{path}");
755 }
756 }
757
758 #[test]
759 fn explicit_default_exclusion_opt_in_normalizes_extension_case() {
760 let registration = CodeRepositoryRegistration::new(
761 "repo",
762 "alias",
763 "/tmp/repo",
764 vec!["assets/logo.SVG".to_owned()],
765 Vec::new(),
766 )
767 .expect("registration should validate");
768 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
769 .expect("selector should validate");
770
771 assert!(path_is_selected(
772 "assets/logo.SVG",
773 ®istration,
774 &selector
775 ));
776 }
777
778 #[test]
779 fn default_file_preset_excludes_dataset_dumps_and_keeps_uv_lock_facts() {
780 let registration = CodeRepositoryRegistration::new(
781 "repo",
782 "alias",
783 "/tmp/repo",
784 vec![".".to_owned()],
785 Vec::new(),
786 )
787 .expect("registration should validate");
788 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
789 .expect("selector should validate");
790
791 assert!(!path_is_selected(
792 ".agent_teams/evals/datasets/swebench-verified-full.jsonl",
793 ®istration,
794 &selector
795 ));
796 assert!(source_default_file_preset_excludes("uv.lock"));
797 assert!(path_is_selected("uv.lock", ®istration, &selector));
798 }
799
800 #[test]
801 fn git_tracked_directory_names_are_selected_without_opt_in() {
802 let registration = CodeRepositoryRegistration::new(
803 "repo",
804 "alias",
805 "/tmp/repo",
806 vec![".".to_owned()],
807 Vec::new(),
808 )
809 .expect("registration should validate");
810 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
811 .expect("selector should validate");
812
813 for path in [
814 "build/workflow.yaml",
815 ".cloudbuild/cloudbuild.yaml",
816 ".cid/pipeline.yml",
817 ".build_config/settings.toml",
818 "external_deps/python_sdk/session_client.py",
819 "packages/ui/src/index.ts",
820 "modules/java_sdk/src/main/java/example/SessionClient.java",
821 "plugins/example.com/nonstandard/session/client.go",
822 "Sources/SwiftSdk/SessionClient.swift",
823 "lib/app/controller.rb",
824 "vendor/pkg/session_client.py",
825 "third_party/pkg/session_client.py",
826 ] {
827 assert!(path_is_selected(path, ®istration, &selector), "{path}");
828 }
829 }
830
831 #[test]
832 fn default_source_preset_keeps_file_extension_opt_in_scoped() {
833 let registration = CodeRepositoryRegistration::new(
834 "repo",
835 "alias",
836 "/tmp/repo",
837 vec![".".to_owned(), "manual.pdf".to_owned()],
838 Vec::new(),
839 )
840 .expect("registration should validate");
841 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
842 .expect("selector should validate");
843
844 assert!(path_is_selected("manual.pdf", ®istration, &selector));
845 assert!(!path_is_selected("other.pdf", ®istration, &selector));
846 }
847}