use std::collections::HashMap;
use std::path::{Path, PathBuf};
use bynk_emit::project::Roots;
#[derive(Debug)]
pub struct DiscoveryError {
path: PathBuf,
source: std::io::Error,
}
impl std::fmt::Display for DiscoveryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"could not read project files under `{}`: {}",
self.path.display(),
self.source
)
}
}
impl std::error::Error for DiscoveryError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
pub fn list_asset_folder(
project_root: &Path,
) -> Result<bynk_emit::project::assets::AssetFolder, DiscoveryError> {
bynk_emit::project::assets::AssetFolder::on_disk(project_root).map_err(|source| {
DiscoveryError {
path: project_root.join(bynk_emit::project::assets::PROJECT_ASSETS_DIR),
source,
}
})
}
pub fn list_embed_folder(
project_root: &Path,
) -> Result<bynk_emit::project::assets::EmbedFolder, DiscoveryError> {
bynk_emit::project::assets::AssetFolder::embeds_on_disk(project_root).map_err(|source| {
DiscoveryError {
path: project_root.join(bynk_emit::project::assets::PROJECT_EMBEDS_DIR),
source,
}
})
}
pub fn read_bynk_tree(
root: &Path,
excludes: &[PathBuf],
) -> Result<HashMap<PathBuf, String>, DiscoveryError> {
let mut out = HashMap::new();
let is_excluded = |dir: &Path| {
excludes.iter().any(|ex| dir == ex || dir.starts_with(ex))
|| dir
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with('.') && n != ".")
};
let to_err = |path: &Path, source: std::io::Error| DiscoveryError {
path: path.to_path_buf(),
source,
};
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let rd = std::fs::read_dir(&dir).map_err(|e| to_err(&dir, e))?;
for entry in rd {
let entry = entry.map_err(|e| to_err(&dir, e))?;
let p = entry.path();
if p.is_dir() {
if !is_excluded(&p) {
stack.push(p);
}
} else if p.extension().and_then(|e| e.to_str()) == Some("bynk") {
let text = std::fs::read_to_string(&p).map_err(|e| to_err(&p, e))?;
out.insert(p, text);
}
}
}
Ok(out)
}
pub fn read_bynk_tree_single(root: &Path) -> Result<HashMap<PathBuf, String>, DiscoveryError> {
read_bynk_tree(root, &[])
}
pub fn read_bynk_tree_split(
trees: &[PathBuf],
excludes: &[PathBuf],
) -> Result<HashMap<PathBuf, String>, DiscoveryError> {
let mut out = HashMap::new();
for (i, root) in trees.iter().enumerate() {
match read_bynk_tree(root, excludes) {
Ok(files) => out.extend(files),
Err(e)
if i > 0 && &e.path == root && e.source.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
}
Ok(out)
}
pub fn sources_for_roots(roots: &Roots) -> Result<HashMap<PathBuf, String>, DiscoveryError> {
let mut trees: Vec<PathBuf> = roots.trees().into_iter().map(|(root, _)| root).collect();
if let Roots::Split { project_root, .. } = roots {
trees.extend(
bynk_emit::project::dependency_trees(project_root, &HashMap::new())
.into_iter()
.map(|(root, _)| root),
);
}
read_bynk_tree_split(&trees, &roots.excludes())
}
#[cfg(test)]
mod tests {
use super::*;
use bynk_emit::project::{ProjectPaths, try_read_project_paths};
use std::fs;
struct Scratch(PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn scratch(tag: &str, files: &[(&str, &str)]) -> Scratch {
let dir = std::env::temp_dir().join(format!(
"bynk_driver_discovery_{tag}_{}_{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&dir);
for (rel, body) in files {
let p = dir.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, body).unwrap();
}
Scratch(dir)
}
#[test]
fn walks_nested_files_and_skips_hidden_directories() {
let root = scratch(
"hidden",
&[
("a.bynk", "context a\n"),
("nested/b.bynk", "context b\n"),
(".git/c.bynk", "context c\n"),
],
);
let found = read_bynk_tree(&root.0, &[]).expect("a real tree must not error");
assert_eq!(
found.len(),
2,
"found: {:?}",
found.keys().collect::<Vec<_>>()
);
assert!(found.contains_key(&root.0.join("a.bynk")));
assert!(found.contains_key(&root.0.join("nested/b.bynk")));
}
#[test]
fn excludes_skip_the_named_subtree() {
let root = scratch(
"excludes",
&[("a.bynk", "context a\n"), ("out/b.bynk", "context b\n")],
);
let found = read_bynk_tree(&root.0, &[root.0.join("out")]).expect("must not error");
assert_eq!(found.len(), 1);
assert!(found.contains_key(&root.0.join("a.bynk")));
}
#[test]
fn an_empty_tree_is_not_an_error() {
let root = scratch("empty", &[(".keep", "")]);
let found = read_bynk_tree(&root.0, &[]).expect("an empty tree is a valid, empty result");
assert!(found.is_empty());
}
#[test]
fn a_missing_root_is_a_discovery_error_not_a_panic() {
let missing = std::env::temp_dir().join(format!(
"bynk_driver_discovery_missing_{}_{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&missing);
let err = read_bynk_tree(&missing, &[]).expect_err("a nonexistent root must not panic");
assert!(err.to_string().contains(&missing.display().to_string()));
}
#[test]
fn split_secondary_root_is_optional_but_primary_is_not() {
let root = scratch("split_no_secondary", &[("src/a.bynk", "context a\n")]);
let found = read_bynk_tree_split(&[root.0.join("src"), root.0.join("tests")], &[])
.expect("a missing secondary root is tolerated");
assert_eq!(found.len(), 1);
let missing_primary = root.0.join("does-not-exist");
read_bynk_tree_split(&[missing_primary, root.0.join("tests")], &[])
.expect_err("a missing primary root must error, not panic");
}
#[cfg(unix)]
#[test]
fn a_dangling_symlink_deep_in_an_optional_tree_is_a_real_error_not_a_silent_drop() {
let root = scratch(
"split_dangling_symlink",
&[("src/a.bynk", "context a\n"), ("tests/b.bynk", "suite a\n")],
);
std::os::unix::fs::symlink(
root.0.join("tests/does-not-exist.bynk"),
root.0.join("tests/dangling.bynk"),
)
.expect("symlink creation must succeed on unix");
let err = read_bynk_tree_split(&[root.0.join("src"), root.0.join("tests")], &[])
.expect_err("a NotFound deep in an existing optional tree's walk must still error");
assert!(
err.to_string().contains("dangling.bynk"),
"the error must name the file that actually failed: {err}"
);
}
#[test]
fn sources_for_roots_merges_three_or_more_include_roots() {
let root = scratch(
"many_includes",
&[
(
"bynk.toml",
"[project]\nname = \"x\"\n\n[paths]\ninclude = [\"src\", \"tests\", \"examples\"]\n",
),
("src/a.bynk", "context a\n"),
("tests/a.bynk", "suite a\n"),
("examples/e.bynk", "context e\n"),
],
);
let paths: ProjectPaths =
try_read_project_paths(&root.0).expect("well-formed fixture manifest");
let roots = Roots::Split {
project_root: root.0.clone(),
paths,
};
let found = sources_for_roots(&roots).expect("a well-formed project must not error");
assert_eq!(
found.len(),
3,
"found: {:?}",
found.keys().collect::<Vec<_>>()
);
assert!(found.contains_key(&root.0.join("src/a.bynk")));
assert!(found.contains_key(&root.0.join("tests/a.bynk")));
assert!(found.contains_key(&root.0.join("examples/e.bynk")));
}
#[test]
fn sources_for_roots_matches_a_conventional_split_project() {
let root = scratch(
"conventional",
&[
("bynk.toml", "[project]\nname = \"x\"\n"),
("src/a.bynk", "context a\n"),
("tests/a.bynk", "suite a\n"),
],
);
let paths: ProjectPaths =
try_read_project_paths(&root.0).expect("well-formed fixture manifest");
let roots = Roots::Split {
project_root: root.0.clone(),
paths,
};
let found = sources_for_roots(&roots).expect("a well-formed project must not error");
assert_eq!(found.len(), 2);
assert!(found.contains_key(&root.0.join("src/a.bynk")));
assert!(found.contains_key(&root.0.join("tests/a.bynk")));
}
#[test]
fn sources_for_roots_on_a_flat_include_root_excludes_its_own_caches() {
let root = scratch(
"flat",
&[
("bynk.toml", "[project]\nname = \"x\"\n"),
("a.bynk", "context a\n"),
("node_modules/dep.bynk", "context dep\n"),
("out/built.bynk", "context built\n"),
],
);
let paths = ProjectPaths {
include: vec![PathBuf::from(".")],
exclude: Vec::new(),
};
let roots = Roots::Split {
project_root: root.0.clone(),
paths,
};
let found = sources_for_roots(&roots).expect("must not error");
assert_eq!(
found.len(),
1,
"found: {:?}",
found.keys().collect::<Vec<_>>()
);
assert!(found.contains_key(&root.0.join("a.bynk")));
}
}