1use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6
7use crate::config::TestConfig;
8use crate::fixture::FixturePool;
9use std::fmt;
10use std::path::Path;
11
12use crate::model::{
13 ExpectedStatus, Hooks, TestAnnotation, TestCase, TestFailure, TestId, TestInfo, TestPlan, TestSuite,
14};
15
16pub struct TestRegistration {
20 pub file: &'static str,
21 pub module_path: &'static str,
24 pub name: &'static str,
25 pub fixture_requests: &'static [&'static str],
26 pub annotations: &'static [TestAnnotation],
27 pub timeout_ms: Option<u64>,
28 pub retries: Option<u32>,
29 pub use_options: Option<&'static str>,
31 pub test_fn: fn(FixturePool) -> Pin<Box<dyn Future<Output = Result<(), TestFailure>> + Send>>,
32}
33
34inventory::collect!(TestRegistration);
35
36#[derive(Debug, Clone, Copy)]
38pub enum HookKindTag {
39 BeforeAll,
40 AfterAll,
41 BeforeEach,
42 AfterEach,
43}
44
45pub struct HookRegistration {
47 pub module_path: &'static str,
48 pub suite_hook_fn: Option<fn(FixturePool) -> Pin<Box<dyn Future<Output = Result<(), TestFailure>> + Send>>>,
50 pub each_hook_fn:
52 Option<fn(FixturePool, Arc<TestInfo>) -> Pin<Box<dyn Future<Output = Result<(), TestFailure>> + Send>>>,
53 pub kind: HookKindTag,
54}
55
56inventory::collect!(HookRegistration);
57
58pub struct FixtureRegistration {
63 pub name: &'static str,
64 pub module_path: &'static str,
65 pub build: fn() -> crate::fixture::FixtureDef,
66}
67
68inventory::collect!(FixtureRegistration);
69
70pub struct SuiteModeRegistration {
73 pub module_path: &'static str,
74 pub mode: crate::model::SuiteMode,
75}
76
77inventory::collect!(SuiteModeRegistration);
78
79pub fn collect_rust_fixtures() -> rustc_hash::FxHashMap<String, crate::fixture::FixtureDef> {
83 let mut defs = rustc_hash::FxHashMap::default();
84 for reg in inventory::iter::<FixtureRegistration> {
85 defs.insert(reg.name.to_string(), (reg.build)());
86 }
87 defs
88}
89
90fn suite_from_module_path(mp: &str) -> &str {
99 mp.split_once("::").map_or(mp, |(_, rest)| rest)
100}
101
102pub fn collect_rust_tests(config: &TestConfig) -> TestPlan {
104 let mut suites: rustc_hash::FxHashMap<String, TestSuite> = rustc_hash::FxHashMap::default();
105
106 for reg in inventory::iter::<TestRegistration> {
107 let file = reg.file.to_string();
108 let suite_name = suite_from_module_path(reg.module_path);
110 let suite_key = format!("{}::{}", file, suite_name);
111
112 let test_fn_ptr = reg.test_fn;
113 let test_case: TestCase = TestCase {
114 id: TestId {
115 file: file.clone(),
116 suite: Some(suite_name.to_string()),
117 name: reg.name.to_string(),
118 line: None,
119 },
120 test_fn: Arc::new(move |pool| test_fn_ptr(pool)),
121 fixture_requests: reg.fixture_requests.iter().map(|s| (*s).to_string()).collect(),
122 annotations: reg.annotations.to_vec(),
123 timeout: reg.timeout_ms.map(std::time::Duration::from_millis),
124 retries: reg.retries,
125 expected_status: ExpectedStatus::Pass,
126 use_options: reg.use_options.map(|s| serde_json::from_str(s).unwrap_or_default()),
127 };
128
129 let suite = suites.entry(suite_key).or_insert_with(|| TestSuite {
130 name: suite_name.to_string(),
131 file: file.clone(),
132 tests: Vec::new(),
133 hooks: Hooks::default(),
134 annotations: Vec::new(),
135 mode: crate::model::SuiteMode::default(),
136 });
137 suite.tests.push(test_case);
138 }
139
140 for reg in inventory::iter::<HookRegistration> {
142 let hook_suite = suite_from_module_path(reg.module_path);
143 for suite in suites.values_mut() {
145 if suite.name == hook_suite {
146 match reg.kind {
147 HookKindTag::BeforeAll => {
148 if let Some(f) = reg.suite_hook_fn {
149 suite.hooks.before_all.push(Arc::new(move |pool| f(pool)));
150 }
151 },
152 HookKindTag::AfterAll => {
153 if let Some(f) = reg.suite_hook_fn {
154 suite.hooks.after_all.push(Arc::new(move |pool| f(pool)));
155 }
156 },
157 HookKindTag::BeforeEach => {
158 if let Some(f) = reg.each_hook_fn {
159 suite.hooks.before_each.push(Arc::new(move |pool, info| f(pool, info)));
160 }
161 },
162 HookKindTag::AfterEach => {
163 if let Some(f) = reg.each_hook_fn {
164 suite.hooks.after_each.push(Arc::new(move |pool, info| f(pool, info)));
165 }
166 },
167 }
168 }
169 }
170 }
171
172 for reg in inventory::iter::<SuiteModeRegistration> {
175 let reg_suite = suite_from_module_path(reg.module_path);
176 for suite in suites.values_mut() {
177 if suite.name == reg_suite {
178 suite.mode = reg.mode;
179 }
180 }
181 }
182
183 let suites: Vec<TestSuite> = suites.into_values().collect();
184 let total_tests = suites.iter().map(|s| s.tests.len()).sum();
185
186 apply_filters(
187 TestPlan {
188 suites,
189 total_tests,
190 shard: None,
191 },
192 config,
193 )
194}
195
196pub fn find_test_files(root: &str, patterns: &[String], ignore: &[String]) -> Result<Vec<String>, String> {
202 let mut files = Vec::new();
203
204 for pattern in patterns {
205 let full_pattern = if pattern.starts_with('/') || pattern.starts_with('.') {
206 pattern.clone()
207 } else {
208 format!("{root}/{pattern}")
209 };
210
211 let entries = glob::glob(&full_pattern).map_err(|e| format!("invalid glob pattern '{full_pattern}': {e}"))?;
212
213 for entry in entries {
214 let path = entry.map_err(|e| format!("glob error: {e}"))?;
215 let path_str = path.display().to_string();
216
217 let ignored = ignore
219 .iter()
220 .any(|ig| glob::Pattern::new(ig).map(|p| p.matches(&path_str)).unwrap_or(false));
221
222 if !ignored {
223 files.push(path_str);
224 }
225 }
226 }
227
228 files.sort();
229 files.dedup();
230 Ok(files)
231}
232
233fn apply_filters(mut plan: TestPlan, _config: &TestConfig) -> TestPlan {
235 plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
238 plan
239}
240
241pub fn filter_by_grep(plan: &mut TestPlan, pattern: &str, invert: bool) {
243 let re = regex::RegexBuilder::new(pattern).case_insensitive(true).build().ok();
246 let pattern_lower = pattern.to_lowercase();
247
248 for suite in &mut plan.suites {
249 suite.tests.retain(|test| {
250 let full_name = test.id.full_name();
251 let matches = if let Some(ref r) = re {
252 r.is_match(&full_name)
253 } else {
254 full_name.to_lowercase().contains(&pattern_lower)
256 };
257 if invert { !matches } else { matches }
258 });
259 }
260 plan.suites.retain(|s| !s.tests.is_empty());
261 plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
262}
263
264pub struct ForbidOnlyError {
266 pub tests: Vec<String>,
267}
268
269impl fmt::Display for ForbidOnlyError {
270 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271 writeln!(f, "Error: test.only() found in {} test(s):", self.tests.len())?;
272 for name in &self.tests {
273 writeln!(f, " {name}")?;
274 }
275 Ok(())
276 }
277}
278
279pub fn check_forbid_only(plan: &TestPlan) -> Result<(), ForbidOnlyError> {
282 let mut only_tests: Vec<String> = Vec::new();
283
284 for suite in &plan.suites {
285 let suite_is_only = suite.annotations.iter().any(|a| matches!(a, TestAnnotation::Only));
286 for test in &suite.tests {
287 let test_is_only = test.annotations.iter().any(|a| matches!(a, TestAnnotation::Only));
288 if suite_is_only || test_is_only {
289 only_tests.push(test.id.full_name());
290 }
291 }
292 }
293
294 if only_tests.is_empty() {
295 Ok(())
296 } else {
297 Err(ForbidOnlyError { tests: only_tests })
298 }
299}
300
301pub fn filter_by_only(plan: &mut TestPlan) {
304 let has_only = plan.suites.iter().any(|suite| {
305 suite.annotations.iter().any(|a| matches!(a, TestAnnotation::Only))
306 || suite
307 .tests
308 .iter()
309 .any(|t| t.annotations.iter().any(|a| matches!(a, TestAnnotation::Only)))
310 });
311
312 if !has_only {
313 return;
314 }
315
316 for suite in &mut plan.suites {
317 let suite_is_only = suite.annotations.iter().any(|a| matches!(a, TestAnnotation::Only));
318 if !suite_is_only {
319 suite
320 .tests
321 .retain(|t| t.annotations.iter().any(|a| matches!(a, TestAnnotation::Only)));
322 }
323 }
324 plan.suites.retain(|s| !s.tests.is_empty());
325 plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
326}
327
328pub fn filter_by_rerun(plan: &mut TestPlan, rerun_path: &Path) {
332 let content = match std::fs::read_to_string(rerun_path) {
333 Ok(c) if !c.trim().is_empty() => c,
334 Ok(_) => {
335 tracing::warn!("rerun file {} is empty, running all tests", rerun_path.display());
336 return;
337 },
338 Err(_) => {
339 tracing::warn!("rerun file {} not found, running all tests", rerun_path.display());
340 return;
341 },
342 };
343
344 let rerun_set: rustc_hash::FxHashSet<String> = content
345 .lines()
346 .map(|l| l.trim().to_string())
347 .filter(|l| !l.is_empty())
348 .collect();
349
350 for suite in &mut plan.suites {
351 suite
352 .tests
353 .retain(|test| rerun_set.contains(&test.id.file_location()) || rerun_set.contains(&test.id.full_name()));
354 }
355 plan.suites.retain(|s| !s.tests.is_empty());
356 plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
357}
358
359pub fn filter_by_tag(plan: &mut TestPlan, tag: &str) {
361 for suite in &mut plan.suites {
362 suite.tests.retain(|test| {
363 test
364 .annotations
365 .iter()
366 .any(|a| matches!(a, TestAnnotation::Tag(t) if t == tag))
367 });
368 }
369 plan.suites.retain(|s| !s.tests.is_empty());
370 plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use crate::model::{ExpectedStatus, Hooks, TestCase, TestPlan, TestSuite};
377
378 fn dummy_test(name: &str, annotations: Vec<TestAnnotation>) -> TestCase {
379 TestCase {
380 id: TestId {
381 file: "test.rs".into(),
382 suite: Some("suite".into()),
383 name: name.into(),
384 line: None,
385 },
386 test_fn: Arc::new(|_| Box::pin(async { Ok(()) })),
387 fixture_requests: vec![],
388 annotations,
389 timeout: None,
390 retries: None,
391 expected_status: ExpectedStatus::Pass,
392 use_options: None,
393 }
394 }
395
396 fn make_plan(tests: Vec<TestCase>, suite_annotations: Vec<TestAnnotation>) -> TestPlan {
397 let total = tests.len();
398 TestPlan {
399 suites: vec![TestSuite {
400 name: "suite".into(),
401 file: "test.rs".into(),
402 tests,
403 hooks: Hooks::default(),
404 annotations: suite_annotations,
405 mode: crate::model::SuiteMode::default(),
406 }],
407 total_tests: total,
408 shard: None,
409 }
410 }
411
412 #[test]
413 fn forbid_only_no_only_markers() {
414 let plan = make_plan(vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])], vec![]);
415 assert!(check_forbid_only(&plan).is_ok());
416 }
417
418 #[test]
419 fn forbid_only_detects_test_level_only() {
420 let plan = make_plan(
421 vec![
422 dummy_test("normal", vec![]),
423 dummy_test("focused", vec![TestAnnotation::Only]),
424 ],
425 vec![],
426 );
427 let err = check_forbid_only(&plan).unwrap_err();
428 assert_eq!(err.tests.len(), 1);
429 assert!(err.tests[0].contains("focused"));
430 }
431
432 #[test]
433 fn forbid_only_detects_suite_level_only() {
434 let plan = make_plan(
435 vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])],
436 vec![TestAnnotation::Only],
437 );
438 let err = check_forbid_only(&plan).unwrap_err();
439 assert_eq!(err.tests.len(), 2);
440 }
441
442 #[test]
443 fn filter_by_only_keeps_only_marked_tests() {
444 let mut plan = make_plan(
445 vec![
446 dummy_test("normal1", vec![]),
447 dummy_test("focused", vec![TestAnnotation::Only]),
448 dummy_test("normal2", vec![]),
449 ],
450 vec![],
451 );
452 filter_by_only(&mut plan);
453 assert_eq!(plan.total_tests, 1);
454 assert_eq!(plan.suites[0].tests[0].id.name, "focused");
455 }
456
457 #[test]
458 fn filter_by_only_no_only_keeps_all() {
459 let mut plan = make_plan(vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])], vec![]);
460 filter_by_only(&mut plan);
461 assert_eq!(plan.total_tests, 2);
462 }
463
464 #[test]
465 fn filter_by_only_suite_level_keeps_all_in_suite() {
466 let mut plan = make_plan(
467 vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])],
468 vec![TestAnnotation::Only],
469 );
470 filter_by_only(&mut plan);
471 assert_eq!(plan.total_tests, 2);
472 }
473
474 #[test]
475 fn forbid_only_error_message_format() {
476 let plan = make_plan(vec![dummy_test("focused", vec![TestAnnotation::Only])], vec![]);
477 let err = check_forbid_only(&plan).unwrap_err();
478 let msg = err.to_string();
479 assert!(msg.contains("test.only() found in 1 test(s)"));
480 assert!(msg.contains("focused"));
481 }
482
483 #[test]
484 fn filter_by_rerun_keeps_matching_tests() {
485 let dir = std::env::temp_dir().join("ferritest_rerun_test");
486 std::fs::create_dir_all(&dir).unwrap();
487 let rerun_path = dir.join("@rerun.txt");
488 std::fs::write(&rerun_path, "test.rs:10\n").unwrap();
489
490 let mut plan = make_plan(
491 vec![
492 {
493 let mut t = dummy_test("match", vec![]);
494 t.id.line = Some(10);
495 t
496 },
497 dummy_test("nomatch", vec![]),
498 ],
499 vec![],
500 );
501 filter_by_rerun(&mut plan, &rerun_path);
502 assert_eq!(plan.total_tests, 1);
503 assert_eq!(plan.suites[0].tests[0].id.name, "match");
504
505 std::fs::remove_dir_all(&dir).ok();
506 }
507
508 #[test]
509 fn filter_by_rerun_missing_file_keeps_all() {
510 let mut plan = make_plan(vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])], vec![]);
511 filter_by_rerun(&mut plan, Path::new("/nonexistent/@rerun.txt"));
512 assert_eq!(plan.total_tests, 2);
513 }
514
515 #[test]
516 fn filter_by_rerun_empty_file_keeps_all() {
517 let dir = std::env::temp_dir().join("ferritest_rerun_empty");
518 std::fs::create_dir_all(&dir).unwrap();
519 let rerun_path = dir.join("@rerun.txt");
520 std::fs::write(&rerun_path, " \n").unwrap();
521
522 let mut plan = make_plan(vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])], vec![]);
523 filter_by_rerun(&mut plan, &rerun_path);
524 assert_eq!(plan.total_tests, 2);
525
526 std::fs::remove_dir_all(&dir).ok();
527 }
528
529 #[test]
530 fn filter_by_rerun_matches_full_name() {
531 let dir = std::env::temp_dir().join("ferritest_rerun_fullname");
532 std::fs::create_dir_all(&dir).unwrap();
533 let rerun_path = dir.join("@rerun.txt");
534 std::fs::write(&rerun_path, "test.rs > suite > focused\n").unwrap();
535
536 let mut plan = make_plan(vec![dummy_test("focused", vec![]), dummy_test("other", vec![])], vec![]);
537 filter_by_rerun(&mut plan, &rerun_path);
538 assert_eq!(plan.total_tests, 1);
539 assert_eq!(plan.suites[0].tests[0].id.name, "focused");
540
541 std::fs::remove_dir_all(&dir).ok();
542 }
543}