Skip to main content

relay_knowledge/code/
scope.rs

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(&registration, &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(&registration.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(&registration.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        &registration.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(&registration.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
229/// Returns a non-mutating preview of the effective repository indexing scope.
230pub fn preview_repository_scope(
231    registration: &CodeRepositoryRegistration,
232    selector: &CodeRepositorySelector,
233) -> Result<CodeRepositoryScopePreview, CodeIndexError> {
234    let root = PathBuf::from(&registration.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        &registration.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
347/// Splits diff paths by the same selector rules used by indexing and impact.
348pub 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(&registration.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        &registration.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, &registration.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(&registration.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(&registration.path_filters, &selector.path_filters);
549    if !registration_scope_can_discover_source_roots(&registration.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, &registration.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, &registration.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            &registration,
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            &registration,
794            &selector
795        ));
796        assert!(source_default_file_preset_excludes("uv.lock"));
797        assert!(path_is_selected("uv.lock", &registration, &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, &registration, &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", &registration, &selector));
845        assert!(!path_is_selected("other.pdf", &registration, &selector));
846    }
847}