Skip to main content

ferridriver_test/
discovery.rs

1//! Test discovery: inventory-based collection for Rust, glob-based file scanning.
2
3use 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
16// ── Inventory-based registration (populated by #[ferritest] macro) ──
17
18/// What the `#[ferritest]` proc macro submits via `inventory::submit!`.
19pub struct TestRegistration {
20  pub file: &'static str,
21  /// The `module_path!()` of the test function.
22  /// Used to derive the suite name from the Rust module structure.
23  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  /// Raw JSON string for fixture/context overrides (viewport, locale, etc.)
30  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/// Hook kind tag for inventory registration (no closures — just the discriminant).
37#[derive(Debug, Clone, Copy)]
38pub enum HookKindTag {
39  BeforeAll,
40  AfterAll,
41  BeforeEach,
42  AfterEach,
43}
44
45/// What `#[before_all]` / `#[after_all]` / `#[before_each]` / `#[after_each]` submit.
46pub struct HookRegistration {
47  pub module_path: &'static str,
48  /// For before_all/after_all: `fn(FixturePool) -> Future<Result<(), TestFailure>>`
49  pub suite_hook_fn: Option<fn(FixturePool) -> Pin<Box<dyn Future<Output = Result<(), TestFailure>> + Send>>>,
50  /// For before_each/after_each: `fn(FixturePool, Arc<TestInfo>) -> Future<Result<(), TestFailure>>`
51  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
58// ── Discovery ──
59
60/// Derive suite name from `module_path!()`.
61///
62/// Given `"my_crate::tests::login_tests::test_fn"`, returns `"tests::login_tests"`.
63/// Strips the crate root (first segment) and the test function name (last segment).
64fn suite_from_module_path(mp: &str) -> &str {
65  // Strip last segment (the function name).
66  let without_fn = mp.rsplit_once("::").map_or(mp, |(prefix, _)| prefix);
67  // Strip first segment (the crate name).
68  without_fn.split_once("::").map_or(without_fn, |(_, rest)| rest)
69}
70
71/// Collect all registered tests and build a `TestPlan`.
72pub 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    // Derive suite name from module_path: strip the last segment (fn name).
78    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  // Collect hooks and attach them to matching suites.
110  for reg in inventory::iter::<HookRegistration> {
111    let hook_suite = suite_from_module_path(reg.module_path);
112    // Find the matching suite — hooks attach to the suite derived from their module.
113    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
154/// Discover test files on disk using glob patterns.
155///
156/// # Errors
157///
158/// Returns an error if glob pattern is invalid.
159pub 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      // Check ignore patterns.
176      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
191/// Apply grep, tag, and other filters to a test plan.
192fn apply_filters(mut plan: TestPlan, _config: &TestConfig) -> TestPlan {
193  // Grep is applied at runtime via CLI, not from config file typically.
194  // This is a placeholder for the runner to call with CliOverrides.
195  plan.total_tests = plan.suites.iter().map(|s| s.tests.len()).sum();
196  plan
197}
198
199/// Filter a test plan by grep pattern.
200pub fn filter_by_grep(plan: &mut TestPlan, pattern: &str, invert: bool) {
201  // Build a case-insensitive regex. If the pattern has invalid regex syntax,
202  // fall back to case-insensitive literal substring match.
203  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        // Fallback: case-insensitive substring search.
213        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
222/// Error returned when `--forbid-only` is set and `.only()` markers are found.
223pub 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
237/// Check that no tests or suites have `Only` annotations.
238/// Returns `Err` listing all offending tests if any are found.
239pub 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
259/// Filter a test plan to only `Only`-marked tests/suites.
260/// If no `Only` annotations exist, the plan is unchanged.
261pub 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
286/// Filter a test plan to only tests listed in a rerun file.
287/// The rerun file contains one `file:line` or `file > suite > name` entry per line.
288/// If the file doesn't exist or is empty, logs a warning and runs all tests.
289pub 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
317/// Filter a test plan by tag.
318pub 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}