Skip to main content

relay_knowledge/code/source/
layout.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};
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(&registration, &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(&registration.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(&registration.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(&registration.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(&registration.path_filters, &selector.path_filters);
193    let policy = FileSystemScanPolicy::from_path_and_language_filters(
194        filters.as_deref().unwrap_or(&[]),
195        &registration.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
240/// Returns a non-mutating preview of the effective repository indexing scope.
241pub fn preview_repository_scope(
242    registration: &CodeRepositoryRegistration,
243    selector: &CodeRepositorySelector,
244) -> Result<CodeRepositoryScopePreview, CodeIndexError> {
245    let root = PathBuf::from(&registration.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
352/// Splits diff paths by the same selector rules used by indexing and impact.
353pub 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(&registration.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, &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(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(&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(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(&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
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(&registration.path_filters);
570    if registration_scope_can_discover_source_roots(&registration.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(&registration.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, &registration.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, &registration.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            &registration,
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            &registration,
916            &selector
917        ));
918        assert!(source_default_file_preset_excludes("uv.lock"));
919        assert!(path_is_selected("uv.lock", &registration, &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, &registration, &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", &registration, &selector));
967        assert!(!path_is_selected("other.pdf", &registration, &selector));
968    }
969}