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