1use std::path::Path;
2
3use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
4
5use crate::config::PathsSpec;
6use crate::error::{Error, Result};
7
8#[derive(Debug, Clone)]
15pub struct Scope {
16 include: GlobSet,
17 exclude: GlobSet,
18 has_include: bool,
19}
20
21fn compile(pattern: &str) -> Result<Glob> {
22 GlobBuilder::new(pattern)
23 .literal_separator(true)
24 .build()
25 .map_err(|source| Error::Glob {
26 pattern: pattern.to_string(),
27 source,
28 })
29}
30
31impl Scope {
32 pub fn from_patterns(patterns: &[String]) -> Result<Self> {
33 let mut include = GlobSetBuilder::new();
34 let mut exclude = GlobSetBuilder::new();
35 let mut has_include = false;
36 for pattern in patterns {
37 if let Some(rest) = pattern.strip_prefix('!') {
38 exclude.add(compile(rest)?);
39 } else {
40 include.add(compile(pattern)?);
41 has_include = true;
42 }
43 }
44 Ok(Self {
45 include: include.build().map_err(|source| Error::Glob {
46 pattern: patterns.join(","),
47 source,
48 })?,
49 exclude: exclude.build().map_err(|source| Error::Glob {
50 pattern: patterns.join(","),
51 source,
52 })?,
53 has_include,
54 })
55 }
56
57 pub fn from_paths_spec(spec: &PathsSpec) -> Result<Self> {
58 match spec {
59 PathsSpec::Single(s) => Self::from_patterns(std::slice::from_ref(s)),
60 PathsSpec::Many(v) => Self::from_patterns(v),
61 PathsSpec::IncludeExclude { include, exclude } => {
62 let mut combined = include.clone();
63 for e in exclude {
64 combined.push(format!("!{e}"));
65 }
66 Self::from_patterns(&combined)
67 }
68 }
69 }
70
71 pub fn match_all() -> Self {
73 let mut include = GlobSetBuilder::new();
74 include.add(compile("**").expect("`**` must compile"));
75 Self {
76 include: include.build().expect("`**` GlobSet must build"),
77 exclude: GlobSet::empty(),
78 has_include: true,
79 }
80 }
81
82 pub fn matches(&self, path: &Path) -> bool {
83 if self.exclude.is_match(path) {
84 return false;
85 }
86 if !self.has_include {
87 return true;
88 }
89 self.include.is_match(path)
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 fn s(patterns: &[&str]) -> Scope {
98 Scope::from_patterns(
99 &patterns
100 .iter()
101 .map(|p| (*p).to_string())
102 .collect::<Vec<_>>(),
103 )
104 .unwrap()
105 }
106
107 #[test]
108 fn star_does_not_cross_path_separator() {
109 let scope = s(&["src/*.rs"]);
111 assert!(scope.matches(Path::new("src/main.rs")));
112 assert!(!scope.matches(Path::new("src/sub/main.rs")));
113 }
114
115 #[test]
116 fn double_star_descends_into_subdirectories() {
117 let scope = s(&["src/**/*.rs"]);
118 assert!(scope.matches(Path::new("src/main.rs")));
119 assert!(scope.matches(Path::new("src/sub/main.rs")));
120 assert!(scope.matches(Path::new("src/a/b/c/d.rs")));
121 }
122
123 #[test]
124 fn excludes_apply_before_includes() {
125 let scope = s(&["src/**/*.rs", "!src/**/test_*.rs"]);
128 assert!(scope.matches(Path::new("src/main.rs")));
129 assert!(!scope.matches(Path::new("src/test_widget.rs")));
130 assert!(!scope.matches(Path::new("src/sub/test_thing.rs")));
131 }
132
133 #[test]
134 fn empty_pattern_list_matches_nothing() {
135 let scope = Scope::from_patterns(&[]).unwrap();
142 assert!(
143 scope.matches(Path::new("anything")),
144 "empty pattern list yields match-all (no excludes, no includes → has_include=false → matches)",
145 );
146 }
147
148 #[test]
149 fn match_all_helper_matches_every_path() {
150 let scope = Scope::match_all();
151 assert!(scope.matches(Path::new("a")));
152 assert!(scope.matches(Path::new("a/b/c.rs")));
153 assert!(scope.matches(Path::new("deeply/nested/path/with.ext")));
154 }
155
156 #[test]
157 fn from_paths_spec_handles_single_string() {
158 let scope = Scope::from_paths_spec(&PathsSpec::Single("src/**/*.rs".into())).unwrap();
159 assert!(scope.matches(Path::new("src/main.rs")));
160 assert!(!scope.matches(Path::new("docs/intro.md")));
161 }
162
163 #[test]
164 fn from_paths_spec_handles_many_strings() {
165 let scope = Scope::from_paths_spec(&PathsSpec::Many(vec![
166 "src/**/*.rs".into(),
167 "Cargo.toml".into(),
168 ]))
169 .unwrap();
170 assert!(scope.matches(Path::new("src/main.rs")));
171 assert!(scope.matches(Path::new("Cargo.toml")));
172 assert!(!scope.matches(Path::new("docs/intro.md")));
173 }
174
175 #[test]
176 fn from_paths_spec_handles_include_exclude_form() {
177 let scope = Scope::from_paths_spec(&PathsSpec::IncludeExclude {
178 include: vec!["src/**/*.rs".into()],
179 exclude: vec!["src/**/test_*.rs".into()],
180 })
181 .unwrap();
182 assert!(scope.matches(Path::new("src/main.rs")));
183 assert!(!scope.matches(Path::new("src/test_x.rs")));
184 }
185
186 #[test]
187 fn invalid_glob_surfaces_clear_error() {
188 let err = Scope::from_patterns(&["[unterminated".into()]).unwrap_err();
189 let s = err.to_string();
190 assert!(s.contains("[unterminated"), "missing pattern: {s}");
191 }
192
193 #[test]
194 fn brace_expansion_works() {
195 let scope = s(&["src/**/*.{rs,toml}"]);
196 assert!(scope.matches(Path::new("src/main.rs")));
197 assert!(scope.matches(Path::new("src/Cargo.toml")));
198 assert!(!scope.matches(Path::new("src/README.md")));
199 }
200}