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