use std::collections::HashMap;
use std::fs;
use std::path::{Component, Path, PathBuf};
use crate::discovery::read_source;
use crate::json::json_string;
pub fn is_unpinned_range(range: &str) -> bool {
let r = range.trim();
if r.is_empty() || r == "*" || r.eq_ignore_ascii_case("x") || r.eq_ignore_ascii_case("latest") {
return true;
}
!r.chars().any(|c| c.is_ascii_digit())
}
pub fn render_package_json(deps: &std::collections::BTreeMap<String, String>) -> String {
let mut out = String::from("{\n \"dependencies\": {\n");
let entries: Vec<String> = deps
.iter()
.map(|(pkg, range)| format!(" {}: {}", json_string(pkg), json_string(range)))
.collect();
out.push_str(&entries.join(",\n"));
out.push_str("\n }\n}\n");
out
}
pub fn normalize_rel(p: &Path) -> PathBuf {
let mut out: Vec<std::ffi::OsString> = Vec::new();
for c in p.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
Component::Normal(s) => out.push(s.to_os_string()),
Component::RootDir | Component::Prefix(_) => {}
}
}
out.iter().collect()
}
#[derive(Debug, Clone)]
pub struct ProjectPaths {
pub include: Vec<PathBuf>,
pub exclude: Vec<PathBuf>,
}
impl ProjectPaths {
pub fn conventional(project_root: &Path) -> Self {
let mut include = Vec::new();
for role in ["src", "tests"] {
if project_root.join(role).is_dir() {
include.push(PathBuf::from(role));
}
}
if include.is_empty() {
include.push(PathBuf::from("."));
}
ProjectPaths {
include,
exclude: Vec::new(),
}
}
}
#[cfg(test)]
pub(crate) fn read_project_paths_with(
project_root: &Path,
overlay: &HashMap<PathBuf, String>,
) -> ProjectPaths {
try_read_project_paths_with(project_root, overlay)
.unwrap_or_else(|_| ProjectPaths::conventional(project_root))
}
#[derive(Debug)]
pub enum ProjectPathsError {
Malformed,
UnknownKey(String),
}
impl std::fmt::Display for ProjectPathsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProjectPathsError::Malformed => write!(f, "`bynk.toml` is not valid TOML"),
ProjectPathsError::UnknownKey(k) => {
write!(
f,
"`[paths]` has no key named `{k}` — did you mean `include` or `exclude`?"
)
}
}
}
}
pub fn try_read_project_paths(project_root: &Path) -> Result<ProjectPaths, ProjectPathsError> {
let toml_path = project_root.join("bynk.toml");
let overlay = match fs::read_to_string(&toml_path) {
Ok(text) => HashMap::from([(toml_path, text)]),
Err(_) => HashMap::new(),
};
try_read_project_paths_with(project_root, &overlay)
}
pub fn try_read_project_paths_with(
project_root: &Path,
overlay: &HashMap<PathBuf, String>,
) -> Result<ProjectPaths, ProjectPathsError> {
let toml_path = project_root.join("bynk.toml");
let Ok(content) = read_source(&toml_path, overlay) else {
return Ok(ProjectPaths::conventional(project_root));
};
let Ok(doc) = content.parse::<toml::Table>() else {
return Err(ProjectPathsError::Malformed);
};
let paths = doc.get("paths").and_then(|v| v.as_table());
if let Some(t) = paths {
for k in t.keys() {
if k != "include" && k != "exclude" {
return Err(ProjectPathsError::UnknownKey(k.clone()));
}
}
}
let list = |key: &str| -> Vec<PathBuf> {
match paths.and_then(|t| t.get(key)) {
Some(toml::Value::Array(items)) => items
.iter()
.filter_map(|v| v.as_str())
.map(PathBuf::from)
.collect(),
Some(toml::Value::String(s)) => vec![PathBuf::from(s)],
_ => Vec::new(),
}
};
let mut include = list("include");
let exclude = list("exclude");
if include.is_empty() {
include = ProjectPaths::conventional(project_root).include;
}
Ok(ProjectPaths { include, exclude })
}
pub fn commons_dir_for(name: &str) -> PathBuf {
let parts: Vec<&str> = name.split('.').collect();
let mut p = PathBuf::new();
for part in parts {
p.push(part);
}
p
}
pub fn ts_output_path(source: &Path) -> PathBuf {
let mut out = source.to_path_buf();
out.set_extension("ts");
out
}
pub fn worker_dir_name(context: &str) -> String {
context.replace('.', "-")
}
pub fn worker_handlers_source_path(context: &str) -> PathBuf {
PathBuf::from(format!(
"workers/{}/handlers.bynk",
worker_dir_name(context)
))
}
pub fn worker_handlers_output_path(context: &str) -> PathBuf {
PathBuf::from(format!("workers/{}/handlers.ts", worker_dir_name(context)))
}
fn stem_parts(rel_path: &Path) -> Vec<String> {
rel_path
.with_extension("")
.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s.to_string_lossy().to_string()),
_ => None,
})
.collect()
}
pub fn unit_path_matches(rel_path: &Path, qualified_name: &str) -> bool {
let name_parts: Vec<&str> = qualified_name.split('.').collect();
let stem_parts = stem_parts(rel_path);
let single_file_match = stem_parts.len() == name_parts.len()
&& stem_parts
.iter()
.zip(name_parts.iter())
.all(|(a, b)| a == b);
single_file_match || is_multi_file_parts(&stem_parts, &name_parts)
}
fn is_multi_file_parts(stem_parts: &[String], name_parts: &[&str]) -> bool {
if stem_parts.is_empty() {
return false;
}
let parent_parts = &stem_parts[..stem_parts.len() - 1];
parent_parts.len() == name_parts.len()
&& parent_parts
.iter()
.zip(name_parts.iter())
.all(|(a, b)| a == b)
}
pub fn is_multi_file_layout(rel_path: &Path, qualified_name: &str) -> bool {
let name_parts: Vec<&str> = qualified_name.split('.').collect();
is_multi_file_parts(&stem_parts(rel_path), &name_parts)
}
pub fn renamed_unit_name(old_rel: &Path, old_name: &str, new_rel: &Path) -> Option<String> {
let name_parts: Vec<&str> = old_name.split('.').collect();
let old_stem = stem_parts(old_rel);
let new_stem = stem_parts(new_rel);
let suffix_matches = |haystack: &[String]| {
haystack.len() >= name_parts.len() && {
let prefix_len = haystack.len() - name_parts.len();
haystack[prefix_len..]
.iter()
.zip(name_parts.iter())
.all(|(a, b)| a == b)
}
};
if suffix_matches(&old_stem) {
let prefix_len = old_stem.len() - name_parts.len();
return (new_stem.len() >= prefix_len).then(|| new_stem[prefix_len..].join("."));
}
if !old_stem.is_empty() {
let old_parent = &old_stem[..old_stem.len() - 1];
if suffix_matches(old_parent) {
let prefix_len = old_parent.len() - name_parts.len();
if new_stem.is_empty() {
return None;
}
let new_parent = &new_stem[..new_stem.len() - 1];
return (new_parent.len() >= prefix_len).then(|| new_parent[prefix_len..].join("."));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
#[test]
fn is_unpinned_range_true_for_wildcards_and_digitless() {
assert!(is_unpinned_range(""));
assert!(is_unpinned_range("*"));
assert!(is_unpinned_range("x"));
assert!(is_unpinned_range("X"));
assert!(is_unpinned_range("latest"));
assert!(is_unpinned_range("LATEST"));
assert!(is_unpinned_range(" * ")); assert!(is_unpinned_range("workspace:*")); assert!(is_unpinned_range("beta"));
}
#[test]
fn is_unpinned_range_false_when_a_digit_is_present() {
assert!(!is_unpinned_range("1.0.0"));
assert!(!is_unpinned_range("^1.2"));
assert!(!is_unpinned_range("~0.1"));
assert!(!is_unpinned_range(">=2"));
assert!(!is_unpinned_range("18"));
}
#[test]
fn read_project_paths_with_honours_a_virtual_bynk_toml() {
let root = PathBuf::from("/nonexistent-bynk-test-root-57");
let mut overlay = HashMap::new();
overlay.insert(
root.join("bynk.toml"),
"[paths]\ninclude = [\"app\"]\nexclude = [\"vendor\"]\n".to_string(),
);
let paths = read_project_paths_with(&root, &overlay);
assert_eq!(paths.include, vec![PathBuf::from("app")]);
assert_eq!(paths.exclude, vec![PathBuf::from("vendor")]);
}
#[test]
fn read_project_paths_with_falls_back_to_conventional_with_no_overlay_entry() {
let root = PathBuf::from("/nonexistent-bynk-test-root-57-empty");
let paths = read_project_paths_with(&root, &HashMap::new());
let conventional = ProjectPaths::conventional(&root);
assert_eq!(paths.include, conventional.include);
assert_eq!(paths.exclude, conventional.exclude);
}
#[test]
fn read_project_paths_with_honours_three_or_more_include_entries() {
let root = PathBuf::from("/nonexistent-bynk-test-root-1113-many-includes");
let mut overlay = HashMap::new();
overlay.insert(
root.join("bynk.toml"),
"[paths]\ninclude = [\"src\", \"tests\", \"examples\"]\n".to_string(),
);
let paths = try_read_project_paths_with(&root, &overlay).expect("must parse");
assert_eq!(
paths.include,
vec![
PathBuf::from("src"),
PathBuf::from("tests"),
PathBuf::from("examples"),
]
);
}
#[test]
fn render_package_json_renders_sorted_dependencies() {
let mut deps = std::collections::BTreeMap::new();
deps.insert("zod".to_string(), "^3.22.4".to_string());
deps.insert("hono".to_string(), "^4.0.0".to_string());
let out = render_package_json(&deps);
assert!(
out.find("\"hono\"").unwrap() < out.find("\"zod\"").unwrap(),
"dependencies render in sorted order:\n{out}"
);
assert!(out.contains("\"hono\": \"^4.0.0\""), "{out}");
}
#[test]
fn render_package_json_escapes_the_control_range() {
let mut deps = std::collections::BTreeMap::new();
deps.insert("pkg\nname".to_string(), "^1.0\u{1}0".to_string());
let out = render_package_json(&deps);
assert!(out.contains("\"pkg\\nname\""), "{out}");
assert!(out.contains("\"^1.0\\u00010\""), "{out}");
assert!(
!out.lines().any(|l| l.chars().any(|c| (c as u32) < 0x20)),
"a raw control character reached the output:\n{out:?}"
);
}
#[test]
fn render_package_json_escapes_structural_characters() {
let mut deps = std::collections::BTreeMap::new();
deps.insert("a\"b".to_string(), "c\\d".to_string());
let out = render_package_json(&deps);
assert!(out.contains(r#""a\"b": "c\\d""#), "{out}");
}
#[test]
fn normalize_rel_resolves_dot_and_parent() {
assert_eq!(
normalize_rel(Path::new("./tokens.binding.ts")),
PathBuf::from("tokens.binding.ts")
);
assert_eq!(normalize_rel(Path::new("a/./b")), PathBuf::from("a/b"));
assert_eq!(normalize_rel(Path::new("a/../b")), PathBuf::from("b"));
assert_eq!(normalize_rel(Path::new("a/b/../../c")), PathBuf::from("c"));
assert_eq!(normalize_rel(Path::new("a/b")), PathBuf::from("a/b"));
}
#[test]
fn normalize_rel_drops_root_and_pops_through_empty() {
assert_eq!(normalize_rel(Path::new("/a/b")), PathBuf::from("a/b"));
assert_eq!(normalize_rel(Path::new("../a")), PathBuf::from("a"));
}
#[test]
fn commons_dir_for_splits_dotted_name_into_dirs() {
assert_eq!(commons_dir_for("a.b.c"), PathBuf::from("a/b/c"));
assert_eq!(commons_dir_for("foo"), PathBuf::from("foo"));
}
#[test]
fn ts_output_path_sets_ts_extension() {
assert_eq!(
ts_output_path(Path::new("foo.bynk")),
PathBuf::from("foo.ts")
);
assert_eq!(
ts_output_path(Path::new("a/b.bynk")),
PathBuf::from("a/b.ts")
);
assert_eq!(ts_output_path(Path::new("foo")), PathBuf::from("foo.ts"));
}
#[test]
fn worker_paths_dasherise_and_root_under_workers() {
assert_eq!(worker_dir_name("commerce.payment"), "commerce-payment");
assert_eq!(worker_dir_name("plain"), "plain");
assert_eq!(
worker_handlers_source_path("commerce.payment"),
PathBuf::from("workers/commerce-payment/handlers.bynk")
);
assert_eq!(
worker_handlers_output_path("commerce.payment"),
PathBuf::from("workers/commerce-payment/handlers.ts")
);
}
#[test]
fn unit_path_matches_single_file_layout() {
assert!(unit_path_matches(Path::new("a/b/c.bynk"), "a.b.c"));
assert!(unit_path_matches(Path::new("foo.bynk"), "foo"));
}
#[test]
fn unit_path_matches_multi_file_layout() {
assert!(unit_path_matches(Path::new("a/b/c/handlers.bynk"), "a.b.c"));
assert!(unit_path_matches(Path::new("a/b/c/anything.bynk"), "a.b.c"));
}
#[test]
fn unit_path_matches_rejects_misalignment() {
assert!(!unit_path_matches(Path::new("a/b.bynk"), "a.b.c"));
assert!(!unit_path_matches(Path::new("x/y/z.bynk"), "a.b.c"));
}
#[test]
fn is_multi_file_layout_true_only_for_directory_layout() {
assert!(is_multi_file_layout(Path::new("thing/a.bynk"), "thing"));
assert!(is_multi_file_layout(Path::new("thing/b.bynk"), "thing"));
assert!(is_multi_file_layout(Path::new("a/b/one.bynk"), "a.b"));
}
#[test]
fn is_multi_file_layout_false_for_single_file_and_misalignment() {
assert!(!is_multi_file_layout(Path::new("thing.bynk"), "thing"));
assert!(!is_multi_file_layout(Path::new("a/b.bynk"), "a.b"));
assert!(!is_multi_file_layout(Path::new("other/a.bynk"), "thing"));
}
#[test]
fn renamed_unit_name_single_file() {
assert_eq!(
renamed_unit_name(
Path::new("a/b/c.bynk"),
"a.b.c",
Path::new("a/b/renamed.bynk")
),
Some("a.b.renamed".to_string())
);
assert_eq!(
renamed_unit_name(Path::new("foo.bynk"), "foo", Path::new("bar.bynk")),
Some("bar".to_string())
);
}
#[test]
fn renamed_unit_name_multi_file_member_rename_is_a_no_op() {
assert_eq!(
renamed_unit_name(
Path::new("a/b/c/old.bynk"),
"a.b.c",
Path::new("a/b/c/new.bynk")
),
Some("a.b.c".to_string())
);
}
#[test]
fn renamed_unit_name_multi_file_directory_move() {
assert_eq!(
renamed_unit_name(
Path::new("a/b/c/handlers.bynk"),
"a.b.c",
Path::new("a/b/renamed/handlers.bynk")
),
Some("a.b.renamed".to_string())
);
}
#[test]
fn renamed_unit_name_none_on_preexisting_misalignment() {
assert_eq!(
renamed_unit_name(Path::new("x/y/z.bynk"), "a.b.c", Path::new("x/y/w.bynk")),
None
);
}
#[test]
fn renamed_unit_name_tolerates_a_shared_include_root_prefix() {
assert_eq!(
renamed_unit_name(
Path::new("src/billing/charge.bynk"),
"billing.charge",
Path::new("src/billing/pay.bynk")
),
Some("billing.pay".to_string())
);
assert_eq!(
renamed_unit_name(
Path::new("src/a/b/c/handlers.bynk"),
"a.b.c",
Path::new("src/a/b/renamed/handlers.bynk")
),
Some("a.b.renamed".to_string())
);
}
}