Skip to main content

relay_knowledge/code/
scope.rs

1use std::{
2    collections::BTreeMap,
3    fs,
4    path::{Path, PathBuf},
5};
6
7use crate::domain::{
8    CodeImpactPathGroups, CodeRepositoryExcludedPath, CodeRepositoryLanguagePreview,
9    CodeRepositoryLargestFile, CodeRepositoryRegistration, CodeRepositoryScopePreview,
10    CodeRepositorySelector,
11};
12
13use super::{
14    CodeIndexError, changes::tracked_entries, git_bytes, git_object_exists, languages::language_id,
15    resolve_ref, resolve_tree,
16};
17
18const PREVIEW_MAX_EXCLUDED_PATHS: usize = 50;
19const PREVIEW_MAX_LARGEST_FILES: usize = 10;
20const DEFAULT_TEXT_FILE_BUDGET_BYTES: usize = 512 * 1024;
21const DEFAULT_EXCLUDED_SEGMENTS: &[&str] = &[
22    ".git",
23    ".cache",
24    ".next",
25    ".nuxt",
26    ".parcel-cache",
27    ".pytest_cache",
28    ".ruff_cache",
29    ".tox",
30    ".venv",
31    "__pycache__",
32    "build",
33    "coverage",
34    "dist",
35    "node_modules",
36    "out",
37    "target",
38    "third_party",
39    "vendor",
40    "venv",
41];
42const DEFAULT_EXCLUDED_EXTENSIONS: &[&str] = &[
43    "7z", "avif", "bmp", "bz2", "class", "eot", "gif", "gz", "ico", "jar", "jpeg", "jpg", "jsonl",
44    "lockb", "map", "mov", "mp4", "otf", "pdf", "png", "svg", "tar", "tgz", "ttf", "wasm", "webm",
45    "woff", "woff2", "zip", "zst",
46];
47const DEFAULT_EXCLUDED_FILENAMES: &[&str] = &[".relay-knowledgeignore", "uv.lock"];
48
49/// Returns a non-mutating preview of the effective repository indexing scope.
50pub fn preview_repository_scope(
51    registration: &CodeRepositoryRegistration,
52    selector: &CodeRepositorySelector,
53) -> Result<CodeRepositoryScopePreview, CodeIndexError> {
54    let root = PathBuf::from(&registration.root_path);
55    let commit = resolve_ref(&root, &selector.ref_selector)?;
56    let tree_hash = resolve_tree(&root, &commit)?;
57    let ignore_rules = load_ignore_rules_from_commit(&root, &commit)?;
58    let mut selected_byte_count = 0usize;
59    let mut selected_file_count = 0usize;
60    let mut unsupported_file_count = 0usize;
61    let mut generated_or_heavy_file_count = 0usize;
62    let mut expected_degraded_file_count = 0usize;
63    let mut language_distribution = BTreeMap::<String, (usize, usize)>::new();
64    let mut largest_files = Vec::<CodeRepositoryLargestFile>::new();
65    let mut excluded_paths = Vec::<CodeRepositoryExcludedPath>::new();
66
67    for entry in tracked_entries(&root, &commit)? {
68        if let Some(reason) =
69            selection_exclusion_reason(&entry.path, registration, selector, &ignore_rules)
70        {
71            if excluded_paths.len() < PREVIEW_MAX_EXCLUDED_PATHS {
72                excluded_paths.push(CodeRepositoryExcludedPath {
73                    path: entry.path,
74                    reason,
75                });
76            }
77            continue;
78        }
79        let language = language_id(&entry.path).unwrap_or("unknown");
80        selected_file_count += 1;
81        selected_byte_count = selected_byte_count.saturating_add(entry.byte_count);
82        let bucket = language_distribution
83            .entry(language.to_owned())
84            .or_insert((0, 0));
85        bucket.0 += 1;
86        bucket.1 = bucket.1.saturating_add(entry.byte_count);
87        let is_unsupported = language == "unknown";
88        let is_heavy = entry.byte_count > DEFAULT_TEXT_FILE_BUDGET_BYTES;
89        if is_unsupported {
90            unsupported_file_count += 1;
91        }
92        if is_heavy {
93            generated_or_heavy_file_count += 1;
94        }
95        if is_unsupported || is_heavy {
96            expected_degraded_file_count += 1;
97        }
98        largest_files.push(CodeRepositoryLargestFile {
99            path: entry.path,
100            byte_count: entry.byte_count,
101        });
102    }
103    largest_files.sort_by(|left, right| {
104        right
105            .byte_count
106            .cmp(&left.byte_count)
107            .then_with(|| left.path.cmp(&right.path))
108    });
109    largest_files.truncate(PREVIEW_MAX_LARGEST_FILES);
110
111    Ok(CodeRepositoryScopePreview {
112        repository_id: registration.repository_id.clone(),
113        alias: registration.alias.clone(),
114        requested_ref: selector.ref_selector.clone(),
115        resolved_commit_sha: commit,
116        tree_hash,
117        selected_file_count,
118        selected_byte_count,
119        unsupported_file_count,
120        generated_or_heavy_file_count,
121        expected_degraded_file_count,
122        language_distribution: language_distribution
123            .into_iter()
124            .map(
125                |(language_id, (file_count, byte_count))| CodeRepositoryLanguagePreview {
126                    language_id,
127                    file_count,
128                    byte_count,
129                },
130            )
131            .collect(),
132        largest_files,
133        excluded_paths,
134    })
135}
136
137/// Splits diff paths by the same selector rules used by indexing and impact.
138pub fn partition_changed_paths_for_selector(
139    registration: &CodeRepositoryRegistration,
140    selector: &CodeRepositorySelector,
141    paths: Vec<String>,
142) -> Result<CodeImpactPathGroups, CodeIndexError> {
143    let root = PathBuf::from(&registration.root_path);
144    let commit = resolve_ref(&root, &selector.ref_selector)?;
145    let ignore_rules = load_ignore_rules_from_commit(&root, &commit)?;
146    let mut in_scope_changed_paths = Vec::new();
147    let mut out_of_scope_changed_paths = Vec::new();
148    for path in paths {
149        if selection_exclusion_reason(&path, registration, selector, &ignore_rules).is_none() {
150            in_scope_changed_paths.push(path);
151        } else {
152            out_of_scope_changed_paths.push(path);
153        }
154    }
155    in_scope_changed_paths.sort();
156    in_scope_changed_paths.dedup();
157    out_of_scope_changed_paths.sort();
158    out_of_scope_changed_paths.dedup();
159
160    Ok(CodeImpactPathGroups {
161        in_scope_changed_paths,
162        out_of_scope_changed_paths,
163    })
164}
165
166#[cfg(test)]
167pub(super) fn path_is_selected(
168    path: &str,
169    registration: &CodeRepositoryRegistration,
170    selector: &CodeRepositorySelector,
171) -> bool {
172    let root = Path::new(&registration.root_path);
173    let ignore_rules = load_ignore_rules(root).expect("ignore rules should load in tests");
174
175    path_is_selected_with_rules(path, registration, selector, &ignore_rules)
176}
177
178pub(super) fn path_is_selected_with_rules(
179    path: &str,
180    registration: &CodeRepositoryRegistration,
181    selector: &CodeRepositorySelector,
182    ignore_rules: &[IgnoreRule],
183) -> bool {
184    selection_exclusion_reason(path, registration, selector, ignore_rules).is_none()
185}
186
187pub(super) fn selection_exclusion_reason(
188    path: &str,
189    registration: &CodeRepositoryRegistration,
190    selector: &CodeRepositorySelector,
191    ignore_rules: &[IgnoreRule],
192) -> Option<String> {
193    if !path_scope_allows(path, registration, selector) {
194        return Some("outside registered/requested path scope".to_owned());
195    }
196    if !language_filter_allows(path, &registration.language_filters)
197        || !language_filter_allows(path, &selector.language_filters)
198    {
199        return Some("outside registered/requested language scope".to_owned());
200    }
201    if ignore_rules.iter().any(|rule| rule.matches(path)) {
202        return Some("excluded by .relay-knowledgeignore".to_owned());
203    }
204    if default_source_preset_excludes(path)
205        && !explicit_path_filter_opts_into_default_exclusion(
206            path,
207            registration
208                .path_filters
209                .iter()
210                .chain(selector.path_filters.iter()),
211        )
212    {
213        return Some("excluded by source preset".to_owned());
214    }
215
216    None
217}
218
219pub(super) fn path_scope_allows(
220    path: &str,
221    registration: &CodeRepositoryRegistration,
222    selector: &CodeRepositorySelector,
223) -> bool {
224    path_filter_allows(path, &registration.path_filters)
225        && path_filter_allows(path, &selector.path_filters)
226}
227
228pub(super) fn path_scope_overlaps(
229    path: &str,
230    registration: &CodeRepositoryRegistration,
231    selector: &CodeRepositorySelector,
232) -> bool {
233    path_filter_overlaps(path, &registration.path_filters)
234        && path_filter_overlaps(path, &selector.path_filters)
235}
236
237pub(super) fn load_ignore_rules(root: &Path) -> Result<Vec<IgnoreRule>, CodeIndexError> {
238    let path = root.join(".relay-knowledgeignore");
239    let content = match fs::read_to_string(path) {
240        Ok(content) => content,
241        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
242        Err(error) => return Err(error.into()),
243    };
244
245    Ok(parse_ignore_rules(&content))
246}
247
248pub(super) fn load_ignore_rules_from_commit(
249    root: &Path,
250    commit: &str,
251) -> Result<Vec<IgnoreRule>, CodeIndexError> {
252    let object = format!("{commit}:.relay-knowledgeignore");
253    if !git_object_exists(root, &object)? {
254        return Ok(Vec::new());
255    }
256    let content = String::from_utf8(git_bytes(root, ["show", &object])?).map_err(|error| {
257        CodeIndexError::InvalidInput(format!(
258            ".relay-knowledgeignore at {commit} is not valid UTF-8: {}",
259            error.utf8_error()
260        ))
261    })?;
262
263    Ok(parse_ignore_rules(&content))
264}
265
266fn parse_ignore_rules(content: &str) -> Vec<IgnoreRule> {
267    content
268        .lines()
269        .map(str::trim)
270        .filter(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with('!'))
271        .map(|line| IgnoreRule {
272            pattern: line.trim_start_matches('/').to_owned(),
273            anchored: line.starts_with('/'),
274        })
275        .collect()
276}
277
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub(super) struct IgnoreRule {
280    pattern: String,
281    anchored: bool,
282}
283
284impl IgnoreRule {
285    fn matches(&self, path: &str) -> bool {
286        let pattern = normalize_path_filter(&self.pattern);
287        let path = normalize_path_filter(path);
288        if pattern.is_empty() {
289            return false;
290        }
291        if let Some(extension) = pattern.strip_prefix("*.") {
292            return if self.anchored {
293                path.rsplit_once('/').is_none()
294                    && path
295                        .rsplit_once('.')
296                        .is_some_and(|(_, path_extension)| path_extension == extension)
297            } else {
298                path.rsplit_once('.')
299                    .is_some_and(|(_, path_extension)| path_extension == extension)
300            };
301        }
302        if pattern.contains('/') {
303            return path == pattern || path.starts_with(&format!("{pattern}/"));
304        }
305        if self.anchored {
306            return path == pattern || path.starts_with(&format!("{pattern}/"));
307        }
308        path.split('/').any(|segment| segment == pattern)
309    }
310}
311
312fn path_filter_allows(path: &str, filters: &[String]) -> bool {
313    filters.is_empty()
314        || filters
315            .iter()
316            .any(|filter| path_matches_filter(path, filter))
317}
318
319fn path_filter_overlaps(path: &str, filters: &[String]) -> bool {
320    filters.is_empty()
321        || filters
322            .iter()
323            .any(|filter| path_overlaps_filter(path, filter))
324}
325
326fn language_filter_allows(path: &str, filters: &[String]) -> bool {
327    filters.is_empty()
328        || language_id(path)
329            .map(|language| filters.iter().any(|filter| filter == language))
330            .unwrap_or(false)
331}
332
333fn default_source_preset_excludes(path: &str) -> bool {
334    let normalized = normalize_path_filter(path);
335    if normalized
336        .rsplit('/')
337        .next()
338        .is_some_and(|file_name| DEFAULT_EXCLUDED_FILENAMES.contains(&file_name))
339    {
340        return true;
341    }
342    if normalized
343        .split('/')
344        .any(|segment| DEFAULT_EXCLUDED_SEGMENTS.contains(&segment))
345    {
346        return true;
347    }
348    normalized
349        .rsplit_once('.')
350        .map(|(_, extension)| {
351            DEFAULT_EXCLUDED_EXTENSIONS.contains(&extension.to_ascii_lowercase().as_str())
352        })
353        .unwrap_or(false)
354}
355
356fn explicit_path_filter_opts_into_default_exclusion<'a>(
357    path: &str,
358    filters: impl IntoIterator<Item = &'a String>,
359) -> bool {
360    let path_extension = path
361        .rsplit_once('.')
362        .map(|(_, extension)| extension.to_ascii_lowercase());
363    filters.into_iter().any(|filter| {
364        let filter = normalize_path_filter(filter);
365        if filter.is_empty() || filter == "." {
366            return false;
367        }
368        let filter_segments = filter.split('/').collect::<Vec<_>>();
369        let targets_default_exclusion = filter_segments.iter().any(|segment| {
370            DEFAULT_EXCLUDED_SEGMENTS.contains(segment)
371                || DEFAULT_EXCLUDED_FILENAMES.contains(segment)
372                || segment
373                    .rsplit_once('.')
374                    .map(|(_, ext)| ext.to_ascii_lowercase())
375                    .is_some_and(|extension| {
376                        DEFAULT_EXCLUDED_EXTENSIONS.contains(&extension.as_str())
377                    })
378        });
379        if !targets_default_exclusion {
380            return false;
381        }
382        path_matches_filter(path, filter)
383            || filter.strip_prefix("*.").is_some_and(|extension| {
384                path_extension.as_deref() == Some(&extension.to_ascii_lowercase())
385            })
386    })
387}
388
389fn path_matches_filter(path: &str, filter: &str) -> bool {
390    let path = normalize_path_filter(path);
391    let filter = normalize_path_filter(filter);
392    if filter == "." {
393        return true;
394    }
395    !filter.is_empty() && (path == filter || path.starts_with(&format!("{filter}/")))
396}
397
398fn path_overlaps_filter(path: &str, filter: &str) -> bool {
399    let path = normalize_path_filter(path);
400    let filter = normalize_path_filter(filter);
401    if filter == "." {
402        return true;
403    }
404    !path.is_empty()
405        && !filter.is_empty()
406        && (path == filter
407            || path.starts_with(&format!("{filter}/"))
408            || filter.starts_with(&format!("{path}/")))
409}
410
411fn normalize_path_filter(filter: &str) -> &str {
412    let mut filter = filter.trim_end_matches(['/', '\\']);
413    while let Some(stripped) = filter.strip_prefix("./") {
414        filter = stripped;
415    }
416
417    filter
418}