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};
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(&registration, &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(&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(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(&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 = 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(&registration.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(&registration.path_filters, &selector.path_filters);
192    let policy = FileSystemScanPolicy::from_path_and_language_filters(
193        filters.as_deref().unwrap_or(&[]),
194        &registration.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
239/// Returns a non-mutating preview of the effective repository indexing scope.
240pub fn preview_repository_scope(
241    registration: &CodeRepositoryRegistration,
242    selector: &CodeRepositorySelector,
243) -> Result<CodeRepositoryScopePreview, CodeIndexError> {
244    let root = PathBuf::from(&registration.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
350/// Splits diff paths by the same selector rules used by indexing and impact.
351pub 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(&registration.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, &registration.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(&registration.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(&registration.path_filters, &selector.path_filters);
547    if !registration_scope_can_discover_source_roots(&registration.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(&registration.path_filters);
568    if registration_scope_can_discover_source_roots(&registration.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(&registration.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, &registration.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, &registration.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            &registration,
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            &registration,
914            &selector
915        ));
916        assert!(source_default_file_preset_excludes("uv.lock"));
917        assert!(path_is_selected("uv.lock", &registration, &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, &registration, &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", &registration, &selector));
965        assert!(!path_is_selected("other.pdf", &registration, &selector));
966    }
967}