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};
32use crate::code::common::generated_detection;
33
34const PREVIEW_MAX_EXCLUDED_PATHS: usize = 50;
35const PREVIEW_MAX_LARGEST_FILES: usize = 10;
36const DEFAULT_TEXT_FILE_BUDGET_BYTES: usize = 512 * 1024;
37const SOURCE_LAYOUT_DISCOVERY_MAX_PATHS: usize = 200_000;
38const SOURCE_LAYOUT_DISCOVERY_MAX_ROOTS: usize = 512;
39const AUTO_SOURCE_SCOPE_FILTERS: &[&str] = &[".", "src", "include", "lib", "Sources"];
40
41#[derive(Debug, Clone)]
42pub(in crate::code) struct ScopedSourceSnapshot {
43 pub(in crate::code) kind: RepositorySourceKind,
44 pub(in crate::code) root: PathBuf,
45 pub(in crate::code) resolved_commit_sha: String,
46 pub(in crate::code) tree_hash: String,
47 pub(in crate::code) entries: Vec<GitTreeEntry>,
48 pub(in crate::code) content_hashes: BTreeMap<String, String>,
49 pub(in crate::code) path_filters: Vec<String>,
50 pub(in crate::code) language_filters: Vec<String>,
51}
52
53pub(in crate::code) fn scoped_source_snapshot(
54 registration: &CodeRepositoryRegistration,
55 selector: &CodeRepositorySelector,
56 root: &std::path::Path,
57 ref_selector: &str,
58) -> Result<ScopedSourceSnapshot, CodeIndexError> {
59 let allow_filesystem_ref =
60 registration_allows_filesystem_ref(registration, root, ref_selector)?;
61 scoped_source_snapshot_inner(
62 registration,
63 selector,
64 root,
65 ref_selector,
66 allow_filesystem_ref,
67 )
68}
69
70pub(in crate::code) fn scoped_source_snapshot_for_filters(
71 root: &std::path::Path,
72 ref_selector: &str,
73 path_filters: &[String],
74 language_filters: &[String],
75) -> Result<ScopedSourceSnapshot, CodeIndexError> {
76 let registration = CodeRepositoryRegistration {
77 repository_id: "repo".to_owned(),
78 alias: "alias".to_owned(),
79 root_path: root.display().to_string(),
80 path_filters: path_filters.to_vec(),
81 language_filters: language_filters.to_vec(),
82 };
83 let selector = CodeRepositorySelector {
84 repository: "alias".to_owned(),
85 ref_selector: ref_selector.to_owned(),
86 path_filters: Vec::new(),
87 language_filters: Vec::new(),
88 };
89
90 scoped_source_snapshot_inner(®istration, &selector, root, ref_selector, true)
91}
92
93pub(in crate::code) fn scoped_source_snapshot_for_registration(
94 registration: &CodeRepositoryRegistration,
95 ref_selector: &str,
96) -> Result<ScopedSourceSnapshot, CodeIndexError> {
97 let root = PathBuf::from(®istration.root_path);
98 let selector = CodeRepositorySelector {
99 repository: registration.alias.clone(),
100 ref_selector: ref_selector.to_owned(),
101 path_filters: Vec::new(),
102 language_filters: Vec::new(),
103 };
104
105 scoped_source_snapshot(registration, &selector, &root, ref_selector)
106}
107
108pub(in crate::code) fn scoped_source_snapshot_for_registration_filters(
109 registration: &CodeRepositoryRegistration,
110 ref_selector: &str,
111 path_filters: &[String],
112 language_filters: &[String],
113) -> Result<ScopedSourceSnapshot, CodeIndexError> {
114 let root = PathBuf::from(®istration.root_path);
115 let selector = CodeRepositorySelector {
116 repository: registration.alias.clone(),
117 ref_selector: ref_selector.to_owned(),
118 path_filters: path_filters.to_vec(),
119 language_filters: language_filters.to_vec(),
120 };
121
122 scoped_source_snapshot(registration, &selector, &root, ref_selector)
123}
124
125fn scoped_source_snapshot_inner(
126 registration: &CodeRepositoryRegistration,
127 selector: &CodeRepositorySelector,
128 root: &std::path::Path,
129 ref_selector: &str,
130 allow_filesystem_ref: bool,
131) -> Result<ScopedSourceSnapshot, CodeIndexError> {
132 let filesystem_policy = filesystem_policy_for_selector(registration, selector);
133 let snapshot =
134 source_snapshot_for_scope(root, ref_selector, filesystem_policy, allow_filesystem_ref)?;
135 let source_layout = discover_source_layout(&snapshot.entries);
136 let path_filters = effective_index_path_filters(registration, selector, &source_layout);
137 let language_filters =
138 snapshot::merged_filters(®istration.language_filters, &selector.language_filters);
139 let entries = snapshot
140 .entries
141 .into_iter()
142 .filter(|entry| {
143 selection_exclusion_reason_for_source(
144 &entry.path,
145 registration,
146 selector,
147 &source_layout,
148 snapshot.kind,
149 )
150 .is_none()
151 })
152 .collect::<Vec<_>>();
153 let (resolved_commit_sha, tree_hash, content_hashes) = if snapshot.kind.is_filesystem() {
154 scoped_filesystem_tree_hash(&snapshot.root, &entries, ref_selector)?
155 } else {
156 (
157 snapshot.resolved_commit_sha,
158 snapshot.tree_hash,
159 BTreeMap::new(),
160 )
161 };
162
163 Ok(ScopedSourceSnapshot {
164 kind: snapshot.kind,
165 root: snapshot.root,
166 resolved_commit_sha,
167 tree_hash,
168 entries,
169 content_hashes,
170 path_filters,
171 language_filters,
172 })
173}
174
175fn source_snapshot_for_scope(
176 root: &std::path::Path,
177 ref_selector: &str,
178 filesystem_policy: FileSystemScanPolicy,
179 allow_filesystem_ref: bool,
180) -> Result<RepositorySourceSnapshot, CodeIndexError> {
181 if source_commit_is_filesystem(ref_selector) && allow_filesystem_ref {
182 return filesystem_source_snapshot(root, filesystem_policy);
183 }
184
185 source_snapshot(root, ref_selector, filesystem_policy)
186}
187
188pub(in crate::code) fn filesystem_policy_for_selector(
189 registration: &CodeRepositoryRegistration,
190 selector: &CodeRepositorySelector,
191) -> FileSystemScanPolicy {
192 let filters = intersect_path_filters(®istration.path_filters, &selector.path_filters);
193 let policy = FileSystemScanPolicy::from_path_and_language_filters(
194 filters.as_deref().unwrap_or(&[]),
195 ®istration.language_filters,
196 &selector.language_filters,
197 );
198 if filters.is_none() {
199 policy.with_denied_path_scope()
200 } else {
201 policy
202 }
203}
204
205fn registration_allows_filesystem_ref(
206 registration: &CodeRepositoryRegistration,
207 root: &std::path::Path,
208 ref_selector: &str,
209) -> Result<bool, CodeIndexError> {
210 if !source_commit_is_filesystem(ref_selector) {
211 return Ok(false);
212 }
213 if registration.repository_id == filesystem_registration_identity(root)? {
214 return Ok(true);
215 }
216
217 Ok(source_kind(root)?.is_filesystem())
218}
219
220pub(in crate::code) fn scoped_filesystem_tree_hash(
221 root: &std::path::Path,
222 entries: &[GitTreeEntry],
223 ref_selector: &str,
224) -> Result<(String, String, BTreeMap<String, String>), CodeIndexError> {
225 let paths = entries
226 .iter()
227 .map(|entry| entry.path.clone())
228 .collect::<Vec<_>>();
229 let content_hashes = filesystem_content_hashes_for_paths(root, &paths)?;
230 let tree_hash = filesystem_tree_hash_from_path_hashes(&content_hashes);
231 if source_commit_is_filesystem(ref_selector) && ref_selector != tree_hash {
232 return Err(CodeIndexError::InvalidInput(format!(
233 "filesystem source snapshot {ref_selector} no longer matches live indexed scope {tree_hash}"
234 )));
235 }
236
237 Ok((tree_hash.clone(), tree_hash, content_hashes))
238}
239
240pub fn preview_repository_scope(
242 registration: &CodeRepositoryRegistration,
243 selector: &CodeRepositorySelector,
244) -> Result<CodeRepositoryScopePreview, CodeIndexError> {
245 let root = PathBuf::from(®istration.root_path);
246 let filesystem_policy = filesystem_policy_for_selector(registration, selector);
247 let allow_filesystem_ref =
248 registration_allows_filesystem_ref(registration, &root, &selector.ref_selector)?;
249 let snapshot = source_snapshot_for_scope(
250 &root,
251 &selector.ref_selector,
252 filesystem_policy,
253 allow_filesystem_ref,
254 )?;
255 let mut selected_byte_count = 0usize;
256 let mut selected_file_count = 0usize;
257 let mut unsupported_file_count = 0usize;
258 let mut generated_or_heavy_file_count = 0usize;
259 let mut expected_degraded_file_count = 0usize;
260 let mut language_distribution = BTreeMap::<String, (usize, usize)>::new();
261 let mut largest_files = Vec::<CodeRepositoryLargestFile>::new();
262 let mut excluded_paths = Vec::<CodeRepositoryExcludedPath>::new();
263
264 let entries = snapshot.entries;
265 let source_layout = discover_source_layout(&entries);
266 let mut selected_entries = Vec::new();
267 for entry in entries {
268 if let Some(reason) = selection_exclusion_reason_for_source(
269 &entry.path,
270 registration,
271 selector,
272 &source_layout,
273 snapshot.kind,
274 ) {
275 if excluded_paths.len() < PREVIEW_MAX_EXCLUDED_PATHS {
276 excluded_paths.push(CodeRepositoryExcludedPath {
277 path: entry.path,
278 reason,
279 });
280 }
281 continue;
282 }
283 let language = preview_language_id(&entry.path);
284 selected_file_count += 1;
285 selected_byte_count = selected_byte_count.saturating_add(entry.byte_count);
286 let bucket = language_distribution
287 .entry(language.to_owned())
288 .or_insert((0, 0));
289 bucket.0 += 1;
290 bucket.1 = bucket.1.saturating_add(entry.byte_count);
291 let is_unsupported = language == "unknown";
292 let is_generated = generated_detection::path_has_generated_signal(&entry.path);
293 let is_heavy = entry.byte_count > DEFAULT_TEXT_FILE_BUDGET_BYTES;
294 if is_unsupported {
295 unsupported_file_count += 1;
296 }
297 if is_generated || is_heavy {
298 generated_or_heavy_file_count += 1;
299 }
300 if is_unsupported || is_heavy {
301 expected_degraded_file_count += 1;
302 }
303 largest_files.push(CodeRepositoryLargestFile {
304 path: entry.path.clone(),
305 byte_count: entry.byte_count,
306 });
307 selected_entries.push(entry);
308 }
309 let (resolved_commit_sha, tree_hash, _) = if snapshot.kind.is_filesystem() {
310 scoped_filesystem_tree_hash(&snapshot.root, &selected_entries, &selector.ref_selector)?
311 } else {
312 (
313 snapshot.resolved_commit_sha,
314 snapshot.tree_hash,
315 BTreeMap::new(),
316 )
317 };
318 largest_files.sort_by(|left, right| {
319 right
320 .byte_count
321 .cmp(&left.byte_count)
322 .then_with(|| left.path.cmp(&right.path))
323 });
324 largest_files.truncate(PREVIEW_MAX_LARGEST_FILES);
325
326 Ok(CodeRepositoryScopePreview {
327 repository_id: registration.repository_id.clone(),
328 alias: registration.alias.clone(),
329 requested_ref: selector.ref_selector.clone(),
330 resolved_commit_sha,
331 tree_hash,
332 selected_file_count,
333 selected_byte_count,
334 unsupported_file_count,
335 generated_or_heavy_file_count,
336 expected_degraded_file_count,
337 language_distribution: language_distribution
338 .into_iter()
339 .map(
340 |(language_id, (file_count, byte_count))| CodeRepositoryLanguagePreview {
341 language_id,
342 file_count,
343 byte_count,
344 },
345 )
346 .collect(),
347 largest_files,
348 excluded_paths,
349 })
350}
351
352pub fn partition_changed_paths_for_selector(
354 registration: &CodeRepositoryRegistration,
355 selector: &CodeRepositorySelector,
356 paths: Vec<String>,
357) -> Result<CodeImpactPathGroups, CodeIndexError> {
358 if paths.is_empty() {
359 return Ok(CodeImpactPathGroups {
360 in_scope_changed_paths: Vec::new(),
361 out_of_scope_changed_paths: Vec::new(),
362 });
363 }
364 let root = PathBuf::from(®istration.root_path);
365 let filesystem_policy = filesystem_policy_for_selector(registration, selector);
366 let (source_layout, source_kind) = if source_commit_is_filesystem(&selector.ref_selector) {
367 let snapshot =
368 scoped_source_snapshot(registration, selector, &root, &selector.ref_selector)?;
369 (discover_source_layout(&snapshot.entries), snapshot.kind)
370 } else {
371 let snapshot = source_snapshot(&root, &selector.ref_selector, filesystem_policy)?;
372 (discover_source_layout(&snapshot.entries), snapshot.kind)
373 };
374 let mut in_scope_changed_paths = Vec::new();
375 let mut out_of_scope_changed_paths = Vec::new();
376 for path in paths {
377 if selection_exclusion_reason_for_source(
378 &path,
379 registration,
380 selector,
381 &source_layout,
382 source_kind,
383 )
384 .is_none()
385 {
386 in_scope_changed_paths.push(path);
387 } else {
388 out_of_scope_changed_paths.push(path);
389 }
390 }
391 in_scope_changed_paths.sort();
392 in_scope_changed_paths.dedup();
393 out_of_scope_changed_paths.sort();
394 out_of_scope_changed_paths.dedup();
395
396 Ok(CodeImpactPathGroups {
397 in_scope_changed_paths,
398 out_of_scope_changed_paths,
399 })
400}
401
402#[cfg(test)]
403pub(in crate::code) fn path_is_selected(
404 path: &str,
405 registration: &CodeRepositoryRegistration,
406 selector: &CodeRepositorySelector,
407) -> bool {
408 selection_exclusion_reason(path, registration, selector).is_none()
409}
410
411pub(in crate::code) fn path_is_selected_with_layout(
412 path: &str,
413 registration: &CodeRepositoryRegistration,
414 selector: &CodeRepositorySelector,
415 source_layout: &SourceLayoutDiscovery,
416) -> bool {
417 selection_exclusion_reason_with_layout(path, registration, selector, source_layout).is_none()
418}
419
420#[cfg(test)]
421pub(in crate::code) 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(in crate::code) 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(in crate::code) 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(in crate::code) 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(in crate::code) 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(in crate::code) 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(in crate::code) 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
564pub(in crate::code) fn effective_path_filter_intersections_for_layouts(
565 registration: &CodeRepositoryRegistration,
566 selector: &CodeRepositorySelector,
567 source_layouts: &[&SourceLayoutDiscovery],
568) -> Option<Vec<String>> {
569 let mut filters = normalized_path_filters(®istration.path_filters);
570 if registration_scope_can_discover_source_roots(®istration.path_filters) {
571 for source_layout in source_layouts {
572 for root in &source_layout.source_roots {
573 if selector_filter_allows_root(root, &selector.path_filters) {
574 push_filter_if_uncovered(&mut filters, root);
575 }
576 }
577 }
578 }
579
580 intersect_path_filters(&filters, &selector.path_filters)
581}
582
583fn filesystem_default_scope_excludes(
584 path: &str,
585 registration: &CodeRepositoryRegistration,
586 selector: &CodeRepositorySelector,
587) -> bool {
588 if !registration.path_filters.is_empty() || !selector.path_filters.is_empty() {
589 return false;
590 }
591
592 !filesystem_default_source_allows(path)
593}
594
595fn preview_language_id(path: &str) -> &'static str {
596 language_id(path).unwrap_or_else(|| {
597 dependency_manifest_language_ids(path)
598 .and_then(|languages| languages.first().copied())
599 .unwrap_or("unknown")
600 })
601}
602
603fn path_contains_broad_dependency_segment(path: &str) -> bool {
604 normalize_path_filter(path)
605 .split('/')
606 .any(|segment| matches!(segment, "vendor" | "third_party" | "node_modules"))
607}
608
609fn registration_scope_can_discover_source_roots(filters: &[String]) -> bool {
610 !filters.is_empty()
611 && filters.iter().all(|filter| {
612 let filter = normalize_path_filter(filter);
613 AUTO_SOURCE_SCOPE_FILTERS.contains(&filter)
614 })
615}
616
617fn selector_path_scope_allows_discovered_root(path: &str, filters: &[String]) -> bool {
618 filters.is_empty()
619 || filters
620 .iter()
621 .any(|filter| path_matches_filter(path, filter))
622}
623
624fn selector_filter_allows_root(root: &str, filters: &[String]) -> bool {
625 filters.is_empty()
626 || filters
627 .iter()
628 .any(|filter| path_matches_filter(root, filter) || path_overlaps_filter(root, filter))
629}
630
631fn merged_path_filters(left: &[String], right: &[String]) -> Vec<String> {
632 let mut merged = Vec::new();
633 for filter in left.iter().chain(right.iter()) {
634 let normalized = normalize_path_filter(filter);
635 if !normalized.is_empty() && !merged.iter().any(|existing| existing == normalized) {
636 merged.push(normalized.to_owned());
637 }
638 }
639
640 merged
641}
642
643pub(in crate::code) fn intersect_path_filters(
644 left: &[String],
645 right: &[String],
646) -> Option<Vec<String>> {
647 let left = normalized_path_filters(left);
648 let right = normalized_path_filters(right);
649 if left.is_empty() {
650 return Some(right);
651 }
652 if right.is_empty() {
653 return Some(left);
654 }
655
656 let mut intersections = Vec::new();
657 for left_filter in &left {
658 for right_filter in &right {
659 if path_filter_covers(left_filter, right_filter) {
660 push_filter_if_missing(&mut intersections, right_filter);
661 } else if path_filter_covers(right_filter, left_filter) {
662 push_filter_if_missing(&mut intersections, left_filter);
663 }
664 }
665 }
666
667 (!intersections.is_empty()).then_some(intersections)
668}
669
670pub(in crate::code) fn submodule_child_scope_filters(
671 path: &str,
672 registration: &CodeRepositoryRegistration,
673 selector: &CodeRepositorySelector,
674) -> Option<Vec<String>> {
675 let filters = intersect_path_filters(®istration.path_filters, &selector.path_filters)?;
676 submodule_child_scope_filters_from_filters(path, &filters)
677}
678
679pub(in crate::code) fn submodule_child_scope_filters_from_filters(
680 path: &str,
681 filters: &[String],
682) -> Option<Vec<String>> {
683 if filters.is_empty() {
684 return Some(Vec::new());
685 }
686 let path = normalize_scope_path(path);
687 if path.is_empty() {
688 return None;
689 }
690 let child_prefix = format!("{path}/");
691 let mut child_filters = Vec::new();
692 let mut parent_scope_covers_submodule = false;
693 for filter in filters {
694 let filter = normalize_scope_path(filter);
695 if filter.is_empty()
696 || filter == "."
697 || filter == path
698 || path.starts_with(&format!("{filter}/"))
699 {
700 parent_scope_covers_submodule = true;
701 continue;
702 }
703 if let Some(child_filter) = filter.strip_prefix(&child_prefix)
704 && !child_filter.is_empty()
705 {
706 child_filters.push(child_filter.to_owned());
707 }
708 }
709 if parent_scope_covers_submodule {
710 return Some(Vec::new());
711 }
712 if child_filters.is_empty() && !parent_scope_covers_submodule {
713 return None;
714 }
715 child_filters.sort();
716 child_filters.dedup();
717
718 Some(child_filters)
719}
720
721fn normalize_scope_path(path: &str) -> String {
722 path.replace('\\', "/")
723 .trim_start_matches("./")
724 .trim_matches('/')
725 .to_owned()
726}
727
728fn normalized_path_filters(filters: &[String]) -> Vec<String> {
729 filters
730 .iter()
731 .map(|filter| normalize_path_filter(filter).to_owned())
732 .filter(|filter| !filter.is_empty())
733 .collect()
734}
735
736fn push_filter_if_missing(filters: &mut Vec<String>, filter: &str) {
737 if !filters.iter().any(|existing| existing == filter) {
738 filters.push(filter.to_owned());
739 }
740}
741
742fn push_filter_if_uncovered(filters: &mut Vec<String>, root: &str) {
743 if filters
744 .iter()
745 .any(|filter| path_filter_covers(filter, root))
746 {
747 return;
748 }
749 filters.retain(|filter| !path_filter_covers(root, filter));
750 filters.push(root.to_owned());
751}
752
753fn path_filter_covers(filter: &str, path: &str) -> bool {
754 let filter = normalize_path_filter(filter);
755 filter == "." || path_matches_filter(path, filter)
756}
757
758fn source_layout_roots_for_path(path: &str) -> Vec<String> {
759 let path = normalize_path_filter(path);
760 let mut roots = Vec::new();
761 if path_matches_filter(path, "include") {
762 push_source_root(&mut roots, "include".to_owned());
763 }
764 for marker in NESTED_SOURCE_MARKERS {
765 if let Some((prefix, _)) = path.split_once(marker) {
766 push_source_root(&mut roots, format!("{prefix}{marker}"));
767 }
768 }
769 for root in STRIPPABLE_SOURCE_ROOTS {
770 if let Some(suffix) = path.strip_prefix(root) {
771 let mut segments = suffix.split('/').filter(|segment| !segment.is_empty());
772 if let Some(first) = segments.next() {
773 push_source_root(&mut roots, format!("{root}{first}"));
774 } else {
775 push_source_root(&mut roots, root.trim_end_matches('/').to_owned());
776 }
777 }
778 }
779 roots
780}
781
782fn push_source_root(roots: &mut Vec<String>, root: String) {
783 let root = root.trim_end_matches('/').to_owned();
784 if !root.is_empty() && !roots.contains(&root) {
785 roots.push(root);
786 }
787}
788
789pub(in crate::code) fn path_scope_allows(
790 path: &str,
791 registration: &CodeRepositoryRegistration,
792 selector: &CodeRepositorySelector,
793) -> bool {
794 path_filter_allows(path, ®istration.path_filters)
795 && path_filter_allows(path, &selector.path_filters)
796}
797
798pub(in crate::code) fn path_scope_overlaps(
799 path: &str,
800 registration: &CodeRepositoryRegistration,
801 selector: &CodeRepositorySelector,
802) -> bool {
803 path_filter_overlaps(path, ®istration.path_filters)
804 && path_filter_overlaps(path, &selector.path_filters)
805}
806
807pub(in crate::code) fn path_overlaps_any_filter(path: &str, filters: &[String]) -> bool {
808 path_filter_overlaps(path, filters)
809}
810
811fn path_filter_allows(path: &str, filters: &[String]) -> bool {
812 filters.is_empty()
813 || filters
814 .iter()
815 .any(|filter| path_matches_filter(path, filter))
816}
817
818fn path_filter_overlaps(path: &str, filters: &[String]) -> bool {
819 filters.is_empty()
820 || filters
821 .iter()
822 .any(|filter| path_overlaps_filter(path, filter))
823}
824
825fn path_matches_filter(path: &str, filter: &str) -> bool {
826 let path = normalize_path_filter(path);
827 let filter = normalize_path_filter(filter);
828 if filter == "." {
829 return true;
830 }
831 !filter.is_empty() && (path == filter || path.starts_with(&format!("{filter}/")))
832}
833
834fn path_overlaps_filter(path: &str, filter: &str) -> bool {
835 let path = normalize_path_filter(path);
836 let filter = normalize_path_filter(filter);
837 if filter == "." {
838 return true;
839 }
840 !path.is_empty()
841 && !filter.is_empty()
842 && (path == filter
843 || path.starts_with(&format!("{filter}/"))
844 || filter.starts_with(&format!("{path}/")))
845}
846
847fn normalize_path_filter(filter: &str) -> &str {
848 let mut filter = filter.trim_end_matches(['/', '\\']);
849 while let Some(stripped) = filter.strip_prefix("./") {
850 filter = stripped;
851 }
852
853 filter
854}
855
856#[cfg(test)]
857mod tests {
858 use crate::domain::{CodeRepositoryRegistration, CodeRepositorySelector};
859
860 use super::*;
861
862 #[test]
863 fn source_preset_does_not_exclude_tracked_directory_names() {
864 for path in [
865 "build/workflow.yaml",
866 ".cloudbuild/cloudbuild.yaml",
867 ".cid/pipeline.yml",
868 ".build_config/settings.toml",
869 "dist/bundle.js",
870 "frontend/dist/js/components/sidebar.js",
871 "node_modules/pkg/dist/js/core/index.js",
872 "target/generated.rs",
873 "vendor/pkg/lib.rs",
874 "third_party/pkg/lib.rs",
875 ] {
876 assert!(!source_default_file_preset_excludes(path), "{path}");
877 }
878 }
879
880 #[test]
881 fn explicit_default_exclusion_opt_in_normalizes_extension_case() {
882 let registration = CodeRepositoryRegistration::new(
883 "repo",
884 "alias",
885 "/tmp/repo",
886 vec!["assets/logo.SVG".to_owned()],
887 Vec::new(),
888 )
889 .expect("registration should validate");
890 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
891 .expect("selector should validate");
892
893 assert!(path_is_selected(
894 "assets/logo.SVG",
895 ®istration,
896 &selector
897 ));
898 }
899
900 #[test]
901 fn default_file_preset_excludes_dataset_dumps_and_keeps_uv_lock_facts() {
902 let registration = CodeRepositoryRegistration::new(
903 "repo",
904 "alias",
905 "/tmp/repo",
906 vec![".".to_owned()],
907 Vec::new(),
908 )
909 .expect("registration should validate");
910 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
911 .expect("selector should validate");
912
913 assert!(!path_is_selected(
914 ".agent_teams/evals/datasets/swebench-verified-full.jsonl",
915 ®istration,
916 &selector
917 ));
918 assert!(source_default_file_preset_excludes("uv.lock"));
919 assert!(path_is_selected("uv.lock", ®istration, &selector));
920 }
921
922 #[test]
923 fn git_tracked_directory_names_are_selected_without_opt_in() {
924 let registration = CodeRepositoryRegistration::new(
925 "repo",
926 "alias",
927 "/tmp/repo",
928 vec![".".to_owned()],
929 Vec::new(),
930 )
931 .expect("registration should validate");
932 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
933 .expect("selector should validate");
934
935 for path in [
936 "build/workflow.yaml",
937 ".cloudbuild/cloudbuild.yaml",
938 ".cid/pipeline.yml",
939 ".build_config/settings.toml",
940 "external_deps/python_sdk/session_client.py",
941 "packages/ui/src/index.ts",
942 "modules/java_sdk/src/main/java/example/SessionClient.java",
943 "plugins/example.com/nonstandard/session/client.go",
944 "Sources/SwiftSdk/SessionClient.swift",
945 "lib/app/controller.rb",
946 "vendor/pkg/session_client.py",
947 "third_party/pkg/session_client.py",
948 ] {
949 assert!(path_is_selected(path, ®istration, &selector), "{path}");
950 }
951 }
952
953 #[test]
954 fn default_source_preset_keeps_file_extension_opt_in_scoped() {
955 let registration = CodeRepositoryRegistration::new(
956 "repo",
957 "alias",
958 "/tmp/repo",
959 vec![".".to_owned(), "manual.pdf".to_owned()],
960 Vec::new(),
961 )
962 .expect("registration should validate");
963 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
964 .expect("selector should validate");
965
966 assert!(path_is_selected("manual.pdf", ®istration, &selector));
967 assert!(!path_is_selected("other.pdf", ®istration, &selector));
968 }
969}