Skip to main content

ferridriver_script/
discover.rs

1//! Canonical source-file discovery for extensions and BDD step files.
2//!
3//! Both hosts (the MCP server's plugin loader and the BDD runner's
4//! extension/step discovery) must agree on which file extensions count
5//! as loadable source and must walk directories the same way — otherwise
6//! a `.tsx` extension visible to the test runner is invisible to the MCP
7//! server, which is exactly the inconsistency this module removes.
8
9use std::path::{Path, PathBuf};
10
11/// Extensions rolldown can bundle as an ESM entry. Superset of what
12/// either host accepted before: `.cts`/`.cjs`/`.tsx`/`.jsx`/`.mts`/
13/// `.mjs` are all valid rolldown entries, so all hosts accept them.
14pub const SOURCE_EXTENSIONS: &[&str] = &["js", "cjs", "mjs", "jsx", "ts", "cts", "mts", "tsx"];
15
16/// True when `path` has a bundleable source extension.
17#[must_use]
18pub fn is_source_file(path: &Path) -> bool {
19  path
20    .extension()
21    .and_then(|e| e.to_str())
22    .is_some_and(|ext| SOURCE_EXTENSIONS.contains(&ext))
23}
24
25/// Recursively collect every source file under `dir` (sorted, stable).
26/// A non-directory or unreadable entry yields an empty result rather
27/// than an error — discovery is best-effort; the caller surfaces "no
28/// files found" once, with context.
29#[must_use]
30pub fn walk_source_files(dir: &Path) -> Vec<PathBuf> {
31  let mut out = Vec::new();
32  walk_into(dir, &mut out);
33  out.sort();
34  out.dedup();
35  out
36}
37
38fn walk_into(dir: &Path, out: &mut Vec<PathBuf>) {
39  let Ok(rd) = std::fs::read_dir(dir) else { return };
40  for entry in rd.flatten() {
41    let p = entry.path();
42    if p.is_dir() {
43      walk_into(&p, out);
44    } else if p.is_file() && is_source_file(&p) {
45      out.push(p);
46    }
47  }
48}
49
50#[cfg(test)]
51mod tests {
52  use super::*;
53
54  #[test]
55  fn accepts_the_full_source_set_rejects_others() {
56    for ext in ["js", "cjs", "mjs", "jsx", "ts", "cts", "mts", "tsx"] {
57      assert!(is_source_file(Path::new(&format!("a.{ext}"))), "{ext} should be source");
58    }
59    for ext in ["txt", "json", "map", ""] {
60      assert!(
61        !is_source_file(Path::new(&format!("a.{ext}"))),
62        "{ext} must not be source"
63      );
64    }
65  }
66
67  #[test]
68  fn walk_recurses_nested_directories() {
69    let tmp = tempfile::tempdir().expect("tempdir");
70    let root = tmp.path();
71    std::fs::create_dir_all(root.join("a/b")).unwrap();
72    std::fs::write(root.join("top.ts"), "").unwrap();
73    std::fs::write(root.join("a/mid.tsx"), "").unwrap();
74    std::fs::write(root.join("a/b/deep.cts"), "").unwrap();
75    std::fs::write(root.join("a/b/skip.txt"), "").unwrap();
76
77    let found = walk_source_files(root);
78    let names: Vec<_> = found
79      .iter()
80      .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
81      .collect();
82    assert_eq!(
83      names,
84      vec!["deep.cts", "mid.tsx", "top.ts"],
85      "recursive + sorted, .txt excluded"
86    );
87  }
88}