1use std::collections::{BTreeMap, HashMap};
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4
5use crate::discovery::read_source;
6use crate::json::json_string;
7
8pub fn is_unpinned_range(range: &str) -> bool {
13 let r = range.trim();
14 if r.is_empty() || r == "*" || r.eq_ignore_ascii_case("x") || r.eq_ignore_ascii_case("latest") {
15 return true;
16 }
17 !r.chars().any(|c| c.is_ascii_digit())
18}
19
20pub fn render_package_json(deps: &std::collections::BTreeMap<String, String>) -> String {
29 let mut out = String::from("{\n \"type\": \"module\"");
30 if !deps.is_empty() {
31 out.push_str(",\n \"dependencies\": {\n");
32 let entries: Vec<String> = deps
33 .iter()
34 .map(|(pkg, range)| format!(" {}: {}", json_string(pkg), json_string(range)))
35 .collect();
36 out.push_str(&entries.join(",\n"));
37 out.push_str("\n }");
38 }
39 out.push_str("\n}\n");
40 out
41}
42
43pub fn normalize_rel(p: &Path) -> PathBuf {
47 let mut out: Vec<std::ffi::OsString> = Vec::new();
48 for c in p.components() {
49 match c {
50 Component::CurDir => {}
51 Component::ParentDir => {
52 out.pop();
53 }
54 Component::Normal(s) => out.push(s.to_os_string()),
55 Component::RootDir | Component::Prefix(_) => {}
56 }
57 }
58 out.iter().collect()
59}
60
61#[derive(Debug, Clone)]
68pub struct ProjectPaths {
69 pub include: Vec<PathBuf>,
73 pub exclude: Vec<PathBuf>,
76}
77
78impl ProjectPaths {
79 pub fn conventional(project_root: &Path) -> Self {
85 let mut include = Vec::new();
86 for role in ["src", "tests"] {
87 if project_root.join(role).is_dir() {
88 include.push(PathBuf::from(role));
89 }
90 }
91 if include.is_empty() {
92 include.push(PathBuf::from("."));
93 }
94 ProjectPaths {
95 include,
96 exclude: Vec::new(),
97 }
98 }
99}
100
101#[cfg(test)]
110pub(crate) fn read_project_paths_with(
111 project_root: &Path,
112 overlay: &HashMap<PathBuf, String>,
113) -> ProjectPaths {
114 try_read_project_paths_with(project_root, overlay)
115 .unwrap_or_else(|_| ProjectPaths::conventional(project_root))
116}
117
118#[derive(Debug)]
121pub enum ProjectPathsError {
122 Malformed,
124 UnknownKey(String),
127 UnknownTable(String),
131 TopLevelKey(String),
133 UnknownTableKey { table: &'static str, key: String },
137 NotAStringList {
141 table: &'static str,
142 key: &'static str,
143 },
144 BadTestTarget { target: String, reason: String },
146}
147
148pub const MANIFEST_TABLES: &[(&str, &[&str])] = &[
152 ("project", &["name", "version"]),
153 ("paths", &["include", "exclude"]),
154 ("fmt", &[]),
155 ("lsp", &["diagnostics_mode", "diagnostics_debounce_ms"]),
156 ("workers", &["compatibility_flags"]),
157 ("unstable", &["allow"]),
158 ("test", &["targets"]),
159];
160
161const PLANNED_TABLES: &[(&str, &str)] = &[
164 ("dependencies", "#843"),
165 ("dev-dependencies", "#843"),
166 ("workspace", "#843"),
167 ("deploy", "#551"),
168];
169
170fn table_list(conj: &str) -> String {
174 let names: Vec<String> = MANIFEST_TABLES
175 .iter()
176 .map(|(t, _)| format!("`[{t}]`"))
177 .collect();
178 match names.split_last() {
179 Some((last, rest)) if !rest.is_empty() => format!("{} {conj} {last}", rest.join(", ")),
180 _ => names.join(""),
181 }
182}
183
184fn did_you_mean<'a>(name: &str, candidates: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
186 candidates
187 .into_iter()
188 .map(|c| (edit_distance(name, c), c))
189 .filter(|(d, _)| *d <= 2)
190 .min_by_key(|(d, _)| *d)
191 .map(|(_, c)| c)
192}
193
194fn edit_distance(a: &str, b: &str) -> usize {
196 let b: Vec<char> = b.chars().collect();
197 let mut row: Vec<usize> = (0..=b.len()).collect();
198 for (i, ca) in a.chars().enumerate() {
199 let mut prev = row[0];
200 row[0] = i + 1;
201 for (j, cb) in b.iter().enumerate() {
202 let cur = row[j + 1];
203 row[j + 1] = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(cur + 1);
204 prev = cur;
205 }
206 }
207 row[b.len()]
208}
209
210impl std::fmt::Display for ProjectPathsError {
211 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212 match self {
213 ProjectPathsError::Malformed => write!(f, "`bynk.toml` is not valid TOML"),
214 ProjectPathsError::UnknownKey(k) => {
215 write!(
216 f,
217 "`[paths]` has no key named `{k}` — did you mean `include` or `exclude`?"
218 )
219 }
220 ProjectPathsError::UnknownTable(name) => {
221 write!(f, "`bynk.toml` has no table named `[{name}]` — ")?;
222 if let Some((_, issue)) = PLANNED_TABLES.iter().find(|(t, _)| *t == name) {
223 write!(f, "it is planned but not yet supported ({issue})")
224 } else if let Some(t) = did_you_mean(name, MANIFEST_TABLES.iter().map(|(t, _)| *t))
225 {
226 write!(f, "did you mean `[{t}]`?")
227 } else {
228 write!(f, "the tables are {}", table_list("and"))
229 }
230 }
231 ProjectPathsError::TopLevelKey(key) => {
232 if MANIFEST_TABLES.iter().any(|(t, _)| t == key) {
235 return write!(
236 f,
237 "`{key}` in `bynk.toml` must be a table — write it as `[{key}]`"
238 );
239 }
240 write!(f, "`bynk.toml` has a key `{key}` outside any table — ")?;
241 match MANIFEST_TABLES
242 .iter()
243 .find(|(_, keys)| keys.contains(&key.as_str()))
244 {
245 Some((table, _)) => write!(f, "did you mean it under `[{table}]`?"),
246 None => write!(f, "keys belong in {}", table_list("or")),
247 }
248 }
249 ProjectPathsError::UnknownTableKey { table, key } => {
250 let keys = MANIFEST_TABLES
251 .iter()
252 .find(|(t, _)| t == table)
253 .map(|(_, k)| *k)
254 .unwrap_or_default();
255 write!(f, "`[{table}]` has no key named `{key}` — ")?;
256 match did_you_mean(key, keys.iter().copied()) {
257 Some(k) => write!(f, "did you mean `{k}`?"),
258 None => {
259 let list: Vec<String> = keys.iter().map(|k| format!("`{k}`")).collect();
260 write!(f, "its keys are {}", list.join(", "))
261 }
262 }
263 }
264 ProjectPathsError::NotAStringList { table, key } => write!(
265 f,
266 "`[{table}]` `{key}` must be a list of strings, like `{key} = [\"nodejs_compat\"]`"
267 ),
268 ProjectPathsError::BadTestTarget { target, reason } => {
269 write!(f, "`[test.targets.{target}]` {reason}")
270 }
271 }
272 }
273}
274
275pub fn check_manifest(
286 project_root: &Path,
287 overlay: &HashMap<PathBuf, String>,
288) -> Result<(), ProjectPathsError> {
289 let toml_path = project_root.join("bynk.toml");
290 let Ok(content) = read_source(&toml_path, overlay) else {
291 return Ok(());
292 };
293 check_manifest_str(&content)
294}
295
296pub fn check_manifest_str(content: &str) -> Result<(), ProjectPathsError> {
298 let doc = content
299 .parse::<toml::Table>()
300 .map_err(|_| ProjectPathsError::Malformed)?;
301 for (name, value) in &doc {
302 if PLANNED_TABLES.iter().any(|(t, _)| t == name) {
305 return Err(ProjectPathsError::UnknownTable(name.clone()));
306 }
307 let Some((table, keys)) = MANIFEST_TABLES.iter().find(|(t, _)| t == name) else {
308 return Err(if value.is_table() {
309 ProjectPathsError::UnknownTable(name.clone())
310 } else {
311 ProjectPathsError::TopLevelKey(name.clone())
312 });
313 };
314 let Some(entries) = value.as_table() else {
317 return Err(ProjectPathsError::TopLevelKey(name.clone()));
318 };
319 if keys.is_empty() {
320 continue;
321 }
322 if *table == "workers" {
323 workers_config_from(entries)?;
324 }
325 if *table == "unstable" {
326 unstable_config_from(entries)?;
327 }
328 if *table == "test" {
329 test_targets_from(entries)?;
330 }
331 for key in entries.keys() {
332 if !keys.contains(&key.as_str()) {
333 return Err(match *table {
334 "paths" => ProjectPathsError::UnknownKey(key.clone()),
335 _ => ProjectPathsError::UnknownTableKey {
336 table,
337 key: key.clone(),
338 },
339 });
340 }
341 }
342 }
343 Ok(())
344}
345
346pub fn try_read_project_paths(project_root: &Path) -> Result<ProjectPaths, ProjectPathsError> {
356 let toml_path = project_root.join("bynk.toml");
357 let overlay = match fs::read_to_string(&toml_path) {
358 Ok(text) => HashMap::from([(toml_path, text)]),
359 Err(_) => HashMap::new(),
360 };
361 try_read_project_paths_with(project_root, &overlay)
362}
363
364pub fn try_read_project_paths_with(
367 project_root: &Path,
368 overlay: &HashMap<PathBuf, String>,
369) -> Result<ProjectPaths, ProjectPathsError> {
370 let toml_path = project_root.join("bynk.toml");
371 let Ok(content) = read_source(&toml_path, overlay) else {
372 return Ok(ProjectPaths::conventional(project_root));
373 };
374 let Ok(doc) = content.parse::<toml::Table>() else {
375 return Err(ProjectPathsError::Malformed);
376 };
377 let paths = doc.get("paths").and_then(|v| v.as_table());
378 if let Some(t) = paths {
379 for k in t.keys() {
380 if k != "include" && k != "exclude" {
381 return Err(ProjectPathsError::UnknownKey(k.clone()));
382 }
383 }
384 }
385 let list = |key: &str| -> Vec<PathBuf> {
386 match paths.and_then(|t| t.get(key)) {
387 Some(toml::Value::Array(items)) => items
388 .iter()
389 .filter_map(|v| v.as_str())
390 .map(PathBuf::from)
391 .collect(),
392 Some(toml::Value::String(s)) => vec![PathBuf::from(s)],
393 _ => Vec::new(),
394 }
395 };
396 let mut include = list("include");
397 let exclude = list("exclude");
398 if include.is_empty() {
399 include = ProjectPaths::conventional(project_root).include;
400 }
401 Ok(ProjectPaths { include, exclude })
402}
403
404#[derive(Debug, Clone, Default, PartialEq, Eq)]
407pub struct WorkersConfig {
408 pub compatibility_flags: Vec<String>,
414}
415
416pub fn try_read_workers_config_with(
424 project_root: &Path,
425 overlay: &HashMap<PathBuf, String>,
426) -> Result<WorkersConfig, ProjectPathsError> {
427 let toml_path = project_root.join("bynk.toml");
428 let Ok(content) = read_source(&toml_path, overlay) else {
429 return Ok(WorkersConfig::default());
430 };
431 let doc = content
432 .parse::<toml::Table>()
433 .map_err(|_| ProjectPathsError::Malformed)?;
434 match doc.get("workers").and_then(|v| v.as_table()) {
435 Some(t) => workers_config_from(t),
436 None => Ok(WorkersConfig::default()),
437 }
438}
439
440pub fn try_read_workers_config(project_root: &Path) -> Result<WorkersConfig, ProjectPathsError> {
442 let toml_path = project_root.join("bynk.toml");
443 let overlay = match fs::read_to_string(&toml_path) {
444 Ok(text) => HashMap::from([(toml_path, text)]),
445 Err(_) => HashMap::new(),
446 };
447 try_read_workers_config_with(project_root, &overlay)
448}
449
450#[derive(Debug, Clone, Default, PartialEq, Eq)]
453pub struct TestTarget {
454 pub urls: BTreeMap<String, String>,
457 pub secrets: Vec<String>,
460 pub production: bool,
462 pub vars: BTreeMap<String, String>,
465}
466
467pub fn try_read_test_targets(
471 project_root: &Path,
472) -> Result<BTreeMap<String, TestTarget>, ProjectPathsError> {
473 let Ok(content) = fs::read_to_string(project_root.join("bynk.toml")) else {
474 return Ok(BTreeMap::new());
475 };
476 let doc = content
477 .parse::<toml::Table>()
478 .map_err(|_| ProjectPathsError::Malformed)?;
479 match doc.get("test").and_then(|v| v.as_table()) {
480 Some(t) => test_targets_from(t),
481 None => Ok(BTreeMap::new()),
482 }
483}
484
485fn test_targets_from(
489 table: &toml::Table,
490) -> Result<BTreeMap<String, TestTarget>, ProjectPathsError> {
491 let mut out = BTreeMap::new();
492 let Some(targets) = table.get("targets") else {
493 return Ok(out);
494 };
495 let Some(targets) = targets.as_table() else {
496 return Err(ProjectPathsError::BadTestTarget {
497 target: "…".to_string(),
498 reason: "must be a table of named targets, like `[test.targets.preview]`".to_string(),
499 });
500 };
501 for (name, entry) in targets {
502 let bad = |reason: &str| ProjectPathsError::BadTestTarget {
503 target: name.clone(),
504 reason: reason.to_string(),
505 };
506 let Some(entry) = entry.as_table() else {
507 return Err(bad("must be a table"));
508 };
509 let strings =
510 |v: &toml::Value, what: &str| -> Result<BTreeMap<String, String>, ProjectPathsError> {
511 v.as_table()
512 .ok_or_else(|| bad(&format!("`{what}` must be a table of strings")))?
513 .iter()
514 .map(|(k, v)| {
515 v.as_str()
516 .map(|s| (k.clone(), s.to_string()))
517 .ok_or_else(|| bad(&format!("`{what}.{k}` must be a string")))
518 })
519 .collect()
520 };
521 let mut target = TestTarget::default();
522 for (key, value) in entry {
523 match key.as_str() {
524 "url" => target.urls = strings(value, "url")?,
525 "vars" => target.vars = strings(value, "vars")?,
526 "secrets" => {
527 target.secrets = value
528 .as_array()
529 .and_then(|a| a.iter().map(|v| v.as_str().map(str::to_string)).collect())
530 .ok_or_else(|| bad("`secrets` must be a list of names"))?;
531 }
532 "production" => {
533 target.production = value
534 .as_bool()
535 .ok_or_else(|| bad("`production` must be `true` or `false`"))?;
536 }
537 other => {
538 return Err(bad(&format!(
539 "has no key named `{other}` — its keys are `url`, `secrets`, `vars` and `production`"
540 )));
541 }
542 }
543 }
544 if target.urls.is_empty() {
545 return Err(bad(
546 "names no Worker URL — add `url.<worker> = \"https://…\"`",
547 ));
548 }
549 out.insert(name.clone(), target);
550 }
551 Ok(out)
552}
553
554pub fn duplicate_compatibility_flag_warning(
562 flag: &str,
563 default: bool,
564 manifest: PathBuf,
565) -> crate::AttributedError {
566 let message = if default {
567 format!(
568 "`bynk.toml`'s `[workers] compatibility_flags` lists `{flag}`, which every Bynk Worker already has"
569 )
570 } else {
571 format!("`bynk.toml`'s `[workers] compatibility_flags` lists `{flag}` more than once")
572 };
573 let mut error = bynk_syntax::CompileError::new(
574 "bynk.project.duplicate_compatibility_flag",
575 bynk_syntax::span::Span::default(),
576 message,
577 );
578 error
579 .notes
580 .push("the duplicate is dropped; remove it from `bynk.toml`".to_string());
581 crate::AttributedError::new(Some(manifest), error)
582}
583
584fn workers_config_from(table: &toml::Table) -> Result<WorkersConfig, ProjectPathsError> {
587 let not_a_list = || ProjectPathsError::NotAStringList {
588 table: "workers",
589 key: "compatibility_flags",
590 };
591 let compatibility_flags = match table.get("compatibility_flags") {
592 None => Vec::new(),
593 Some(toml::Value::Array(items)) => items
594 .iter()
595 .map(|v| v.as_str().map(str::to_owned).ok_or_else(not_a_list))
596 .collect::<Result<_, _>>()?,
597 Some(_) => return Err(not_a_list()),
598 };
599 Ok(WorkersConfig {
600 compatibility_flags,
601 })
602}
603
604#[derive(Debug, Clone, Default, PartialEq, Eq)]
607pub struct UnstableConfig {
608 pub allow: Vec<String>,
613}
614
615pub fn try_read_unstable_config_with(
620 project_root: &Path,
621 overlay: &HashMap<PathBuf, String>,
622) -> Result<UnstableConfig, ProjectPathsError> {
623 let toml_path = project_root.join("bynk.toml");
624 let Ok(content) = read_source(&toml_path, overlay) else {
625 return Ok(UnstableConfig::default());
626 };
627 let doc = content
628 .parse::<toml::Table>()
629 .map_err(|_| ProjectPathsError::Malformed)?;
630 match doc.get("unstable").and_then(|v| v.as_table()) {
631 Some(t) => unstable_config_from(t),
632 None => Ok(UnstableConfig::default()),
633 }
634}
635
636pub fn try_read_unstable_config(project_root: &Path) -> Result<UnstableConfig, ProjectPathsError> {
638 let toml_path = project_root.join("bynk.toml");
639 let overlay = match fs::read_to_string(&toml_path) {
640 Ok(text) => HashMap::from([(toml_path, text)]),
641 Err(_) => HashMap::new(),
642 };
643 try_read_unstable_config_with(project_root, &overlay)
644}
645
646pub fn read_unstable_config(
652 project_root: &Path,
653 overlay: &HashMap<PathBuf, String>,
654) -> UnstableConfig {
655 let manifest = project_root.join("bynk.toml");
656 let read = if overlay.contains_key(&manifest) {
657 try_read_unstable_config_with(project_root, overlay)
658 } else {
659 try_read_unstable_config(project_root)
660 };
661 read.unwrap_or_default()
662}
663
664fn unstable_config_from(table: &toml::Table) -> Result<UnstableConfig, ProjectPathsError> {
667 let not_a_list = || ProjectPathsError::NotAStringList {
668 table: "unstable",
669 key: "allow",
670 };
671 let allow = match table.get("allow") {
672 None => Vec::new(),
673 Some(toml::Value::Array(items)) => items
674 .iter()
675 .map(|v| v.as_str().map(str::to_owned).ok_or_else(not_a_list))
676 .collect::<Result<_, _>>()?,
677 Some(_) => return Err(not_a_list()),
678 };
679 Ok(UnstableConfig { allow })
680}
681
682pub fn commons_dir_for(name: &str) -> PathBuf {
683 let parts: Vec<&str> = name.split('.').collect();
684 let mut p = PathBuf::new();
685 for part in parts {
686 p.push(part);
687 }
688 p
689}
690
691pub fn ts_output_path(source: &Path) -> PathBuf {
692 let mut out = source.to_path_buf();
693 out.set_extension("ts");
694 out
695}
696
697pub fn worker_dir_name(context: &str) -> String {
700 context.replace('.', "-")
701}
702
703pub fn worker_handlers_source_path(context: &str) -> PathBuf {
707 PathBuf::from(format!(
708 "workers/{}/handlers.bynk",
709 worker_dir_name(context)
710 ))
711}
712
713pub fn worker_handlers_output_path(context: &str) -> PathBuf {
715 PathBuf::from(format!("workers/{}/handlers.ts", worker_dir_name(context)))
716}
717
718pub fn worker_file_source_path(context: &str, source: &Path) -> PathBuf {
730 let stem = source.file_stem().unwrap_or_default().to_string_lossy();
731 PathBuf::from(format!(
732 "workers/{}/handlers/{stem}.bynk",
733 worker_dir_name(context)
734 ))
735}
736
737fn stem_parts(rel_path: &Path) -> Vec<String> {
740 rel_path
741 .with_extension("")
742 .components()
743 .filter_map(|c| match c {
744 Component::Normal(s) => Some(s.to_string_lossy().to_string()),
745 _ => None,
746 })
747 .collect()
748}
749
750pub fn unit_path_matches(rel_path: &Path, qualified_name: &str) -> bool {
759 let name_parts: Vec<&str> = qualified_name.split('.').collect();
760 let stem_parts = stem_parts(rel_path);
761 let single_file_match = stem_parts.len() == name_parts.len()
762 && stem_parts
763 .iter()
764 .zip(name_parts.iter())
765 .all(|(a, b)| a == b);
766 single_file_match || is_multi_file_parts(&stem_parts, &name_parts)
767}
768
769fn is_multi_file_parts(stem_parts: &[String], name_parts: &[&str]) -> bool {
772 if stem_parts.is_empty() {
773 return false;
774 }
775 let parent_parts = &stem_parts[..stem_parts.len() - 1];
776 parent_parts.len() == name_parts.len()
777 && parent_parts
778 .iter()
779 .zip(name_parts.iter())
780 .all(|(a, b)| a == b)
781}
782
783pub fn is_multi_file_layout(rel_path: &Path, qualified_name: &str) -> bool {
793 let name_parts: Vec<&str> = qualified_name.split('.').collect();
794 is_multi_file_parts(&stem_parts(rel_path), &name_parts)
795}
796
797pub fn renamed_unit_name(old_rel: &Path, old_name: &str, new_rel: &Path) -> Option<String> {
812 let name_parts: Vec<&str> = old_name.split('.').collect();
813 let old_stem = stem_parts(old_rel);
814 let new_stem = stem_parts(new_rel);
815
816 let suffix_matches = |haystack: &[String]| {
817 haystack.len() >= name_parts.len() && {
818 let prefix_len = haystack.len() - name_parts.len();
819 haystack[prefix_len..]
820 .iter()
821 .zip(name_parts.iter())
822 .all(|(a, b)| a == b)
823 }
824 };
825
826 if suffix_matches(&old_stem) {
827 let prefix_len = old_stem.len() - name_parts.len();
828 return (new_stem.len() >= prefix_len).then(|| new_stem[prefix_len..].join("."));
829 }
830 if !old_stem.is_empty() {
831 let old_parent = &old_stem[..old_stem.len() - 1];
832 if suffix_matches(old_parent) {
833 let prefix_len = old_parent.len() - name_parts.len();
834 if new_stem.is_empty() {
835 return None;
836 }
837 let new_parent = &new_stem[..new_stem.len() - 1];
838 return (new_parent.len() >= prefix_len).then(|| new_parent[prefix_len..].join("."));
839 }
840 }
841 None
842}
843
844#[cfg(test)]
845mod tests {
846 use super::*;
847 use std::path::{Path, PathBuf};
848
849 #[test]
851 fn is_unpinned_range_true_for_wildcards_and_digitless() {
852 assert!(is_unpinned_range(""));
853 assert!(is_unpinned_range("*"));
854 assert!(is_unpinned_range("x"));
855 assert!(is_unpinned_range("X"));
856 assert!(is_unpinned_range("latest"));
857 assert!(is_unpinned_range("LATEST"));
858 assert!(is_unpinned_range(" * ")); assert!(is_unpinned_range("workspace:*")); assert!(is_unpinned_range("beta"));
861 }
862
863 #[test]
864 fn is_unpinned_range_false_when_a_digit_is_present() {
865 assert!(!is_unpinned_range("1.0.0"));
866 assert!(!is_unpinned_range("^1.2"));
867 assert!(!is_unpinned_range("~0.1"));
868 assert!(!is_unpinned_range(">=2"));
869 assert!(!is_unpinned_range("18"));
870 }
871
872 #[test]
874 fn read_project_paths_with_honours_a_virtual_bynk_toml() {
875 let root = PathBuf::from("/nonexistent-bynk-test-root-57");
876 let mut overlay = HashMap::new();
877 overlay.insert(
878 root.join("bynk.toml"),
879 "[paths]\ninclude = [\"app\"]\nexclude = [\"vendor\"]\n".to_string(),
880 );
881 let paths = read_project_paths_with(&root, &overlay);
882 assert_eq!(paths.include, vec![PathBuf::from("app")]);
883 assert_eq!(paths.exclude, vec![PathBuf::from("vendor")]);
884 }
885
886 #[test]
887 fn read_project_paths_with_falls_back_to_conventional_with_no_overlay_entry() {
888 let root = PathBuf::from("/nonexistent-bynk-test-root-57-empty");
891 let paths = read_project_paths_with(&root, &HashMap::new());
892 let conventional = ProjectPaths::conventional(&root);
893 assert_eq!(paths.include, conventional.include);
894 assert_eq!(paths.exclude, conventional.exclude);
895 }
896
897 #[test]
900 fn read_project_paths_with_honours_three_or_more_include_entries() {
901 let root = PathBuf::from("/nonexistent-bynk-test-root-1113-many-includes");
902 let mut overlay = HashMap::new();
903 overlay.insert(
904 root.join("bynk.toml"),
905 "[paths]\ninclude = [\"src\", \"tests\", \"examples\"]\n".to_string(),
906 );
907 let paths = try_read_project_paths_with(&root, &overlay).expect("must parse");
908 assert_eq!(
909 paths.include,
910 vec![
911 PathBuf::from("src"),
912 PathBuf::from("tests"),
913 PathBuf::from("examples"),
914 ]
915 );
916 }
917
918 #[test]
920 fn render_package_json_declares_es_modules() {
921 let bare = render_package_json(&std::collections::BTreeMap::new());
923 assert_eq!(bare, "{\n \"type\": \"module\"\n}\n");
924 let mut deps = std::collections::BTreeMap::new();
925 deps.insert("jose".to_string(), "^5.0.0".to_string());
926 let with_deps = render_package_json(&deps);
927 assert_eq!(
928 with_deps,
929 "{\n \"type\": \"module\",\n \"dependencies\": {\n \"jose\": \"^5.0.0\"\n }\n}\n"
930 );
931 }
932
933 #[test]
934 fn render_package_json_renders_sorted_dependencies() {
935 let mut deps = std::collections::BTreeMap::new();
936 deps.insert("zod".to_string(), "^3.22.4".to_string());
937 deps.insert("hono".to_string(), "^4.0.0".to_string());
938 let out = render_package_json(&deps);
939 assert!(
941 out.find("\"hono\"").unwrap() < out.find("\"zod\"").unwrap(),
942 "dependencies render in sorted order:\n{out}"
943 );
944 assert!(out.contains("\"hono\": \"^4.0.0\""), "{out}");
945 }
946
947 #[test]
953 fn render_package_json_escapes_the_control_range() {
954 let mut deps = std::collections::BTreeMap::new();
955 deps.insert("pkg\nname".to_string(), "^1.0\u{1}0".to_string());
956 let out = render_package_json(&deps);
957 assert!(out.contains("\"pkg\\nname\""), "{out}");
958 assert!(out.contains("\"^1.0\\u00010\""), "{out}");
959 assert!(
962 !out.lines().any(|l| l.chars().any(|c| (c as u32) < 0x20)),
963 "a raw control character reached the output:\n{out:?}"
964 );
965 }
966
967 #[test]
968 fn render_package_json_escapes_structural_characters() {
969 let mut deps = std::collections::BTreeMap::new();
970 deps.insert("a\"b".to_string(), "c\\d".to_string());
971 let out = render_package_json(&deps);
972 assert!(out.contains(r#""a\"b": "c\\d""#), "{out}");
973 }
974
975 #[test]
977 fn normalize_rel_resolves_dot_and_parent() {
978 assert_eq!(
979 normalize_rel(Path::new("./tokens.binding.ts")),
980 PathBuf::from("tokens.binding.ts")
981 );
982 assert_eq!(normalize_rel(Path::new("a/./b")), PathBuf::from("a/b"));
983 assert_eq!(normalize_rel(Path::new("a/../b")), PathBuf::from("b"));
984 assert_eq!(normalize_rel(Path::new("a/b/../../c")), PathBuf::from("c"));
985 assert_eq!(normalize_rel(Path::new("a/b")), PathBuf::from("a/b"));
986 }
987
988 #[test]
989 fn normalize_rel_drops_root_and_pops_through_empty() {
990 assert_eq!(normalize_rel(Path::new("/a/b")), PathBuf::from("a/b"));
992 assert_eq!(normalize_rel(Path::new("../a")), PathBuf::from("a"));
994 }
995
996 #[test]
998 fn commons_dir_for_splits_dotted_name_into_dirs() {
999 assert_eq!(commons_dir_for("a.b.c"), PathBuf::from("a/b/c"));
1000 assert_eq!(commons_dir_for("foo"), PathBuf::from("foo"));
1001 }
1002
1003 #[test]
1004 fn ts_output_path_sets_ts_extension() {
1005 assert_eq!(
1006 ts_output_path(Path::new("foo.bynk")),
1007 PathBuf::from("foo.ts")
1008 );
1009 assert_eq!(
1010 ts_output_path(Path::new("a/b.bynk")),
1011 PathBuf::from("a/b.ts")
1012 );
1013 assert_eq!(ts_output_path(Path::new("foo")), PathBuf::from("foo.ts"));
1014 }
1015
1016 #[test]
1018 fn worker_paths_dasherise_and_root_under_workers() {
1019 assert_eq!(worker_dir_name("commerce.payment"), "commerce-payment");
1020 assert_eq!(worker_dir_name("plain"), "plain");
1021 assert_eq!(
1022 worker_handlers_source_path("commerce.payment"),
1023 PathBuf::from("workers/commerce-payment/handlers.bynk")
1024 );
1025 assert_eq!(
1026 worker_handlers_output_path("commerce.payment"),
1027 PathBuf::from("workers/commerce-payment/handlers.ts")
1028 );
1029 }
1030
1031 #[test]
1032 fn a_split_context_file_has_its_own_module_under_handlers() {
1033 assert_eq!(
1034 worker_file_source_path("shop.orders", Path::new("shop/orders/place.bynk")),
1035 PathBuf::from("workers/shop-orders/handlers/place.bynk")
1036 );
1037 }
1038
1039 #[test]
1041 fn unit_path_matches_single_file_layout() {
1042 assert!(unit_path_matches(Path::new("a/b/c.bynk"), "a.b.c"));
1043 assert!(unit_path_matches(Path::new("foo.bynk"), "foo"));
1044 }
1045
1046 #[test]
1047 fn unit_path_matches_multi_file_layout() {
1048 assert!(unit_path_matches(Path::new("a/b/c/handlers.bynk"), "a.b.c"));
1050 assert!(unit_path_matches(Path::new("a/b/c/anything.bynk"), "a.b.c"));
1051 }
1052
1053 #[test]
1054 fn unit_path_matches_rejects_misalignment() {
1055 assert!(!unit_path_matches(Path::new("a/b.bynk"), "a.b.c"));
1056 assert!(!unit_path_matches(Path::new("x/y/z.bynk"), "a.b.c"));
1057 }
1058
1059 #[test]
1061 fn is_multi_file_layout_true_only_for_directory_layout() {
1062 assert!(is_multi_file_layout(Path::new("thing/a.bynk"), "thing"));
1064 assert!(is_multi_file_layout(Path::new("thing/b.bynk"), "thing"));
1065 assert!(is_multi_file_layout(Path::new("a/b/one.bynk"), "a.b"));
1067 }
1068
1069 #[test]
1070 fn is_multi_file_layout_false_for_single_file_and_misalignment() {
1071 assert!(!is_multi_file_layout(Path::new("thing.bynk"), "thing"));
1073 assert!(!is_multi_file_layout(Path::new("a/b.bynk"), "a.b"));
1075 assert!(!is_multi_file_layout(Path::new("other/a.bynk"), "thing"));
1077 }
1078
1079 #[test]
1081 fn renamed_unit_name_single_file() {
1082 assert_eq!(
1083 renamed_unit_name(
1084 Path::new("a/b/c.bynk"),
1085 "a.b.c",
1086 Path::new("a/b/renamed.bynk")
1087 ),
1088 Some("a.b.renamed".to_string())
1089 );
1090 assert_eq!(
1091 renamed_unit_name(Path::new("foo.bynk"), "foo", Path::new("bar.bynk")),
1092 Some("bar".to_string())
1093 );
1094 }
1095
1096 #[test]
1097 fn renamed_unit_name_multi_file_member_rename_is_a_no_op() {
1098 assert_eq!(
1102 renamed_unit_name(
1103 Path::new("a/b/c/old.bynk"),
1104 "a.b.c",
1105 Path::new("a/b/c/new.bynk")
1106 ),
1107 Some("a.b.c".to_string())
1108 );
1109 }
1110
1111 #[test]
1112 fn renamed_unit_name_multi_file_directory_move() {
1113 assert_eq!(
1114 renamed_unit_name(
1115 Path::new("a/b/c/handlers.bynk"),
1116 "a.b.c",
1117 Path::new("a/b/renamed/handlers.bynk")
1118 ),
1119 Some("a.b.renamed".to_string())
1120 );
1121 }
1122
1123 #[test]
1124 fn renamed_unit_name_none_on_preexisting_misalignment() {
1125 assert_eq!(
1126 renamed_unit_name(Path::new("x/y/z.bynk"), "a.b.c", Path::new("x/y/w.bynk")),
1127 None
1128 );
1129 }
1130
1131 #[test]
1132 fn renamed_unit_name_tolerates_a_shared_include_root_prefix() {
1133 assert_eq!(
1139 renamed_unit_name(
1140 Path::new("src/billing/charge.bynk"),
1141 "billing.charge",
1142 Path::new("src/billing/pay.bynk")
1143 ),
1144 Some("billing.pay".to_string())
1145 );
1146 assert_eq!(
1148 renamed_unit_name(
1149 Path::new("src/a/b/c/handlers.bynk"),
1150 "a.b.c",
1151 Path::new("src/a/b/renamed/handlers.bynk")
1152 ),
1153 Some("a.b.renamed".to_string())
1154 );
1155 }
1156}
1157
1158#[cfg(test)]
1159mod manifest_tests {
1160 use super::*;
1161
1162 fn err(manifest: &str) -> String {
1163 check_manifest_str(manifest)
1164 .expect_err("the manifest is refused")
1165 .to_string()
1166 }
1167
1168 #[test]
1170 fn the_documented_tables_are_accepted() {
1171 check_manifest_str(
1172 "[project]\nname = \"p\"\nversion = \"0.1.0\"\n\n[paths]\ninclude = [\"src\"]\nexclude = []\n\n\
1173 [fmt]\nindent = \"tab\"\nmax_line_width = 100\n\n[lsp]\ndiagnostics_mode = \"live\"\n\
1174 diagnostics_debounce_ms = 300\n\n[workers]\ncompatibility_flags = [\"nodejs_compat\"]\n",
1175 )
1176 .expect("accepted");
1177 check_manifest_str("").expect("an empty manifest is accepted");
1178 }
1179
1180 #[test]
1181 fn a_planned_table_names_its_issue() {
1182 assert!(err("[dependencies]\nx = \"1\"\n").contains("not yet supported (#843)"));
1183 assert!(err("[deploy]\ngroups = []\n").contains("not yet supported (#551)"));
1184 assert!(err("[workspace]\nmembers = []\n").contains("(#843)"));
1185 }
1186
1187 #[test]
1188 fn a_typod_table_suggests_the_nearest() {
1189 assert!(err("[pahts]\n").contains("did you mean `[paths]`?"));
1190 assert!(err("[fnt]\n").contains("did you mean `[fmt]`?"));
1191 assert!(err("[zzzzzz]\n").contains("the tables are"));
1192 }
1193
1194 #[test]
1195 fn an_unknown_key_in_project_or_lsp_suggests_the_nearest() {
1196 assert!(err("[project]\nnmae = \"p\"\n").contains("did you mean `name`?"));
1197 assert!(
1198 err("[lsp]\ndiagnostics_mod = \"live\"\n").contains("did you mean `diagnostics_mode`?")
1199 );
1200 assert!(err("[project]\nlicense = \"MIT\"\n").contains("its keys are `name`, `version`"));
1201 }
1202
1203 #[test]
1204 fn a_paths_key_keeps_its_own_error() {
1205 assert!(matches!(
1206 check_manifest_str("[paths]\nout = \"out\"\n"),
1207 Err(ProjectPathsError::UnknownKey(k)) if k == "out"
1208 ));
1209 }
1210
1211 #[test]
1212 fn a_top_level_key_points_at_its_table() {
1213 assert!(err("name = \"p\"\n").contains("did you mean it under `[project]`?"));
1214 }
1215
1216 #[test]
1219 fn a_table_name_with_a_plain_value_must_be_a_table() {
1220 assert!(err("paths = \"src\"\n").contains("must be a table — write it as `[paths]`"));
1221 assert!(err("lsp = 3\n").contains("`[lsp]`"));
1222 assert!(err("fmt = true\n").contains("`[fmt]`"));
1223 check_manifest_str("paths = { include = [\"src\"] }\n").expect("accepted");
1225 }
1226
1227 #[test]
1229 fn a_planned_table_in_any_shape_names_its_issue() {
1230 assert!(err("[[dependencies]]\nname = \"acme\"\n").contains("(#843)"));
1231 assert!(err("dependencies = [\"acme\"]\n").contains("(#843)"));
1232 }
1233
1234 #[test]
1236 fn fmt_keys_are_left_to_the_formatter() {
1237 check_manifest_str("[fmt]\nanything = 1\n").expect("not this check's concern");
1238 }
1239
1240 #[test]
1243 fn workers_compatibility_flags_must_be_a_list_of_strings() {
1244 let msg = "`[workers]` `compatibility_flags` must be a list of strings";
1245 assert!(err("[workers]\ncompatibility_flags = \"nodejs_compat\"\n").contains(msg));
1246 assert!(err("[workers]\ncompatibility_flags = [\"a\", 1]\n").contains(msg));
1247 assert!(err("[workers]\ncompatibility_flags = [[\"a\"]]\n").contains(msg));
1248 assert!(
1249 err("[workers]\ncompatibility_flag = []\n")
1250 .contains("did you mean `compatibility_flags`?")
1251 );
1252 check_manifest_str("[workers]\ncompatibility_flags = [\"no_such_flag_yet\"]\n")
1253 .expect("flag names are not validated");
1254 check_manifest_str("[workers]\n").expect("an empty `[workers]` is accepted");
1255 }
1256
1257 #[test]
1260 fn unstable_allow_must_be_a_list_of_strings() {
1261 let msg = "`[unstable]` `allow` must be a list of strings";
1262 assert!(err("[unstable]\nallow = \"bynk.cloudflare.D1\"\n").contains(msg));
1263 assert!(err("[unstable]\nallow = [\"a\", 1]\n").contains(msg));
1264 assert!(err("[unstable]\nallows = []\n").contains("did you mean `allow`?"));
1265 check_manifest_str("[unstable]\nallow = [\"bynk.anything\"]\n")
1266 .expect("item paths are the checker's to validate");
1267 check_manifest_str("[unstable]\n").expect("an empty `[unstable]` is accepted");
1268 }
1269
1270 #[test]
1271 fn unstable_config_reads_the_entries_in_order() {
1272 let root = PathBuf::from("/nonexistent-bynk-test-root-1963");
1273 let read = |manifest: &str| {
1274 let overlay = HashMap::from([(root.join("bynk.toml"), manifest.to_string())]);
1275 try_read_unstable_config_with(&root, &overlay)
1276 };
1277 assert_eq!(
1278 read("[unstable]\nallow = [\"b.x\", \"a.y\"]\n")
1279 .unwrap()
1280 .allow,
1281 vec!["b.x", "a.y"]
1282 );
1283 assert_eq!(
1284 read("[project]\nname = \"p\"\n").unwrap(),
1285 UnstableConfig::default()
1286 );
1287 assert!(matches!(
1288 read("[unstable]\nallow = 3\n"),
1289 Err(ProjectPathsError::NotAStringList {
1290 table: "unstable",
1291 key: "allow"
1292 })
1293 ));
1294 }
1295
1296 #[test]
1297 fn workers_config_reads_the_flags_in_order() {
1298 let root = PathBuf::from("/nonexistent-bynk-test-root-1890");
1299 let read = |manifest: &str| {
1300 let overlay = HashMap::from([(root.join("bynk.toml"), manifest.to_string())]);
1301 try_read_workers_config_with(&root, &overlay)
1302 };
1303 assert_eq!(
1304 read("[workers]\ncompatibility_flags = [\"b\", \"a\", \"b\"]\n")
1305 .unwrap()
1306 .compatibility_flags,
1307 vec!["b", "a", "b"],
1308 "order and duplicates are kept; deduplication is the emitter's"
1309 );
1310 assert_eq!(
1311 read("[project]\nname = \"p\"\n").unwrap(),
1312 WorkersConfig::default()
1313 );
1314 assert_eq!(
1315 try_read_workers_config_with(&root, &HashMap::new()).unwrap(),
1316 WorkersConfig::default(),
1317 "no manifest at all"
1318 );
1319 assert!(matches!(
1320 read("[workers]\ncompatibility_flags = [true]\n"),
1321 Err(ProjectPathsError::NotAStringList { .. })
1322 ));
1323 assert!(matches!(
1324 read("[workers\n"),
1325 Err(ProjectPathsError::Malformed)
1326 ));
1327 }
1328
1329 #[test]
1330 fn a_malformed_manifest_is_malformed() {
1331 assert!(matches!(
1332 check_manifest_str("[project\n"),
1333 Err(ProjectPathsError::Malformed)
1334 ));
1335 }
1336
1337 #[test]
1342 fn the_readers_accept_the_shapes_the_manifest_schema_offers() {
1343 let root = PathBuf::from("/nonexistent-bynk-test-root-2096");
1344 let overlay =
1345 |manifest: &str| HashMap::from([(root.join("bynk.toml"), manifest.to_string())]);
1346
1347 for manifest in [
1349 "[paths]\ninclude = \"src\"\n",
1350 "[paths]\ninclude = [\"src\"]\n",
1351 ] {
1352 check_manifest_str(manifest).expect("the manifest check accepts it");
1353 let paths = try_read_project_paths_with(&root, &overlay(manifest))
1354 .unwrap_or_else(|e| panic!("{manifest:?}: {e}"));
1355 assert_eq!(paths.include, vec![PathBuf::from("src")], "{manifest:?}");
1356 }
1357
1358 let manifest = "[workers]\ncompatibility_flags = [\"x\"]\n";
1360 check_manifest_str(manifest).expect("the manifest check accepts it");
1361 assert_eq!(
1362 try_read_workers_config_with(&root, &overlay(manifest))
1363 .unwrap()
1364 .compatibility_flags,
1365 vec!["x"]
1366 );
1367 let manifest = "[unstable]\nallow = [\"x\"]\n";
1368 check_manifest_str(manifest).expect("the manifest check accepts it");
1369 assert_eq!(
1370 try_read_unstable_config_with(&root, &overlay(manifest))
1371 .unwrap()
1372 .allow,
1373 vec!["x"]
1374 );
1375
1376 let manifest = "[workers]\ncompatibility_flags = \"x\"\n";
1379 assert!(matches!(
1380 try_read_workers_config_with(&root, &overlay(manifest)),
1381 Err(ProjectPathsError::NotAStringList { .. })
1382 ));
1383 assert!(check_manifest_str(manifest).is_err());
1384 let manifest = "[unstable]\nallow = \"x\"\n";
1385 assert!(matches!(
1386 try_read_unstable_config_with(&root, &overlay(manifest)),
1387 Err(ProjectPathsError::NotAStringList { .. })
1388 ));
1389 assert!(check_manifest_str(manifest).is_err());
1390
1391 let manifest = "[test.targets.preview]\nurl.shop-orders = \"https://x\"\nsecrets = [\"S\"]\nvars = { P = \"fake\" }\nproduction = false\n";
1393 check_manifest_str(manifest).expect("a well-formed target is accepted");
1394 let targets = test_targets_from(
1395 manifest.parse::<toml::Table>().unwrap()["test"]
1396 .as_table()
1397 .unwrap(),
1398 )
1399 .unwrap();
1400 let preview = &targets["preview"];
1401 assert_eq!(preview.urls["shop-orders"], "https://x");
1402 assert_eq!(preview.secrets, ["S"]);
1403 assert_eq!(preview.vars["P"], "fake");
1404 assert!(!preview.production);
1405 for (manifest, needle) in [
1406 ("[test.targets.p]\nsecrets = []\n", "names no Worker URL"),
1407 (
1408 "[test.targets.p]\nurl.a = \"x\"\nsecret = \"S\"\n",
1409 "no key named `secret`",
1410 ),
1411 (
1412 "[test.targets.p]\nurl.a = \"x\"\nproduction = \"yes\"\n",
1413 "`production` must be",
1414 ),
1415 ("[test.targets.p]\nurl.a = 1\n", "`url.a` must be a string"),
1416 ("[test]\ntarget = 1\n", "no key named `target`"),
1417 ] {
1418 let err = check_manifest_str(manifest)
1419 .expect_err(manifest)
1420 .to_string();
1421 assert!(err.contains(needle), "{manifest:?}: {err}");
1422 }
1423 }
1424}