1use std::path::{Component, Path, PathBuf};
4
5use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
6use serde::ser::{Serialize, SerializeStruct, Serializer};
7use thiserror::Error;
8
9use crate::ConfigSource;
10
11pub const IGNORE_POLICY_VERSION: u8 = 1;
13
14pub const PROTECTED_EXCLUDES: &[&str] = &[
16 "**/.git/**",
17 "**/.hg/**",
18 "**/.svn/**",
19 "**/.codegraph/**",
20 "**/.code-system-graph/**",
21];
22
23pub const DEFAULT_EXCLUDES: &[&str] = &[
25 "**/.next/**",
26 "**/.venv/**",
27 "**/.mypy_cache/**",
28 "**/.nox/**",
29 "**/.pytest_cache/**",
30 "**/.ruff_cache/**",
31 "**/.tox/**",
32 "**/venv/**",
33 "**/env/**",
34 "**/site-packages/**",
35 "**/node_modules/**",
36 "**/vendor/**",
37 "**/target/**",
38 "**/dist/**",
39 "**/build/**",
40 "**/__pycache__/**",
41];
42
43const PROTECTED_DIRECTORY_NAMES: &[&str] =
44 &[".git", ".hg", ".svn", ".codegraph", ".code-system-graph"];
45
46#[derive(Debug, Error)]
48pub enum IgnorePatternError {
49 #[error("ignore pattern must not be empty")]
51 Empty,
52 #[error("ignore pattern `{0}` must contain a repository-relative path component")]
54 MissingPathComponent(String),
55 #[error("ignore pattern `{0}` must be repository-relative")]
57 Absolute(String),
58 #[error("ignore pattern `{0}` must not contain a `..` component")]
60 ParentTraversal(String),
61 #[error("ignore pattern `{0}` must use `/` separators")]
63 Backslash(String),
64 #[error("ignore pattern contains unsafe control or bidirectional characters")]
66 UnsafeCharacters,
67 #[error(
69 "ignore pattern `{0}` uses unsupported glob syntax; only `*`, `?`, and whole-component `**` are supported"
70 )]
71 UnsupportedSyntax(String),
72 #[error("invalid ignore pattern `{pattern}`: {detail}")]
74 InvalidGlob {
75 pattern: String,
77 detail: String,
79 },
80 #[error("includeDefaults pattern `{pattern}` targets protected directory `{directory}`")]
82 ProtectedInclude {
83 pattern: String,
85 directory: String,
87 },
88}
89
90#[derive(Debug, Clone)]
92pub struct IgnorePolicy {
93 configured_excludes: Vec<String>,
94 configured_excludes_source: ConfigSource,
95 include_defaults: Vec<String>,
96 include_defaults_source: ConfigSource,
97 protected_matcher: GlobSet,
98 default_matcher: GlobSet,
99 configured_matcher: GlobSet,
100 include_matcher: GlobSet,
101 include_prefixes: Vec<PathBuf>,
102 include_can_match_anywhere: bool,
103}
104
105impl PartialEq for IgnorePolicy {
106 fn eq(&self, other: &Self) -> bool {
107 self.configured_excludes == other.configured_excludes
108 && self.configured_excludes_source == other.configured_excludes_source
109 && self.include_defaults == other.include_defaults
110 && self.include_defaults_source == other.include_defaults_source
111 }
112}
113
114impl Eq for IgnorePolicy {}
115
116impl Serialize for IgnorePolicy {
117 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
118 where
119 S: Serializer,
120 {
121 let mut state = serializer.serialize_struct("IgnorePolicy", 4)?;
122 state.serialize_field("configured_excludes", &self.configured_excludes)?;
123 state.serialize_field(
124 "configured_excludes_source",
125 &self.configured_excludes_source,
126 )?;
127 state.serialize_field("include_defaults", &self.include_defaults)?;
128 state.serialize_field("include_defaults_source", &self.include_defaults_source)?;
129 state.end()
130 }
131}
132
133impl IgnorePolicy {
134 pub fn new(
141 configured_excludes: Vec<String>,
142 configured_excludes_source: ConfigSource,
143 include_defaults: Vec<String>,
144 include_defaults_source: ConfigSource,
145 ) -> Result<Self, IgnorePatternError> {
146 let mut configured_excludes = normalize_patterns(configured_excludes)?;
147 let mut include_defaults = normalize_patterns(include_defaults)?;
148 validate_protected_includes(&include_defaults)?;
149 configured_excludes.sort();
150 configured_excludes.dedup();
151 include_defaults.sort();
152 include_defaults.dedup();
153 let (include_prefixes, include_can_match_anywhere) = include_prefixes(&include_defaults);
154 Ok(Self {
155 protected_matcher: compile(PROTECTED_EXCLUDES.iter().copied())?,
156 default_matcher: compile(DEFAULT_EXCLUDES.iter().copied())?,
157 configured_matcher: compile(configured_excludes.iter().map(String::as_str))?,
158 include_matcher: compile(include_defaults.iter().map(String::as_str))?,
159 configured_excludes,
160 configured_excludes_source,
161 include_defaults,
162 include_defaults_source,
163 include_prefixes,
164 include_can_match_anywhere,
165 })
166 }
167
168 #[must_use]
170 pub fn configured_excludes(&self) -> &[String] {
171 &self.configured_excludes
172 }
173
174 #[must_use]
176 pub const fn configured_excludes_source(&self) -> ConfigSource {
177 self.configured_excludes_source
178 }
179
180 #[must_use]
182 pub fn include_defaults(&self) -> &[String] {
183 &self.include_defaults
184 }
185
186 #[must_use]
188 pub const fn include_defaults_source(&self) -> ConfigSource {
189 self.include_defaults_source
190 }
191
192 #[must_use]
194 pub fn excludes(&self, relative: &Path, directory: bool) -> bool {
195 let candidate = candidate(relative);
196 let directory_candidate = directory.then(|| format!("{candidate}/"));
197 let matches = |matcher: &GlobSet| {
198 matcher.is_match(&candidate)
199 || directory_candidate
200 .as_deref()
201 .is_some_and(|candidate| matcher.is_match(candidate))
202 };
203 if matches(&self.protected_matcher) || matches(&self.configured_matcher) {
204 return true;
205 }
206 matches(&self.default_matcher)
207 && !matches(&self.include_matcher)
208 && !(directory && self.may_contain_included_default(relative))
209 }
210
211 #[must_use]
213 pub fn fingerprint_material(&self) -> String {
214 format!(
215 "version={IGNORE_POLICY_VERSION};protected={PROTECTED_EXCLUDES:?};defaults={DEFAULT_EXCLUDES:?};\
216 excludes={:?};excludes_source={:?};include_defaults={:?};include_defaults_source={:?}",
217 self.configured_excludes,
218 self.configured_excludes_source,
219 self.include_defaults,
220 self.include_defaults_source
221 )
222 }
223
224 fn may_contain_included_default(&self, directory: &Path) -> bool {
225 if self.include_can_match_anywhere {
226 return true;
227 }
228 self.include_prefixes
229 .iter()
230 .any(|prefix| prefix.starts_with(directory) || directory.starts_with(prefix))
231 }
232}
233
234pub fn validate_excludes(patterns: &[String]) -> Result<(), IgnorePatternError> {
240 let normalized = normalize_patterns(patterns)?;
241 compile(normalized.iter().map(String::as_str)).map(|_| ())
242}
243
244pub fn validate_include_defaults(patterns: &[String]) -> Result<(), IgnorePatternError> {
251 let normalized = normalize_patterns(patterns)?;
252 validate_protected_includes(&normalized)?;
253 compile(normalized.iter().map(String::as_str)).map(|_| ())
254}
255
256fn validate_protected_includes(patterns: &[String]) -> Result<(), IgnorePatternError> {
257 for pattern in patterns {
258 for component in pattern.split('/') {
259 if PROTECTED_DIRECTORY_NAMES.contains(&component) {
260 return Err(IgnorePatternError::ProtectedInclude {
261 pattern: pattern.clone(),
262 directory: component.to_owned(),
263 });
264 }
265 }
266 }
267 Ok(())
268}
269
270fn normalize_patterns<I, S>(patterns: I) -> Result<Vec<String>, IgnorePatternError>
271where
272 I: IntoIterator<Item = S>,
273 S: AsRef<str>,
274{
275 patterns
276 .into_iter()
277 .map(|pattern| normalize_pattern(pattern.as_ref()))
278 .collect()
279}
280
281fn normalize_pattern(pattern: &str) -> Result<String, IgnorePatternError> {
282 if pattern.trim().is_empty() {
283 return Err(IgnorePatternError::Empty);
284 }
285 if absolute_pattern(pattern) {
286 return Err(IgnorePatternError::Absolute(pattern.to_owned()));
287 }
288 if pattern.contains('\\') {
289 return Err(IgnorePatternError::Backslash(pattern.to_owned()));
290 }
291 if pattern.split('/').any(|component| component == "..") {
292 return Err(IgnorePatternError::ParentTraversal(pattern.to_owned()));
293 }
294 if pattern.chars().any(unsafe_character) {
295 return Err(IgnorePatternError::UnsafeCharacters);
296 }
297
298 let directory_only = pattern.ends_with('/')
299 || pattern
300 .split('/')
301 .rfind(|component| !component.is_empty())
302 .is_some_and(|component| component == ".");
303 let components = pattern
304 .split('/')
305 .filter(|component| !component.is_empty() && *component != ".")
306 .collect::<Vec<_>>();
307 if components.is_empty() {
308 return Err(IgnorePatternError::MissingPathComponent(pattern.to_owned()));
309 }
310 validate_supported_syntax(pattern, &components)?;
311
312 let mut normalized = components.join("/");
313 if absolute_pattern(&normalized) {
314 return Err(IgnorePatternError::Absolute(pattern.to_owned()));
315 }
316 if directory_only {
317 normalized.push('/');
318 }
319 Ok(normalized)
320}
321
322fn validate_supported_syntax(pattern: &str, components: &[&str]) -> Result<(), IgnorePatternError> {
323 let unsupported_delimiter = pattern.contains(['[', ']', '{', '}']);
324 let unsupported_recursive = components
325 .iter()
326 .any(|component| component.contains("**") && *component != "**");
327 if unsupported_delimiter || unsupported_recursive {
328 return Err(IgnorePatternError::UnsupportedSyntax(pattern.to_owned()));
329 }
330 Ok(())
331}
332
333fn absolute_pattern(pattern: &str) -> bool {
334 pattern.starts_with('/')
335 || pattern
336 .as_bytes()
337 .get(1)
338 .is_some_and(|separator| *separator == b':')
339}
340
341fn compile<'a>(patterns: impl Iterator<Item = &'a str>) -> Result<GlobSet, IgnorePatternError> {
342 let mut builder = GlobSetBuilder::new();
343 for pattern in patterns {
344 let glob = GlobBuilder::new(pattern)
345 .literal_separator(true)
346 .backslash_escape(false)
347 .build()
348 .map_err(|error| IgnorePatternError::InvalidGlob {
349 pattern: pattern.to_owned(),
350 detail: error.to_string(),
351 })?;
352 builder.add(glob);
353 }
354 builder
355 .build()
356 .map_err(|error| IgnorePatternError::InvalidGlob {
357 pattern: "<set>".to_owned(),
358 detail: error.to_string(),
359 })
360}
361
362fn include_prefixes(patterns: &[String]) -> (Vec<PathBuf>, bool) {
363 let mut prefixes = Vec::new();
364 let mut can_match_anywhere = false;
365 for pattern in patterns {
366 let mut prefix = PathBuf::new();
367 for component in pattern.split('/') {
368 if component.contains(['*', '?']) {
369 break;
370 }
371 if !component.is_empty() {
372 prefix.push(component);
373 }
374 }
375 if prefix.as_os_str().is_empty() {
376 can_match_anywhere = true;
377 } else {
378 prefixes.push(prefix);
379 }
380 }
381 prefixes.sort();
382 prefixes.dedup();
383 (prefixes, can_match_anywhere)
384}
385
386fn candidate(path: &Path) -> String {
387 path.components()
388 .filter_map(|component| match component {
389 Component::Normal(value) => Some(value.to_string_lossy()),
390 _ => None,
391 })
392 .collect::<Vec<_>>()
393 .join("/")
394}
395
396fn unsafe_character(character: char) -> bool {
397 character.is_control()
398 || matches!(
399 character,
400 '\u{061c}'
401 | '\u{200e}'
402 | '\u{200f}'
403 | '\u{202a}'..='\u{202e}'
404 | '\u{2066}'..='\u{2069}'
405 )
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 fn policy(excludes: &[&str], includes: &[&str]) -> IgnorePolicy {
413 IgnorePolicy::new(
414 excludes.iter().map(ToString::to_string).collect(),
415 ConfigSource::WorkspaceManifest,
416 includes.iter().map(ToString::to_string).collect(),
417 ConfigSource::WorkspaceManifest,
418 )
419 .expect("valid fixture policy")
420 }
421
422 #[test]
423 fn defaults_should_exclude_nested_dependency_tree() {
424 assert!(policy(&[], &[]).excludes(Path::new("web/node_modules/react/index.js"), false));
425 }
426
427 #[test]
428 fn configured_excludes_should_match_repository_relative_globs() {
429 assert!(policy(&["coverage/**"], &[]).excludes(Path::new("coverage/lcov.info"), false));
430 }
431
432 #[test]
433 fn configured_excludes_should_support_single_component_wildcards() {
434 assert!(policy(&["src/*/?.rs"], &[]).excludes(Path::new("src/api/x.rs"), false));
435 }
436
437 #[test]
438 fn configured_excludes_should_normalize_and_deduplicate_patterns() {
439 let policy = policy(&["./coverage//**", "coverage/./**", "coverage/**"], &[]);
440
441 assert_eq!(policy.configured_excludes(), &["coverage/**"]);
442 }
443
444 #[test]
445 fn configured_excludes_should_match_normalized_patterns() {
446 assert!(policy(&["./coverage/./**"], &[]).excludes(Path::new("coverage/lcov.info"), false));
447 }
448
449 #[test]
450 fn directory_patterns_should_preserve_their_terminal_separator() {
451 let policy = policy(&["./coverage//"], &[]);
452
453 assert!(policy.excludes(Path::new("coverage"), true));
454 assert!(!policy.excludes(Path::new("coverage"), false));
455 }
456
457 #[test]
458 fn ordinary_globs_should_match_directories_without_a_terminal_separator() {
459 let policy = policy(&["generated/*"], &[]);
460
461 assert!(policy.excludes(Path::new("generated/output"), true));
462 }
463
464 #[test]
465 fn include_defaults_should_reopen_only_selected_subtree() {
466 let policy = policy(&[], &["./vendor//internal-sdk/./**"]);
467 assert_eq!(
468 (
469 policy.excludes(Path::new("vendor/internal-sdk/src/lib.rs"), false),
470 policy.excludes(Path::new("vendor/external/src/lib.rs"), false),
471 ),
472 (false, true)
473 );
474 }
475
476 #[test]
477 fn configured_excludes_should_override_default_includes() {
478 assert!(
479 policy(
480 &["vendor/internal-sdk/private/**"],
481 &["vendor/internal-sdk/**"]
482 )
483 .excludes(Path::new("vendor/internal-sdk/private/key.rs"), false)
484 );
485 }
486
487 #[test]
488 fn include_defaults_should_keep_ancestor_traversable() {
489 assert!(!policy(&[], &["./vendor//internal-sdk/./**"]).excludes(Path::new("vendor"), true));
490 }
491
492 #[test]
493 fn canonical_equivalent_policies_should_have_the_same_fingerprint() {
494 let canonical = policy(&["coverage/**"], &["vendor/internal-sdk/**"]);
495 let redundant = policy(&["./coverage//./**"], &["./vendor//internal-sdk/./**"]);
496
497 assert_eq!(
498 canonical.fingerprint_material(),
499 redundant.fingerprint_material()
500 );
501 }
502
503 #[test]
504 fn include_defaults_should_reject_protected_directories() {
505 assert!(matches!(
506 validate_include_defaults(&["./.git//config".to_owned()]),
507 Err(IgnorePatternError::ProtectedInclude { .. })
508 ));
509 }
510
511 #[test]
512 fn patterns_should_reject_parent_traversal() {
513 assert!(matches!(
514 validate_excludes(&["./safe/../outside/**".to_owned()]),
515 Err(IgnorePatternError::ParentTraversal(_))
516 ));
517 }
518
519 #[test]
520 fn patterns_should_reject_windows_absolute_paths_after_normalization() {
521 assert!(matches!(
522 validate_excludes(&["./C:/outside/**".to_owned()]),
523 Err(IgnorePatternError::Absolute(_))
524 ));
525 }
526
527 #[test]
528 fn patterns_should_reject_whitespace_only_values() {
529 assert!(matches!(
530 validate_excludes(&[" ".to_owned()]),
531 Err(IgnorePatternError::Empty)
532 ));
533 }
534
535 #[test]
536 fn patterns_should_reject_values_without_path_components() {
537 assert!(matches!(
538 validate_excludes(&["././".to_owned()]),
539 Err(IgnorePatternError::MissingPathComponent(_))
540 ));
541 }
542
543 #[test]
544 fn patterns_should_reject_character_classes() {
545 assert!(matches!(
546 validate_excludes(&["src/[ab]/**".to_owned()]),
547 Err(IgnorePatternError::UnsupportedSyntax(_))
548 ));
549 }
550
551 #[test]
552 fn patterns_should_reject_alternations() {
553 assert!(matches!(
554 validate_excludes(&["{src,test}/**".to_owned()]),
555 Err(IgnorePatternError::UnsupportedSyntax(_))
556 ));
557 }
558
559 #[test]
560 fn patterns_should_reject_non_component_recursive_wildcards() {
561 assert!(matches!(
562 validate_excludes(&["src/**generated/**".to_owned()]),
563 Err(IgnorePatternError::UnsupportedSyntax(_))
564 ));
565 }
566
567 #[test]
568 fn patterns_should_reject_three_star_wildcards() {
569 assert!(matches!(
570 validate_excludes(&["src/***/generated".to_owned()]),
571 Err(IgnorePatternError::UnsupportedSyntax(_))
572 ));
573 }
574}