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(in crate::code) struct ScopedSourceSnapshot {
42 pub(in crate::code) kind: RepositorySourceKind,
43 pub(in crate::code) root: PathBuf,
44 pub(in crate::code) resolved_commit_sha: String,
45 pub(in crate::code) tree_hash: String,
46 pub(in crate::code) entries: Vec<GitTreeEntry>,
47 pub(in crate::code) content_hashes: BTreeMap<String, String>,
48 pub(in crate::code) path_filters: Vec<String>,
49 pub(in crate::code) language_filters: Vec<String>,
50}
51
52pub(in crate::code) 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(in crate::code) 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(in crate::code) 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(in crate::code) 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 = filesystem_policy_for_selector(registration, selector);
132 let snapshot =
133 source_snapshot_for_scope(root, ref_selector, filesystem_policy, allow_filesystem_ref)?;
134 let source_layout = discover_source_layout(&snapshot.entries);
135 let path_filters = effective_index_path_filters(registration, selector, &source_layout);
136 let language_filters =
137 snapshot::merged_filters(®istration.language_filters, &selector.language_filters);
138 let entries = snapshot
139 .entries
140 .into_iter()
141 .filter(|entry| {
142 selection_exclusion_reason_for_source(
143 &entry.path,
144 registration,
145 selector,
146 &source_layout,
147 snapshot.kind,
148 )
149 .is_none()
150 })
151 .collect::<Vec<_>>();
152 let (resolved_commit_sha, tree_hash, content_hashes) = if snapshot.kind.is_filesystem() {
153 scoped_filesystem_tree_hash(&snapshot.root, &entries, ref_selector)?
154 } else {
155 (
156 snapshot.resolved_commit_sha,
157 snapshot.tree_hash,
158 BTreeMap::new(),
159 )
160 };
161
162 Ok(ScopedSourceSnapshot {
163 kind: snapshot.kind,
164 root: snapshot.root,
165 resolved_commit_sha,
166 tree_hash,
167 entries,
168 content_hashes,
169 path_filters,
170 language_filters,
171 })
172}
173
174fn source_snapshot_for_scope(
175 root: &std::path::Path,
176 ref_selector: &str,
177 filesystem_policy: FileSystemScanPolicy,
178 allow_filesystem_ref: bool,
179) -> Result<RepositorySourceSnapshot, CodeIndexError> {
180 if source_commit_is_filesystem(ref_selector) && allow_filesystem_ref {
181 return filesystem_source_snapshot(root, filesystem_policy);
182 }
183
184 source_snapshot(root, ref_selector, filesystem_policy)
185}
186
187pub(in crate::code) fn filesystem_policy_for_selector(
188 registration: &CodeRepositoryRegistration,
189 selector: &CodeRepositorySelector,
190) -> FileSystemScanPolicy {
191 let filters = intersect_path_filters(®istration.path_filters, &selector.path_filters);
192 let policy = FileSystemScanPolicy::from_path_and_language_filters(
193 filters.as_deref().unwrap_or(&[]),
194 ®istration.language_filters,
195 &selector.language_filters,
196 );
197 if filters.is_none() {
198 policy.with_denied_path_scope()
199 } else {
200 policy
201 }
202}
203
204fn registration_allows_filesystem_ref(
205 registration: &CodeRepositoryRegistration,
206 root: &std::path::Path,
207 ref_selector: &str,
208) -> Result<bool, CodeIndexError> {
209 if !source_commit_is_filesystem(ref_selector) {
210 return Ok(false);
211 }
212 if registration.repository_id == filesystem_registration_identity(root)? {
213 return Ok(true);
214 }
215
216 Ok(source_kind(root)?.is_filesystem())
217}
218
219pub(in crate::code) fn scoped_filesystem_tree_hash(
220 root: &std::path::Path,
221 entries: &[GitTreeEntry],
222 ref_selector: &str,
223) -> Result<(String, String, BTreeMap<String, String>), CodeIndexError> {
224 let paths = entries
225 .iter()
226 .map(|entry| entry.path.clone())
227 .collect::<Vec<_>>();
228 let content_hashes = filesystem_content_hashes_for_paths(root, &paths)?;
229 let tree_hash = filesystem_tree_hash_from_path_hashes(&content_hashes);
230 if source_commit_is_filesystem(ref_selector) && ref_selector != tree_hash {
231 return Err(CodeIndexError::InvalidInput(format!(
232 "filesystem source snapshot {ref_selector} no longer matches live indexed scope {tree_hash}"
233 )));
234 }
235
236 Ok((tree_hash.clone(), tree_hash, content_hashes))
237}
238
239pub fn preview_repository_scope(
241 registration: &CodeRepositoryRegistration,
242 selector: &CodeRepositorySelector,
243) -> Result<CodeRepositoryScopePreview, CodeIndexError> {
244 let root = PathBuf::from(®istration.root_path);
245 let filesystem_policy = filesystem_policy_for_selector(registration, selector);
246 let allow_filesystem_ref =
247 registration_allows_filesystem_ref(registration, &root, &selector.ref_selector)?;
248 let snapshot = source_snapshot_for_scope(
249 &root,
250 &selector.ref_selector,
251 filesystem_policy,
252 allow_filesystem_ref,
253 )?;
254 let mut selected_byte_count = 0usize;
255 let mut selected_file_count = 0usize;
256 let mut unsupported_file_count = 0usize;
257 let mut generated_or_heavy_file_count = 0usize;
258 let mut expected_degraded_file_count = 0usize;
259 let mut language_distribution = BTreeMap::<String, (usize, usize)>::new();
260 let mut largest_files = Vec::<CodeRepositoryLargestFile>::new();
261 let mut excluded_paths = Vec::<CodeRepositoryExcludedPath>::new();
262
263 let entries = snapshot.entries;
264 let source_layout = discover_source_layout(&entries);
265 let mut selected_entries = Vec::new();
266 for entry in entries {
267 if let Some(reason) = selection_exclusion_reason_for_source(
268 &entry.path,
269 registration,
270 selector,
271 &source_layout,
272 snapshot.kind,
273 ) {
274 if excluded_paths.len() < PREVIEW_MAX_EXCLUDED_PATHS {
275 excluded_paths.push(CodeRepositoryExcludedPath {
276 path: entry.path,
277 reason,
278 });
279 }
280 continue;
281 }
282 let language = preview_language_id(&entry.path);
283 selected_file_count += 1;
284 selected_byte_count = selected_byte_count.saturating_add(entry.byte_count);
285 let bucket = language_distribution
286 .entry(language.to_owned())
287 .or_insert((0, 0));
288 bucket.0 += 1;
289 bucket.1 = bucket.1.saturating_add(entry.byte_count);
290 let is_unsupported = language == "unknown";
291 let is_heavy = entry.byte_count > DEFAULT_TEXT_FILE_BUDGET_BYTES;
292 if is_unsupported {
293 unsupported_file_count += 1;
294 }
295 if is_heavy {
296 generated_or_heavy_file_count += 1;
297 }
298 if is_unsupported || is_heavy {
299 expected_degraded_file_count += 1;
300 }
301 largest_files.push(CodeRepositoryLargestFile {
302 path: entry.path.clone(),
303 byte_count: entry.byte_count,
304 });
305 selected_entries.push(entry);
306 }
307 let (resolved_commit_sha, tree_hash, _) = if snapshot.kind.is_filesystem() {
308 scoped_filesystem_tree_hash(&snapshot.root, &selected_entries, &selector.ref_selector)?
309 } else {
310 (
311 snapshot.resolved_commit_sha,
312 snapshot.tree_hash,
313 BTreeMap::new(),
314 )
315 };
316 largest_files.sort_by(|left, right| {
317 right
318 .byte_count
319 .cmp(&left.byte_count)
320 .then_with(|| left.path.cmp(&right.path))
321 });
322 largest_files.truncate(PREVIEW_MAX_LARGEST_FILES);
323
324 Ok(CodeRepositoryScopePreview {
325 repository_id: registration.repository_id.clone(),
326 alias: registration.alias.clone(),
327 requested_ref: selector.ref_selector.clone(),
328 resolved_commit_sha,
329 tree_hash,
330 selected_file_count,
331 selected_byte_count,
332 unsupported_file_count,
333 generated_or_heavy_file_count,
334 expected_degraded_file_count,
335 language_distribution: language_distribution
336 .into_iter()
337 .map(
338 |(language_id, (file_count, byte_count))| CodeRepositoryLanguagePreview {
339 language_id,
340 file_count,
341 byte_count,
342 },
343 )
344 .collect(),
345 largest_files,
346 excluded_paths,
347 })
348}
349
350pub fn partition_changed_paths_for_selector(
352 registration: &CodeRepositoryRegistration,
353 selector: &CodeRepositorySelector,
354 paths: Vec<String>,
355) -> Result<CodeImpactPathGroups, CodeIndexError> {
356 if paths.is_empty() {
357 return Ok(CodeImpactPathGroups {
358 in_scope_changed_paths: Vec::new(),
359 out_of_scope_changed_paths: Vec::new(),
360 });
361 }
362 let root = PathBuf::from(®istration.root_path);
363 let filesystem_policy = filesystem_policy_for_selector(registration, selector);
364 let (source_layout, source_kind) = if source_commit_is_filesystem(&selector.ref_selector) {
365 let snapshot =
366 scoped_source_snapshot(registration, selector, &root, &selector.ref_selector)?;
367 (discover_source_layout(&snapshot.entries), snapshot.kind)
368 } else {
369 let snapshot = source_snapshot(&root, &selector.ref_selector, filesystem_policy)?;
370 (discover_source_layout(&snapshot.entries), snapshot.kind)
371 };
372 let mut in_scope_changed_paths = Vec::new();
373 let mut out_of_scope_changed_paths = Vec::new();
374 for path in paths {
375 if selection_exclusion_reason_for_source(
376 &path,
377 registration,
378 selector,
379 &source_layout,
380 source_kind,
381 )
382 .is_none()
383 {
384 in_scope_changed_paths.push(path);
385 } else {
386 out_of_scope_changed_paths.push(path);
387 }
388 }
389 in_scope_changed_paths.sort();
390 in_scope_changed_paths.dedup();
391 out_of_scope_changed_paths.sort();
392 out_of_scope_changed_paths.dedup();
393
394 Ok(CodeImpactPathGroups {
395 in_scope_changed_paths,
396 out_of_scope_changed_paths,
397 })
398}
399
400#[cfg(test)]
401pub(in crate::code) fn path_is_selected(
402 path: &str,
403 registration: &CodeRepositoryRegistration,
404 selector: &CodeRepositorySelector,
405) -> bool {
406 selection_exclusion_reason(path, registration, selector).is_none()
407}
408
409pub(in crate::code) fn path_is_selected_with_layout(
410 path: &str,
411 registration: &CodeRepositoryRegistration,
412 selector: &CodeRepositorySelector,
413 source_layout: &SourceLayoutDiscovery,
414) -> bool {
415 selection_exclusion_reason_with_layout(path, registration, selector, source_layout).is_none()
416}
417
418#[cfg(test)]
419pub(in crate::code) fn selection_exclusion_reason(
420 path: &str,
421 registration: &CodeRepositoryRegistration,
422 selector: &CodeRepositorySelector,
423) -> Option<String> {
424 selection_exclusion_reason_with_layout(
425 path,
426 registration,
427 selector,
428 &SourceLayoutDiscovery::default(),
429 )
430}
431
432pub(in crate::code) fn selection_exclusion_reason_with_layout(
433 path: &str,
434 registration: &CodeRepositoryRegistration,
435 selector: &CodeRepositorySelector,
436 source_layout: &SourceLayoutDiscovery,
437) -> Option<String> {
438 selection_exclusion_reason_for_source(
439 path,
440 registration,
441 selector,
442 source_layout,
443 RepositorySourceKind::Git,
444 )
445}
446
447pub(in crate::code) fn selection_exclusion_reason_for_source(
448 path: &str,
449 registration: &CodeRepositoryRegistration,
450 selector: &CodeRepositorySelector,
451 source_layout: &SourceLayoutDiscovery,
452 source_kind: RepositorySourceKind,
453) -> Option<String> {
454 if !path_scope_allows(path, registration, selector)
455 && !source_layout.extends_path_scope(path, registration, selector)
456 {
457 return Some("outside registered/requested path scope".to_owned());
458 }
459 if source_kind.is_filesystem()
460 && filesystem_default_scope_excludes(path, registration, selector)
461 {
462 return Some("outside non-git default source whitelist".to_owned());
463 }
464 if !source_language_filter_allows(path, ®istration.language_filters)
465 || !source_language_filter_allows(path, &selector.language_filters)
466 {
467 return Some("outside registered/requested language scope".to_owned());
468 }
469 if source_default_file_preset_excludes(path)
470 && !dependency_manifest_overrides_default_exclusion(path)
471 && !source_layout.keeps_default_excluded_source(path)
472 && !explicit_path_filter_opts_into_default_file_exclusion(
473 path,
474 registration
475 .path_filters
476 .iter()
477 .chain(selector.path_filters.iter()),
478 )
479 {
480 return Some("excluded by file preset".to_owned());
481 }
482
483 None
484}
485
486#[derive(Debug, Clone, Default, PartialEq, Eq)]
487pub(in crate::code) struct SourceLayoutDiscovery {
488 source_roots: BTreeSet<String>,
489}
490
491impl SourceLayoutDiscovery {
492 fn keeps_default_excluded_source(&self, path: &str) -> bool {
493 source_path_has_indexable_content(path)
494 && !path_contains_broad_dependency_segment(path)
495 && self
496 .source_roots
497 .iter()
498 .any(|root| path_matches_filter(path, root))
499 }
500
501 fn extends_path_scope(
502 &self,
503 path: &str,
504 registration: &CodeRepositoryRegistration,
505 selector: &CodeRepositorySelector,
506 ) -> bool {
507 registration_scope_can_discover_source_roots(®istration.path_filters)
508 && selector_path_scope_allows_discovered_root(path, &selector.path_filters)
509 && self.keeps_default_excluded_source(path)
510 }
511}
512
513pub(in crate::code) fn discover_source_layout(entries: &[GitTreeEntry]) -> SourceLayoutDiscovery {
514 let mut source_roots = BTreeSet::new();
515 for entry in entries.iter().take(SOURCE_LAYOUT_DISCOVERY_MAX_PATHS) {
516 if !source_path_has_indexable_content(&entry.path)
517 || path_contains_broad_dependency_segment(&entry.path)
518 || source_default_file_preset_excludes(&entry.path)
519 {
520 continue;
521 }
522 for root in source_layout_roots_for_path(&entry.path) {
523 source_roots.insert(root);
524 if source_roots.len() >= SOURCE_LAYOUT_DISCOVERY_MAX_ROOTS {
525 return SourceLayoutDiscovery { source_roots };
526 }
527 }
528 }
529
530 SourceLayoutDiscovery { source_roots }
531}
532
533pub(in crate::code) fn effective_index_path_filters(
534 registration: &CodeRepositoryRegistration,
535 selector: &CodeRepositorySelector,
536 source_layout: &SourceLayoutDiscovery,
537) -> Vec<String> {
538 effective_index_path_filters_for_layouts(registration, selector, &[source_layout])
539}
540
541pub(in crate::code) fn effective_index_path_filters_for_layouts(
542 registration: &CodeRepositoryRegistration,
543 selector: &CodeRepositorySelector,
544 source_layouts: &[&SourceLayoutDiscovery],
545) -> Vec<String> {
546 let mut filters = merged_path_filters(®istration.path_filters, &selector.path_filters);
547 if !registration_scope_can_discover_source_roots(®istration.path_filters) {
548 return filters;
549 }
550 for source_layout in source_layouts {
551 for root in &source_layout.source_roots {
552 if !selector_filter_allows_root(root, &selector.path_filters) {
553 continue;
554 }
555 push_filter_if_uncovered(&mut filters, root);
556 }
557 }
558
559 filters
560}
561
562pub(in crate::code) fn effective_path_filter_intersections_for_layouts(
563 registration: &CodeRepositoryRegistration,
564 selector: &CodeRepositorySelector,
565 source_layouts: &[&SourceLayoutDiscovery],
566) -> Option<Vec<String>> {
567 let mut filters = normalized_path_filters(®istration.path_filters);
568 if registration_scope_can_discover_source_roots(®istration.path_filters) {
569 for source_layout in source_layouts {
570 for root in &source_layout.source_roots {
571 if selector_filter_allows_root(root, &selector.path_filters) {
572 push_filter_if_uncovered(&mut filters, root);
573 }
574 }
575 }
576 }
577
578 intersect_path_filters(&filters, &selector.path_filters)
579}
580
581fn filesystem_default_scope_excludes(
582 path: &str,
583 registration: &CodeRepositoryRegistration,
584 selector: &CodeRepositorySelector,
585) -> bool {
586 if !registration.path_filters.is_empty() || !selector.path_filters.is_empty() {
587 return false;
588 }
589
590 !filesystem_default_source_allows(path)
591}
592
593fn preview_language_id(path: &str) -> &'static str {
594 language_id(path).unwrap_or_else(|| {
595 dependency_manifest_language_ids(path)
596 .and_then(|languages| languages.first().copied())
597 .unwrap_or("unknown")
598 })
599}
600
601fn path_contains_broad_dependency_segment(path: &str) -> bool {
602 normalize_path_filter(path)
603 .split('/')
604 .any(|segment| matches!(segment, "vendor" | "third_party" | "node_modules"))
605}
606
607fn registration_scope_can_discover_source_roots(filters: &[String]) -> bool {
608 !filters.is_empty()
609 && filters.iter().all(|filter| {
610 let filter = normalize_path_filter(filter);
611 AUTO_SOURCE_SCOPE_FILTERS.contains(&filter)
612 })
613}
614
615fn selector_path_scope_allows_discovered_root(path: &str, filters: &[String]) -> bool {
616 filters.is_empty()
617 || filters
618 .iter()
619 .any(|filter| path_matches_filter(path, filter))
620}
621
622fn selector_filter_allows_root(root: &str, filters: &[String]) -> bool {
623 filters.is_empty()
624 || filters
625 .iter()
626 .any(|filter| path_matches_filter(root, filter) || path_overlaps_filter(root, filter))
627}
628
629fn merged_path_filters(left: &[String], right: &[String]) -> Vec<String> {
630 let mut merged = Vec::new();
631 for filter in left.iter().chain(right.iter()) {
632 let normalized = normalize_path_filter(filter);
633 if !normalized.is_empty() && !merged.iter().any(|existing| existing == normalized) {
634 merged.push(normalized.to_owned());
635 }
636 }
637
638 merged
639}
640
641pub(in crate::code) fn intersect_path_filters(
642 left: &[String],
643 right: &[String],
644) -> Option<Vec<String>> {
645 let left = normalized_path_filters(left);
646 let right = normalized_path_filters(right);
647 if left.is_empty() {
648 return Some(right);
649 }
650 if right.is_empty() {
651 return Some(left);
652 }
653
654 let mut intersections = Vec::new();
655 for left_filter in &left {
656 for right_filter in &right {
657 if path_filter_covers(left_filter, right_filter) {
658 push_filter_if_missing(&mut intersections, right_filter);
659 } else if path_filter_covers(right_filter, left_filter) {
660 push_filter_if_missing(&mut intersections, left_filter);
661 }
662 }
663 }
664
665 (!intersections.is_empty()).then_some(intersections)
666}
667
668pub(in crate::code) fn submodule_child_scope_filters(
669 path: &str,
670 registration: &CodeRepositoryRegistration,
671 selector: &CodeRepositorySelector,
672) -> Option<Vec<String>> {
673 let filters = intersect_path_filters(®istration.path_filters, &selector.path_filters)?;
674 submodule_child_scope_filters_from_filters(path, &filters)
675}
676
677pub(in crate::code) fn submodule_child_scope_filters_from_filters(
678 path: &str,
679 filters: &[String],
680) -> Option<Vec<String>> {
681 if filters.is_empty() {
682 return Some(Vec::new());
683 }
684 let path = normalize_scope_path(path);
685 if path.is_empty() {
686 return None;
687 }
688 let child_prefix = format!("{path}/");
689 let mut child_filters = Vec::new();
690 let mut parent_scope_covers_submodule = false;
691 for filter in filters {
692 let filter = normalize_scope_path(filter);
693 if filter.is_empty()
694 || filter == "."
695 || filter == path
696 || path.starts_with(&format!("{filter}/"))
697 {
698 parent_scope_covers_submodule = true;
699 continue;
700 }
701 if let Some(child_filter) = filter.strip_prefix(&child_prefix)
702 && !child_filter.is_empty()
703 {
704 child_filters.push(child_filter.to_owned());
705 }
706 }
707 if parent_scope_covers_submodule {
708 return Some(Vec::new());
709 }
710 if child_filters.is_empty() && !parent_scope_covers_submodule {
711 return None;
712 }
713 child_filters.sort();
714 child_filters.dedup();
715
716 Some(child_filters)
717}
718
719fn normalize_scope_path(path: &str) -> String {
720 path.replace('\\', "/")
721 .trim_start_matches("./")
722 .trim_matches('/')
723 .to_owned()
724}
725
726fn normalized_path_filters(filters: &[String]) -> Vec<String> {
727 filters
728 .iter()
729 .map(|filter| normalize_path_filter(filter).to_owned())
730 .filter(|filter| !filter.is_empty())
731 .collect()
732}
733
734fn push_filter_if_missing(filters: &mut Vec<String>, filter: &str) {
735 if !filters.iter().any(|existing| existing == filter) {
736 filters.push(filter.to_owned());
737 }
738}
739
740fn push_filter_if_uncovered(filters: &mut Vec<String>, root: &str) {
741 if filters
742 .iter()
743 .any(|filter| path_filter_covers(filter, root))
744 {
745 return;
746 }
747 filters.retain(|filter| !path_filter_covers(root, filter));
748 filters.push(root.to_owned());
749}
750
751fn path_filter_covers(filter: &str, path: &str) -> bool {
752 let filter = normalize_path_filter(filter);
753 filter == "." || path_matches_filter(path, filter)
754}
755
756fn source_layout_roots_for_path(path: &str) -> Vec<String> {
757 let path = normalize_path_filter(path);
758 let mut roots = Vec::new();
759 if path_matches_filter(path, "include") {
760 push_source_root(&mut roots, "include".to_owned());
761 }
762 for marker in NESTED_SOURCE_MARKERS {
763 if let Some((prefix, _)) = path.split_once(marker) {
764 push_source_root(&mut roots, format!("{prefix}{marker}"));
765 }
766 }
767 for root in STRIPPABLE_SOURCE_ROOTS {
768 if let Some(suffix) = path.strip_prefix(root) {
769 let mut segments = suffix.split('/').filter(|segment| !segment.is_empty());
770 if let Some(first) = segments.next() {
771 push_source_root(&mut roots, format!("{root}{first}"));
772 } else {
773 push_source_root(&mut roots, root.trim_end_matches('/').to_owned());
774 }
775 }
776 }
777 roots
778}
779
780fn push_source_root(roots: &mut Vec<String>, root: String) {
781 let root = root.trim_end_matches('/').to_owned();
782 if !root.is_empty() && !roots.contains(&root) {
783 roots.push(root);
784 }
785}
786
787pub(in crate::code) fn path_scope_allows(
788 path: &str,
789 registration: &CodeRepositoryRegistration,
790 selector: &CodeRepositorySelector,
791) -> bool {
792 path_filter_allows(path, ®istration.path_filters)
793 && path_filter_allows(path, &selector.path_filters)
794}
795
796pub(in crate::code) fn path_scope_overlaps(
797 path: &str,
798 registration: &CodeRepositoryRegistration,
799 selector: &CodeRepositorySelector,
800) -> bool {
801 path_filter_overlaps(path, ®istration.path_filters)
802 && path_filter_overlaps(path, &selector.path_filters)
803}
804
805pub(in crate::code) fn path_overlaps_any_filter(path: &str, filters: &[String]) -> bool {
806 path_filter_overlaps(path, filters)
807}
808
809fn path_filter_allows(path: &str, filters: &[String]) -> bool {
810 filters.is_empty()
811 || filters
812 .iter()
813 .any(|filter| path_matches_filter(path, filter))
814}
815
816fn path_filter_overlaps(path: &str, filters: &[String]) -> bool {
817 filters.is_empty()
818 || filters
819 .iter()
820 .any(|filter| path_overlaps_filter(path, filter))
821}
822
823fn path_matches_filter(path: &str, filter: &str) -> bool {
824 let path = normalize_path_filter(path);
825 let filter = normalize_path_filter(filter);
826 if filter == "." {
827 return true;
828 }
829 !filter.is_empty() && (path == filter || path.starts_with(&format!("{filter}/")))
830}
831
832fn path_overlaps_filter(path: &str, filter: &str) -> bool {
833 let path = normalize_path_filter(path);
834 let filter = normalize_path_filter(filter);
835 if filter == "." {
836 return true;
837 }
838 !path.is_empty()
839 && !filter.is_empty()
840 && (path == filter
841 || path.starts_with(&format!("{filter}/"))
842 || filter.starts_with(&format!("{path}/")))
843}
844
845fn normalize_path_filter(filter: &str) -> &str {
846 let mut filter = filter.trim_end_matches(['/', '\\']);
847 while let Some(stripped) = filter.strip_prefix("./") {
848 filter = stripped;
849 }
850
851 filter
852}
853
854#[cfg(test)]
855mod tests {
856 use crate::domain::{CodeRepositoryRegistration, CodeRepositorySelector};
857
858 use super::*;
859
860 #[test]
861 fn source_preset_does_not_exclude_tracked_directory_names() {
862 for path in [
863 "build/workflow.yaml",
864 ".cloudbuild/cloudbuild.yaml",
865 ".cid/pipeline.yml",
866 ".build_config/settings.toml",
867 "dist/bundle.js",
868 "frontend/dist/js/components/sidebar.js",
869 "node_modules/pkg/dist/js/core/index.js",
870 "target/generated.rs",
871 "vendor/pkg/lib.rs",
872 "third_party/pkg/lib.rs",
873 ] {
874 assert!(!source_default_file_preset_excludes(path), "{path}");
875 }
876 }
877
878 #[test]
879 fn explicit_default_exclusion_opt_in_normalizes_extension_case() {
880 let registration = CodeRepositoryRegistration::new(
881 "repo",
882 "alias",
883 "/tmp/repo",
884 vec!["assets/logo.SVG".to_owned()],
885 Vec::new(),
886 )
887 .expect("registration should validate");
888 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
889 .expect("selector should validate");
890
891 assert!(path_is_selected(
892 "assets/logo.SVG",
893 ®istration,
894 &selector
895 ));
896 }
897
898 #[test]
899 fn default_file_preset_excludes_dataset_dumps_and_keeps_uv_lock_facts() {
900 let registration = CodeRepositoryRegistration::new(
901 "repo",
902 "alias",
903 "/tmp/repo",
904 vec![".".to_owned()],
905 Vec::new(),
906 )
907 .expect("registration should validate");
908 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
909 .expect("selector should validate");
910
911 assert!(!path_is_selected(
912 ".agent_teams/evals/datasets/swebench-verified-full.jsonl",
913 ®istration,
914 &selector
915 ));
916 assert!(source_default_file_preset_excludes("uv.lock"));
917 assert!(path_is_selected("uv.lock", ®istration, &selector));
918 }
919
920 #[test]
921 fn git_tracked_directory_names_are_selected_without_opt_in() {
922 let registration = CodeRepositoryRegistration::new(
923 "repo",
924 "alias",
925 "/tmp/repo",
926 vec![".".to_owned()],
927 Vec::new(),
928 )
929 .expect("registration should validate");
930 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
931 .expect("selector should validate");
932
933 for path in [
934 "build/workflow.yaml",
935 ".cloudbuild/cloudbuild.yaml",
936 ".cid/pipeline.yml",
937 ".build_config/settings.toml",
938 "external_deps/python_sdk/session_client.py",
939 "packages/ui/src/index.ts",
940 "modules/java_sdk/src/main/java/example/SessionClient.java",
941 "plugins/example.com/nonstandard/session/client.go",
942 "Sources/SwiftSdk/SessionClient.swift",
943 "lib/app/controller.rb",
944 "vendor/pkg/session_client.py",
945 "third_party/pkg/session_client.py",
946 ] {
947 assert!(path_is_selected(path, ®istration, &selector), "{path}");
948 }
949 }
950
951 #[test]
952 fn default_source_preset_keeps_file_extension_opt_in_scoped() {
953 let registration = CodeRepositoryRegistration::new(
954 "repo",
955 "alias",
956 "/tmp/repo",
957 vec![".".to_owned(), "manual.pdf".to_owned()],
958 Vec::new(),
959 )
960 .expect("registration should validate");
961 let selector = CodeRepositorySelector::new("alias", "HEAD", Vec::new(), Vec::new())
962 .expect("selector should validate");
963
964 assert!(path_is_selected("manual.pdf", ®istration, &selector));
965 assert!(!path_is_selected("other.pdf", ®istration, &selector));
966 }
967}