1use crate::config::Config;
4use crate::gherkin::FeatureFormat;
5use crate::source_adapter;
6pub use crate::source_adapter::{SourceFile, SourceLanguage};
7use anyhow::{Context, Result};
8use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder};
9use ignore::WalkBuilder;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13#[non_exhaustive]
15pub struct FeatureFile {
16 pub path: PathBuf,
18 pub format: FeatureFormat,
20 pub pattern: String,
22}
23
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25#[non_exhaustive]
27pub struct DiscoveredFiles {
28 pub features: Vec<FeatureFile>,
30 pub definitions: Vec<SourceFile>,
32 pub errors: Vec<String>,
34 pub unmatched_feature_patterns: Vec<String>,
36 pub unmatched_definition_patterns: Vec<String>,
38}
39
40pub fn discover(config: &Config) -> Result<DiscoveredFiles> {
42 let feature_globs = compile_pattern_matchers(&config.features, "feature")?;
43 let definition_globs = if config.definitions.is_empty() {
44 None
45 } else {
46 Some(compile_pattern_matchers(&config.definitions, "definition")?)
47 };
48 let excludes = compile_globs(&config.exclude, "exclude")?;
49 let mut files = DiscoveredFiles::default();
50
51 let root = config.root.clone();
52 let walk_excludes = excludes.clone();
53 let mut builder = WalkBuilder::new(&config.root);
54 builder
55 .standard_filters(true)
56 .add_custom_ignore_filename(".cuke-dedupignore")
57 .require_git(false)
58 .hidden(!config.include_hidden)
59 .follow_links(false)
60 .filter_entry(move |entry| {
61 if entry.depth() == 0 {
62 return true;
63 }
64 let relative = entry.path().strip_prefix(&root).unwrap_or(entry.path());
65 let directory_form = format!("{}/", relative.to_string_lossy().replace('\\', "/"));
66 !walk_excludes.is_match(relative) && !walk_excludes.is_match(directory_form)
67 });
68 let walker = builder.build();
69
70 for entry in walker {
71 let entry = match entry {
72 Ok(entry) => entry,
73 Err(error) => {
74 files.errors.push(format!(
75 "failed while walking target directory {}: {error}",
76 config.root.display()
77 ));
78 continue;
79 }
80 };
81 match entry.file_type() {
82 Some(file_type) if file_type.is_file() => {}
83 _ => continue,
84 }
85 let path = entry.into_path();
86 let relative = path.strip_prefix(&config.root).unwrap_or(&path);
87 if excludes.is_match(relative) {
88 continue;
89 }
90
91 if let Some((_, pattern)) = feature_globs
92 .iter()
93 .find(|(matcher, _)| matcher.is_match(relative))
94 {
95 files.features.push(FeatureFile {
96 format: FeatureFormat::from_path(relative),
97 path,
98 pattern: pattern.clone(),
99 });
100 continue;
101 }
102
103 if let Some(language) = source_language(relative) {
104 if definition_globs
105 .as_ref()
106 .map(|globs| globs.iter().any(|(matcher, _)| matcher.is_match(relative)))
107 .unwrap_or(true)
108 {
109 files.definitions.push(SourceFile { path, language });
110 }
111 }
112 }
113
114 files
115 .features
116 .sort_by(|left, right| left.path.cmp(&right.path));
117 files.unmatched_feature_patterns = feature_globs
118 .iter()
119 .filter(|(matcher, _)| {
120 !files.features.iter().any(|file| {
121 matcher.is_match(file.path.strip_prefix(&config.root).unwrap_or(&file.path))
122 })
123 })
124 .map(|(_, pattern)| pattern.clone())
125 .collect();
126 files
127 .definitions
128 .sort_by(|left, right| left.path.cmp(&right.path));
129 files.unmatched_definition_patterns = definition_globs
130 .as_ref()
131 .into_iter()
132 .flatten()
133 .filter(|(matcher, _)| {
134 !files.definitions.iter().any(|file| {
135 matcher.is_match(file.path.strip_prefix(&config.root).unwrap_or(&file.path))
136 })
137 })
138 .map(|(_, pattern)| pattern.clone())
139 .collect();
140 Ok(files)
141}
142
143fn compile_pattern_matchers(patterns: &[String], kind: &str) -> Result<Vec<(GlobMatcher, String)>> {
145 patterns
146 .iter()
147 .map(|pattern| {
148 let glob = Glob::new(pattern)
149 .with_context(|| format!("invalid {kind} glob pattern `{pattern}`"))?;
150 Ok((glob.compile_matcher(), pattern.clone()))
151 })
152 .collect()
153}
154
155fn compile_globs(patterns: &[String], kind: &str) -> Result<GlobSet> {
156 let mut builder = GlobSetBuilder::new();
157 for pattern in patterns {
158 builder.add(
159 Glob::new(pattern)
160 .with_context(|| format!("invalid {kind} glob pattern `{pattern}`"))?,
161 );
162 }
163 builder
164 .build()
165 .with_context(|| format!("failed to build {kind} glob set"))
166}
167
168fn source_language(path: &Path) -> Option<SourceLanguage> {
169 source_adapter::language_for_path(path)
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::config::ConfigOverrides;
176 use std::fs;
177
178 #[test]
179 fn discovers_sources_and_features_but_uses_gitignore() {
180 let directory = tempfile::tempdir().unwrap();
181 fs::create_dir_all(directory.path().join("features/steps")).unwrap();
182 fs::create_dir_all(directory.path().join("ignored")).unwrap();
183 fs::write(directory.path().join(".gitignore"), "ignored/\n").unwrap();
184 fs::write(
185 directory.path().join("features/example.feature"),
186 "Feature: x",
187 )
188 .unwrap();
189 fs::write(
190 directory.path().join("features/steps/example.ts"),
191 "Given('x', () => {})",
192 )
193 .unwrap();
194 fs::write(
195 directory.path().join("ignored/step.ts"),
196 "Given('y', () => {})",
197 )
198 .unwrap();
199 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
200
201 let files = discover(&config).unwrap();
202 assert_eq!(files.features.len(), 1);
203 assert_eq!(files.definitions.len(), 1);
204 }
205
206 #[test]
207 fn cuke_dedup_ignore_supports_scoped_gitignore_rules_and_negation() {
208 let directory = tempfile::tempdir().unwrap();
209 fs::create_dir_all(directory.path().join("ignored")).unwrap();
210 fs::create_dir_all(directory.path().join("packages/first")).unwrap();
211 fs::create_dir_all(directory.path().join("packages/second")).unwrap();
212 fs::write(
213 directory.path().join(".cuke-dedupignore"),
214 "ignored/\n*.generated.ts\n!keep.generated.ts\n",
215 )
216 .unwrap();
217 fs::write(
218 directory.path().join("packages/first/.cuke-dedupignore"),
219 "local.ts\n",
220 )
221 .unwrap();
222 for path in [
223 "included.ts",
224 "ignored/step.ts",
225 "drop.generated.ts",
226 "keep.generated.ts",
227 "packages/first/local.ts",
228 "packages/second/local.ts",
229 ] {
230 fs::write(directory.path().join(path), "Given('x', () => work())").unwrap();
231 }
232
233 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
234 let root = config.root.clone();
235 let files = discover(&config).unwrap();
236 let relative = files
237 .definitions
238 .iter()
239 .map(|file| {
240 file.path
241 .strip_prefix(&root)
242 .unwrap()
243 .to_string_lossy()
244 .replace('\\', "/")
245 })
246 .collect::<Vec<_>>();
247
248 assert_eq!(
249 relative,
250 vec![
251 "included.ts",
252 "keep.generated.ts",
253 "packages/second/local.ts"
254 ]
255 );
256 }
257
258 #[test]
259 fn discovers_classic_and_markdown_features_with_parser_metadata() {
260 let directory = tempfile::tempdir().unwrap();
261 fs::create_dir_all(directory.path().join("features")).unwrap();
262 fs::write(directory.path().join("features/a.feature"), "Feature: A").unwrap();
263 fs::write(
264 directory.path().join("features/b.feature.md"),
265 "# Feature: B",
266 )
267 .unwrap();
268 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
269
270 let files = discover(&config).unwrap();
271 assert_eq!(files.features.len(), 2);
272 assert_eq!(files.features[0].format, FeatureFormat::Gherkin);
273 assert_eq!(files.features[1].format, FeatureFormat::GherkinMarkdown);
274 assert!(files.unmatched_feature_patterns.is_empty());
275 }
276
277 #[test]
278 fn overlapping_feature_patterns_are_both_counted_as_matched() {
279 let directory = tempfile::tempdir().unwrap();
280 fs::create_dir_all(directory.path().join("features")).unwrap();
281 fs::write(directory.path().join("features/a.feature"), "Feature: A").unwrap();
282 let config = Config::load(
283 directory.path(),
284 ConfigOverrides {
285 features: Some(vec![
286 "**/*.feature".to_owned(),
287 "features/**/*.feature".to_owned(),
288 ]),
289 ..ConfigOverrides::default()
290 },
291 )
292 .unwrap();
293
294 let files = discover(&config).unwrap();
295 assert!(files.unmatched_feature_patterns.is_empty());
296 }
297
298 #[test]
299 fn configured_definition_globs_narrow_source_files() {
300 let directory = tempfile::tempdir().unwrap();
301 fs::create_dir_all(directory.path().join("steps")).unwrap();
302 fs::write(directory.path().join("steps/a.ts"), "").unwrap();
303 fs::write(directory.path().join("other.ts"), "").unwrap();
304 let overrides = ConfigOverrides {
305 definitions: Some(vec!["steps/**/*.ts".to_owned()]),
306 ..ConfigOverrides::default()
307 };
308 let config = Config::load(directory.path(), overrides).unwrap();
309
310 let files = discover(&config).unwrap();
311 assert_eq!(files.definitions.len(), 1);
312 assert!(files.definitions[0].path.ends_with("steps/a.ts"));
313 assert!(files.unmatched_definition_patterns.is_empty());
314 }
315
316 #[test]
317 fn configured_globs_use_documented_cross_separator_and_brace_semantics() {
318 let globs = compile_globs(
319 &[
320 "*.ts".to_owned(),
321 "features/*.{feature,feature.md}".to_owned(),
322 ],
323 "test",
324 )
325 .unwrap();
326 assert!(globs.is_match(Path::new("nested/deep/steps.ts")));
327 assert!(globs.is_match(Path::new("features/nested/example.feature.md")));
328 assert!(!globs.is_match(Path::new("features/example.md")));
329 }
330
331 #[test]
332 fn reports_each_unmatched_definition_glob() {
333 let directory = tempfile::tempdir().unwrap();
334 fs::write(
335 directory.path().join("steps.ts"),
336 "Given('x', () => work())",
337 )
338 .unwrap();
339 let config = Config::load(
340 directory.path(),
341 ConfigOverrides {
342 definitions: Some(vec!["stpes/**/*.ts".to_owned()]),
343 ..ConfigOverrides::default()
344 },
345 )
346 .unwrap();
347
348 let files = discover(&config).unwrap();
349 assert!(files.definitions.is_empty());
350 assert_eq!(files.unmatched_definition_patterns, ["stpes/**/*.ts"]);
351 }
352
353 #[test]
354 fn declaration_and_source_map_files_are_not_definition_sources() {
355 for path in ["types.d.ts", "types.d.mts", "types.d.cts", "code.js.map"] {
356 assert_eq!(source_language(Path::new(path)), None, "{path}");
357 }
358 }
359
360 #[test]
361 fn built_in_excludes_prune_sources_unless_explicitly_disabled() {
362 let directory = tempfile::tempdir().unwrap();
363 fs::create_dir_all(directory.path().join("node_modules/workspace")).unwrap();
364 fs::write(
365 directory.path().join("node_modules/workspace/steps.ts"),
366 "Given('nested', () => work())",
367 )
368 .unwrap();
369
370 let default_config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
371 assert!(discover(&default_config).unwrap().definitions.is_empty());
372
373 let enabled_config = Config::load(
374 directory.path(),
375 ConfigOverrides {
376 definitions: Some(vec!["node_modules/workspace/**/*.ts".to_owned()]),
377 exclude_defaults: Some(false),
378 ..ConfigOverrides::default()
379 },
380 )
381 .unwrap();
382 let files = discover(&enabled_config).unwrap();
383 assert_eq!(files.definitions.len(), 1);
384 }
385
386 #[test]
387 fn hidden_sources_require_the_include_hidden_option() {
388 let directory = tempfile::tempdir().unwrap();
389 fs::create_dir_all(directory.path().join(".steps")).unwrap();
390 fs::write(
391 directory.path().join(".steps/example.ts"),
392 "Given('hidden', () => work())",
393 )
394 .unwrap();
395 let default_config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
396 assert!(discover(&default_config).unwrap().definitions.is_empty());
397
398 let visible_config = Config::load(
399 directory.path(),
400 ConfigOverrides {
401 definitions: Some(vec![".steps/**/*.ts".to_owned()]),
402 include_hidden: Some(true),
403 ..ConfigOverrides::default()
404 },
405 )
406 .unwrap();
407 assert_eq!(discover(&visible_config).unwrap().definitions.len(), 1);
408 }
409
410 #[cfg(unix)]
411 #[test]
412 fn traversal_errors_are_collected_without_discarding_other_files() {
413 use std::os::unix::fs::PermissionsExt;
414
415 let directory = tempfile::tempdir().unwrap();
416 fs::write(
417 directory.path().join("visible.ts"),
418 "Given('visible', () => work())",
419 )
420 .unwrap();
421 let locked = directory.path().join("locked");
422 fs::create_dir(&locked).unwrap();
423 fs::write(locked.join("hidden.ts"), "Given('hidden', () => work())").unwrap();
424 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
425
426 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
427 let files = discover(&config).unwrap();
428
429 fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).unwrap();
430 assert_eq!(files.definitions.len(), 1);
431 assert!(!files.errors.is_empty());
432 }
433
434 #[cfg(unix)]
435 #[test]
436 fn excluded_directories_are_pruned_before_traversal() {
437 use std::os::unix::fs::PermissionsExt;
438
439 let directory = tempfile::tempdir().unwrap();
440 let locked = directory.path().join("node_modules/locked");
441 fs::create_dir_all(&locked).unwrap();
442 fs::write(locked.join("step.ts"), "Given('hidden', () => work())").unwrap();
443 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
444
445 let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
446 let files = discover(&config).unwrap();
447
448 fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).unwrap();
449 assert!(files.definitions.is_empty());
450 assert!(files.errors.is_empty());
451 }
452}