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
58fn suite_from_module_path(mp: &str) -> &str {
65 let without_fn = mp.rsplit_once("::").map_or(mp, |(prefix, _)| prefix);
67 without_fn.split_once("::").map_or(without_fn, |(_, rest)| rest)
69}
70
71pub fn collect_rust_tests(config: &TestConfig) -> TestPlan {
73 let mut suites: rustc_hash::FxHashMap<String, TestSuite> = rustc_hash::FxHashMap::default();
74
75 for reg in inventory::iter::<TestRegistration> {
76 let file = reg.file.to_string();
77 let suite_name = suite_from_module_path(reg.module_path);
79 let suite_key = format!("{}::{}", file, suite_name);
80
81 let test_fn_ptr = reg.test_fn;
82 let test_case: TestCase = TestCase {
83 id: TestId {
84 file: file.clone(),
85 suite: Some(suite_name.to_string()),
86 name: reg.name.to_string(),
87 line: None,
88 },
89 test_fn: Arc::new(move |pool| test_fn_ptr(pool)),
90 fixture_requests: reg.fixture_requests.iter().map(|s| (*s).to_string()).collect(),
91 annotations: reg.annotations.to_vec(),
92 timeout: reg.timeout_ms.map(std::time::Duration::from_millis),
93 retries: reg.retries,
94 expected_status: ExpectedStatus::Pass,
95 use_options: reg.use_options.map(|s| serde_json::from_str(s).unwrap_or_default()),
96 };
97
98 let suite = suites.entry(suite_key).or_insert_with(|| TestSuite {
99 name: suite_name.to_string(),
100 file: file.clone(),
101 tests: Vec::new(),
102 hooks: Hooks::default(),
103 annotations: Vec::new(),
104 mode: crate::model::SuiteMode::default(),
105 });
106 suite.tests.push(test_case);
107 }
108
109 for reg in inventory::iter::<HookRegistration> {
111 let hook_suite = suite_from_module_path(reg.module_path);
112 for suite in suites.values_mut() {
114 if suite.name == hook_suite {
115 match reg.kind {
116 HookKindTag::BeforeAll => {
117 if let Some(f) = reg.suite_hook_fn {
118 suite.hooks.before_all.push(Arc::new(move |pool| f(pool)));
119 }
120 },
121 HookKindTag::AfterAll => {
122 if let Some(f) = reg.suite_hook_fn {
123 suite.hooks.after_all.push(Arc::new(move |pool| f(pool)));
124 }
125 },
126 HookKindTag::BeforeEach => {
127 if let Some(f) = reg.each_hook_fn {
128 suite.hooks.before_each.push(Arc::new(move |pool, info| f(pool, info)));
129 }
130 },
131 HookKindTag::AfterEach => {
132 if let Some(f) = reg.each_hook_fn {
133 suite.hooks.after_each.push(Arc::new(move |pool, info| f(pool, info)));
134 }
135 },
136 }
137 }
138 }
139 }
140
141 let suites: Vec<TestSuite> = suites.into_values().collect();
142 let total_tests = suites.iter().map(|s| s.tests.len()).sum();
143
144 apply_filters(
145 TestPlan {
146 suites,
147 total_tests,
148 shard: None,
149 },
150 config,
151 )
152}
153
154pub fn find_test_files(root: &str, patterns: &[String], ignore: &[String]) -> Result<Vec<String>, String> {
160 let mut files = Vec::new();
161
162 for pattern in patterns {
163 let full_pattern = if pattern.starts_with('/') || pattern.starts_with('.') {
164 pattern.clone()
165 } else {
166 format!("{root}/{pattern}")
167 };
168
169 let entries = glob::glob(&full_pattern).map_err(|e| format!("invalid glob pattern '{full_pattern}': {e}"))?;
170
171 for entry in entries {
172 let path = entry.map_err(|e| format!("glob error: {e}"))?;
173 let path_str = path.display().to_string();
174
175 let ignored = ignore
177 .iter()
178 .any(|ig| glob::Pattern::new(ig).map(|p| p.matches(&path_str)).unwrap_or(false));
179
180 if !ignored {
181 files.push(path_str);
182 }
183 }
184 }
185
186 files.sort();
187 files.dedup();
188 Ok(files)
189}
190
191fn apply_filters(mut plan: TestPlan, _config: &TestConfig) -> TestPlan {
193 plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
196 plan
197}
198
199pub fn filter_by_grep(plan: &mut TestPlan, pattern: &str, invert: bool) {
201 let re = regex::RegexBuilder::new(pattern).case_insensitive(true).build().ok();
204 let pattern_lower = pattern.to_lowercase();
205
206 for suite in &mut plan.suites {
207 suite.tests.retain(|test| {
208 let full_name = test.id.full_name();
209 let matches = if let Some(ref r) = re {
210 r.is_match(&full_name)
211 } else {
212 full_name.to_lowercase().contains(&pattern_lower)
214 };
215 if invert { !matches } else { matches }
216 });
217 }
218 plan.suites.retain(|s| !s.tests.is_empty());
219 plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
220}
221
222pub struct ForbidOnlyError {
224 pub tests: Vec<String>,
225}
226
227impl fmt::Display for ForbidOnlyError {
228 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229 writeln!(f, "Error: test.only() found in {} test(s):", self.tests.len())?;
230 for name in &self.tests {
231 writeln!(f, " {name}")?;
232 }
233 Ok(())
234 }
235}
236
237pub fn check_forbid_only(plan: &TestPlan) -> Result<(), ForbidOnlyError> {
240 let mut only_tests: Vec<String> = Vec::new();
241
242 for suite in &plan.suites {
243 let suite_is_only = suite.annotations.iter().any(|a| matches!(a, TestAnnotation::Only));
244 for test in &suite.tests {
245 let test_is_only = test.annotations.iter().any(|a| matches!(a, TestAnnotation::Only));
246 if suite_is_only || test_is_only {
247 only_tests.push(test.id.full_name());
248 }
249 }
250 }
251
252 if only_tests.is_empty() {
253 Ok(())
254 } else {
255 Err(ForbidOnlyError { tests: only_tests })
256 }
257}
258
259pub fn filter_by_only(plan: &mut TestPlan) {
262 let has_only = plan.suites.iter().any(|suite| {
263 suite.annotations.iter().any(|a| matches!(a, TestAnnotation::Only))
264 || suite
265 .tests
266 .iter()
267 .any(|t| t.annotations.iter().any(|a| matches!(a, TestAnnotation::Only)))
268 });
269
270 if !has_only {
271 return;
272 }
273
274 for suite in &mut plan.suites {
275 let suite_is_only = suite.annotations.iter().any(|a| matches!(a, TestAnnotation::Only));
276 if !suite_is_only {
277 suite
278 .tests
279 .retain(|t| t.annotations.iter().any(|a| matches!(a, TestAnnotation::Only)));
280 }
281 }
282 plan.suites.retain(|s| !s.tests.is_empty());
283 plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
284}
285
286pub fn filter_by_rerun(plan: &mut TestPlan, rerun_path: &Path) {
290 let content = match std::fs::read_to_string(rerun_path) {
291 Ok(c) if !c.trim().is_empty() => c,
292 Ok(_) => {
293 tracing::warn!("rerun file {} is empty, running all tests", rerun_path.display());
294 return;
295 },
296 Err(_) => {
297 tracing::warn!("rerun file {} not found, running all tests", rerun_path.display());
298 return;
299 },
300 };
301
302 let rerun_set: rustc_hash::FxHashSet<String> = content
303 .lines()
304 .map(|l| l.trim().to_string())
305 .filter(|l| !l.is_empty())
306 .collect();
307
308 for suite in &mut plan.suites {
309 suite
310 .tests
311 .retain(|test| rerun_set.contains(&test.id.file_location()) || rerun_set.contains(&test.id.full_name()));
312 }
313 plan.suites.retain(|s| !s.tests.is_empty());
314 plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
315}
316
317pub fn filter_by_tag(plan: &mut TestPlan, tag: &str) {
319 for suite in &mut plan.suites {
320 suite.tests.retain(|test| {
321 test
322 .annotations
323 .iter()
324 .any(|a| matches!(a, TestAnnotation::Tag(t) if t == tag))
325 });
326 }
327 plan.suites.retain(|s| !s.tests.is_empty());
328 plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::model::{ExpectedStatus, Hooks, TestCase, TestPlan, TestSuite};
335
336 fn dummy_test(name: &str, annotations: Vec<TestAnnotation>) -> TestCase {
337 TestCase {
338 id: TestId {
339 file: "test.rs".into(),
340 suite: Some("suite".into()),
341 name: name.into(),
342 line: None,
343 },
344 test_fn: Arc::new(|_| Box::pin(async { Ok(()) })),
345 fixture_requests: vec![],
346 annotations,
347 timeout: None,
348 retries: None,
349 expected_status: ExpectedStatus::Pass,
350 use_options: None,
351 }
352 }
353
354 fn make_plan(tests: Vec<TestCase>, suite_annotations: Vec<TestAnnotation>) -> TestPlan {
355 let total = tests.len();
356 TestPlan {
357 suites: vec![TestSuite {
358 name: "suite".into(),
359 file: "test.rs".into(),
360 tests,
361 hooks: Hooks::default(),
362 annotations: suite_annotations,
363 mode: crate::model::SuiteMode::default(),
364 }],
365 total_tests: total,
366 shard: None,
367 }
368 }
369
370 #[test]
371 fn forbid_only_no_only_markers() {
372 let plan = make_plan(vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])], vec![]);
373 assert!(check_forbid_only(&plan).is_ok());
374 }
375
376 #[test]
377 fn forbid_only_detects_test_level_only() {
378 let plan = make_plan(
379 vec![
380 dummy_test("normal", vec![]),
381 dummy_test("focused", vec![TestAnnotation::Only]),
382 ],
383 vec![],
384 );
385 let err = check_forbid_only(&plan).unwrap_err();
386 assert_eq!(err.tests.len(), 1);
387 assert!(err.tests[0].contains("focused"));
388 }
389
390 #[test]
391 fn forbid_only_detects_suite_level_only() {
392 let plan = make_plan(
393 vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])],
394 vec![TestAnnotation::Only],
395 );
396 let err = check_forbid_only(&plan).unwrap_err();
397 assert_eq!(err.tests.len(), 2);
398 }
399
400 #[test]
401 fn filter_by_only_keeps_only_marked_tests() {
402 let mut plan = make_plan(
403 vec![
404 dummy_test("normal1", vec![]),
405 dummy_test("focused", vec![TestAnnotation::Only]),
406 dummy_test("normal2", vec![]),
407 ],
408 vec![],
409 );
410 filter_by_only(&mut plan);
411 assert_eq!(plan.total_tests, 1);
412 assert_eq!(plan.suites[0].tests[0].id.name, "focused");
413 }
414
415 #[test]
416 fn filter_by_only_no_only_keeps_all() {
417 let mut plan = make_plan(vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])], vec![]);
418 filter_by_only(&mut plan);
419 assert_eq!(plan.total_tests, 2);
420 }
421
422 #[test]
423 fn filter_by_only_suite_level_keeps_all_in_suite() {
424 let mut plan = make_plan(
425 vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])],
426 vec![TestAnnotation::Only],
427 );
428 filter_by_only(&mut plan);
429 assert_eq!(plan.total_tests, 2);
430 }
431
432 #[test]
433 fn forbid_only_error_message_format() {
434 let plan = make_plan(vec![dummy_test("focused", vec![TestAnnotation::Only])], vec![]);
435 let err = check_forbid_only(&plan).unwrap_err();
436 let msg = err.to_string();
437 assert!(msg.contains("test.only() found in 1 test(s)"));
438 assert!(msg.contains("focused"));
439 }
440
441 #[test]
442 fn filter_by_rerun_keeps_matching_tests() {
443 let dir = std::env::temp_dir().join("ferritest_rerun_test");
444 std::fs::create_dir_all(&dir).unwrap();
445 let rerun_path = dir.join("@rerun.txt");
446 std::fs::write(&rerun_path, "test.rs:10\n").unwrap();
447
448 let mut plan = make_plan(
449 vec![
450 {
451 let mut t = dummy_test("match", vec![]);
452 t.id.line = Some(10);
453 t
454 },
455 dummy_test("nomatch", vec![]),
456 ],
457 vec![],
458 );
459 filter_by_rerun(&mut plan, &rerun_path);
460 assert_eq!(plan.total_tests, 1);
461 assert_eq!(plan.suites[0].tests[0].id.name, "match");
462
463 std::fs::remove_dir_all(&dir).ok();
464 }
465
466 #[test]
467 fn filter_by_rerun_missing_file_keeps_all() {
468 let mut plan = make_plan(vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])], vec![]);
469 filter_by_rerun(&mut plan, Path::new("/nonexistent/@rerun.txt"));
470 assert_eq!(plan.total_tests, 2);
471 }
472
473 #[test]
474 fn filter_by_rerun_empty_file_keeps_all() {
475 let dir = std::env::temp_dir().join("ferritest_rerun_empty");
476 std::fs::create_dir_all(&dir).unwrap();
477 let rerun_path = dir.join("@rerun.txt");
478 std::fs::write(&rerun_path, " \n").unwrap();
479
480 let mut plan = make_plan(vec![dummy_test("test1", vec![]), dummy_test("test2", vec![])], vec![]);
481 filter_by_rerun(&mut plan, &rerun_path);
482 assert_eq!(plan.total_tests, 2);
483
484 std::fs::remove_dir_all(&dir).ok();
485 }
486
487 #[test]
488 fn filter_by_rerun_matches_full_name() {
489 let dir = std::env::temp_dir().join("ferritest_rerun_fullname");
490 std::fs::create_dir_all(&dir).unwrap();
491 let rerun_path = dir.join("@rerun.txt");
492 std::fs::write(&rerun_path, "test.rs > suite > focused\n").unwrap();
493
494 let mut plan = make_plan(vec![dummy_test("focused", vec![]), dummy_test("other", vec![])], vec![]);
495 filter_by_rerun(&mut plan, &rerun_path);
496 assert_eq!(plan.total_tests, 1);
497 assert_eq!(plan.suites[0].tests[0].id.name, "focused");
498
499 std::fs::remove_dir_all(&dir).ok();
500 }
501}