1use std::collections::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 {
22 let mut out = String::from("{\n \"dependencies\": {\n");
23 let entries: Vec<String> = deps
24 .iter()
25 .map(|(pkg, range)| format!(" {}: {}", json_string(pkg), json_string(range)))
26 .collect();
27 out.push_str(&entries.join(",\n"));
28 out.push_str("\n }\n}\n");
29 out
30}
31
32pub fn normalize_rel(p: &Path) -> PathBuf {
36 let mut out: Vec<std::ffi::OsString> = Vec::new();
37 for c in p.components() {
38 match c {
39 Component::CurDir => {}
40 Component::ParentDir => {
41 out.pop();
42 }
43 Component::Normal(s) => out.push(s.to_os_string()),
44 Component::RootDir | Component::Prefix(_) => {}
45 }
46 }
47 out.iter().collect()
48}
49
50#[derive(Debug, Clone)]
57pub struct ProjectPaths {
58 pub include: Vec<PathBuf>,
62 pub exclude: Vec<PathBuf>,
65}
66
67impl ProjectPaths {
68 pub fn conventional(project_root: &Path) -> Self {
74 let mut include = Vec::new();
75 for role in ["src", "tests"] {
76 if project_root.join(role).is_dir() {
77 include.push(PathBuf::from(role));
78 }
79 }
80 if include.is_empty() {
81 include.push(PathBuf::from("."));
82 }
83 ProjectPaths {
84 include,
85 exclude: Vec::new(),
86 }
87 }
88}
89
90#[cfg(test)]
99pub(crate) fn read_project_paths_with(
100 project_root: &Path,
101 overlay: &HashMap<PathBuf, String>,
102) -> ProjectPaths {
103 try_read_project_paths_with(project_root, overlay)
104 .unwrap_or_else(|_| ProjectPaths::conventional(project_root))
105}
106
107#[derive(Debug)]
110pub enum ProjectPathsError {
111 Malformed,
113 UnknownKey(String),
116 UnknownTable(String),
120 TopLevelKey(String),
122 UnknownTableKey { table: &'static str, key: String },
126 NotAStringList {
130 table: &'static str,
131 key: &'static str,
132 },
133}
134
135pub const MANIFEST_TABLES: &[(&str, &[&str])] = &[
139 ("project", &["name", "version"]),
140 ("paths", &["include", "exclude"]),
141 ("fmt", &[]),
142 ("lsp", &["diagnostics_mode", "diagnostics_debounce_ms"]),
143 ("workers", &["compatibility_flags"]),
144];
145
146const PLANNED_TABLES: &[(&str, &str)] = &[
149 ("dependencies", "#843"),
150 ("dev-dependencies", "#843"),
151 ("workspace", "#843"),
152 ("deploy", "#551"),
153];
154
155fn table_list(conj: &str) -> String {
159 let names: Vec<String> = MANIFEST_TABLES
160 .iter()
161 .map(|(t, _)| format!("`[{t}]`"))
162 .collect();
163 match names.split_last() {
164 Some((last, rest)) if !rest.is_empty() => format!("{} {conj} {last}", rest.join(", ")),
165 _ => names.join(""),
166 }
167}
168
169fn did_you_mean<'a>(name: &str, candidates: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
171 candidates
172 .into_iter()
173 .map(|c| (edit_distance(name, c), c))
174 .filter(|(d, _)| *d <= 2)
175 .min_by_key(|(d, _)| *d)
176 .map(|(_, c)| c)
177}
178
179fn edit_distance(a: &str, b: &str) -> usize {
181 let b: Vec<char> = b.chars().collect();
182 let mut row: Vec<usize> = (0..=b.len()).collect();
183 for (i, ca) in a.chars().enumerate() {
184 let mut prev = row[0];
185 row[0] = i + 1;
186 for (j, cb) in b.iter().enumerate() {
187 let cur = row[j + 1];
188 row[j + 1] = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(cur + 1);
189 prev = cur;
190 }
191 }
192 row[b.len()]
193}
194
195impl std::fmt::Display for ProjectPathsError {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 match self {
198 ProjectPathsError::Malformed => write!(f, "`bynk.toml` is not valid TOML"),
199 ProjectPathsError::UnknownKey(k) => {
200 write!(
201 f,
202 "`[paths]` has no key named `{k}` — did you mean `include` or `exclude`?"
203 )
204 }
205 ProjectPathsError::UnknownTable(name) => {
206 write!(f, "`bynk.toml` has no table named `[{name}]` — ")?;
207 if let Some((_, issue)) = PLANNED_TABLES.iter().find(|(t, _)| *t == name) {
208 write!(f, "it is planned but not yet supported ({issue})")
209 } else if let Some(t) = did_you_mean(name, MANIFEST_TABLES.iter().map(|(t, _)| *t))
210 {
211 write!(f, "did you mean `[{t}]`?")
212 } else {
213 write!(f, "the tables are {}", table_list("and"))
214 }
215 }
216 ProjectPathsError::TopLevelKey(key) => {
217 if MANIFEST_TABLES.iter().any(|(t, _)| t == key) {
220 return write!(
221 f,
222 "`{key}` in `bynk.toml` must be a table — write it as `[{key}]`"
223 );
224 }
225 write!(f, "`bynk.toml` has a key `{key}` outside any table — ")?;
226 match MANIFEST_TABLES
227 .iter()
228 .find(|(_, keys)| keys.contains(&key.as_str()))
229 {
230 Some((table, _)) => write!(f, "did you mean it under `[{table}]`?"),
231 None => write!(f, "keys belong in {}", table_list("or")),
232 }
233 }
234 ProjectPathsError::UnknownTableKey { table, key } => {
235 let keys = MANIFEST_TABLES
236 .iter()
237 .find(|(t, _)| t == table)
238 .map(|(_, k)| *k)
239 .unwrap_or_default();
240 write!(f, "`[{table}]` has no key named `{key}` — ")?;
241 match did_you_mean(key, keys.iter().copied()) {
242 Some(k) => write!(f, "did you mean `{k}`?"),
243 None => {
244 let list: Vec<String> = keys.iter().map(|k| format!("`{k}`")).collect();
245 write!(f, "its keys are {}", list.join(", "))
246 }
247 }
248 }
249 ProjectPathsError::NotAStringList { table, key } => write!(
250 f,
251 "`[{table}]` `{key}` must be a list of strings, like `{key} = [\"nodejs_compat\"]`"
252 ),
253 }
254 }
255}
256
257pub fn check_manifest(
268 project_root: &Path,
269 overlay: &HashMap<PathBuf, String>,
270) -> Result<(), ProjectPathsError> {
271 let toml_path = project_root.join("bynk.toml");
272 let Ok(content) = read_source(&toml_path, overlay) else {
273 return Ok(());
274 };
275 check_manifest_str(&content)
276}
277
278pub fn check_manifest_str(content: &str) -> Result<(), ProjectPathsError> {
280 let doc = content
281 .parse::<toml::Table>()
282 .map_err(|_| ProjectPathsError::Malformed)?;
283 for (name, value) in &doc {
284 if PLANNED_TABLES.iter().any(|(t, _)| t == name) {
287 return Err(ProjectPathsError::UnknownTable(name.clone()));
288 }
289 let Some((table, keys)) = MANIFEST_TABLES.iter().find(|(t, _)| t == name) else {
290 return Err(if value.is_table() {
291 ProjectPathsError::UnknownTable(name.clone())
292 } else {
293 ProjectPathsError::TopLevelKey(name.clone())
294 });
295 };
296 let Some(entries) = value.as_table() else {
299 return Err(ProjectPathsError::TopLevelKey(name.clone()));
300 };
301 if keys.is_empty() {
302 continue;
303 }
304 if *table == "workers" {
305 workers_config_from(entries)?;
306 }
307 for key in entries.keys() {
308 if !keys.contains(&key.as_str()) {
309 return Err(match *table {
310 "paths" => ProjectPathsError::UnknownKey(key.clone()),
311 _ => ProjectPathsError::UnknownTableKey {
312 table,
313 key: key.clone(),
314 },
315 });
316 }
317 }
318 }
319 Ok(())
320}
321
322pub fn try_read_project_paths(project_root: &Path) -> Result<ProjectPaths, ProjectPathsError> {
332 let toml_path = project_root.join("bynk.toml");
333 let overlay = match fs::read_to_string(&toml_path) {
334 Ok(text) => HashMap::from([(toml_path, text)]),
335 Err(_) => HashMap::new(),
336 };
337 try_read_project_paths_with(project_root, &overlay)
338}
339
340pub fn try_read_project_paths_with(
343 project_root: &Path,
344 overlay: &HashMap<PathBuf, String>,
345) -> Result<ProjectPaths, ProjectPathsError> {
346 let toml_path = project_root.join("bynk.toml");
347 let Ok(content) = read_source(&toml_path, overlay) else {
348 return Ok(ProjectPaths::conventional(project_root));
349 };
350 let Ok(doc) = content.parse::<toml::Table>() else {
351 return Err(ProjectPathsError::Malformed);
352 };
353 let paths = doc.get("paths").and_then(|v| v.as_table());
354 if let Some(t) = paths {
355 for k in t.keys() {
356 if k != "include" && k != "exclude" {
357 return Err(ProjectPathsError::UnknownKey(k.clone()));
358 }
359 }
360 }
361 let list = |key: &str| -> Vec<PathBuf> {
362 match paths.and_then(|t| t.get(key)) {
363 Some(toml::Value::Array(items)) => items
364 .iter()
365 .filter_map(|v| v.as_str())
366 .map(PathBuf::from)
367 .collect(),
368 Some(toml::Value::String(s)) => vec![PathBuf::from(s)],
369 _ => Vec::new(),
370 }
371 };
372 let mut include = list("include");
373 let exclude = list("exclude");
374 if include.is_empty() {
375 include = ProjectPaths::conventional(project_root).include;
376 }
377 Ok(ProjectPaths { include, exclude })
378}
379
380#[derive(Debug, Clone, Default, PartialEq, Eq)]
383pub struct WorkersConfig {
384 pub compatibility_flags: Vec<String>,
390}
391
392pub fn try_read_workers_config_with(
400 project_root: &Path,
401 overlay: &HashMap<PathBuf, String>,
402) -> Result<WorkersConfig, ProjectPathsError> {
403 let toml_path = project_root.join("bynk.toml");
404 let Ok(content) = read_source(&toml_path, overlay) else {
405 return Ok(WorkersConfig::default());
406 };
407 let doc = content
408 .parse::<toml::Table>()
409 .map_err(|_| ProjectPathsError::Malformed)?;
410 match doc.get("workers").and_then(|v| v.as_table()) {
411 Some(t) => workers_config_from(t),
412 None => Ok(WorkersConfig::default()),
413 }
414}
415
416pub fn try_read_workers_config(project_root: &Path) -> Result<WorkersConfig, ProjectPathsError> {
418 let toml_path = project_root.join("bynk.toml");
419 let overlay = match fs::read_to_string(&toml_path) {
420 Ok(text) => HashMap::from([(toml_path, text)]),
421 Err(_) => HashMap::new(),
422 };
423 try_read_workers_config_with(project_root, &overlay)
424}
425
426pub fn duplicate_compatibility_flag_warning(
434 flag: &str,
435 default: bool,
436 manifest: PathBuf,
437) -> crate::AttributedError {
438 let message = if default {
439 format!(
440 "`bynk.toml`'s `[workers] compatibility_flags` lists `{flag}`, which every Bynk Worker already has"
441 )
442 } else {
443 format!("`bynk.toml`'s `[workers] compatibility_flags` lists `{flag}` more than once")
444 };
445 let mut error = bynk_syntax::CompileError::new(
446 "bynk.project.duplicate_compatibility_flag",
447 bynk_syntax::span::Span::default(),
448 message,
449 );
450 error
451 .notes
452 .push("the duplicate is dropped; remove it from `bynk.toml`".to_string());
453 crate::AttributedError::new(Some(manifest), error)
454}
455
456fn workers_config_from(table: &toml::Table) -> Result<WorkersConfig, ProjectPathsError> {
459 let not_a_list = || ProjectPathsError::NotAStringList {
460 table: "workers",
461 key: "compatibility_flags",
462 };
463 let compatibility_flags = match table.get("compatibility_flags") {
464 None => Vec::new(),
465 Some(toml::Value::Array(items)) => items
466 .iter()
467 .map(|v| v.as_str().map(str::to_owned).ok_or_else(not_a_list))
468 .collect::<Result<_, _>>()?,
469 Some(_) => return Err(not_a_list()),
470 };
471 Ok(WorkersConfig {
472 compatibility_flags,
473 })
474}
475
476pub fn commons_dir_for(name: &str) -> PathBuf {
477 let parts: Vec<&str> = name.split('.').collect();
478 let mut p = PathBuf::new();
479 for part in parts {
480 p.push(part);
481 }
482 p
483}
484
485pub fn ts_output_path(source: &Path) -> PathBuf {
486 let mut out = source.to_path_buf();
487 out.set_extension("ts");
488 out
489}
490
491pub fn worker_dir_name(context: &str) -> String {
494 context.replace('.', "-")
495}
496
497pub fn worker_handlers_source_path(context: &str) -> PathBuf {
501 PathBuf::from(format!(
502 "workers/{}/handlers.bynk",
503 worker_dir_name(context)
504 ))
505}
506
507pub fn worker_handlers_output_path(context: &str) -> PathBuf {
509 PathBuf::from(format!("workers/{}/handlers.ts", worker_dir_name(context)))
510}
511
512pub fn worker_file_source_path(context: &str, source: &Path) -> PathBuf {
524 let stem = source.file_stem().unwrap_or_default().to_string_lossy();
525 PathBuf::from(format!(
526 "workers/{}/handlers/{stem}.bynk",
527 worker_dir_name(context)
528 ))
529}
530
531fn stem_parts(rel_path: &Path) -> Vec<String> {
534 rel_path
535 .with_extension("")
536 .components()
537 .filter_map(|c| match c {
538 Component::Normal(s) => Some(s.to_string_lossy().to_string()),
539 _ => None,
540 })
541 .collect()
542}
543
544pub fn unit_path_matches(rel_path: &Path, qualified_name: &str) -> bool {
553 let name_parts: Vec<&str> = qualified_name.split('.').collect();
554 let stem_parts = stem_parts(rel_path);
555 let single_file_match = stem_parts.len() == name_parts.len()
556 && stem_parts
557 .iter()
558 .zip(name_parts.iter())
559 .all(|(a, b)| a == b);
560 single_file_match || is_multi_file_parts(&stem_parts, &name_parts)
561}
562
563fn is_multi_file_parts(stem_parts: &[String], name_parts: &[&str]) -> bool {
566 if stem_parts.is_empty() {
567 return false;
568 }
569 let parent_parts = &stem_parts[..stem_parts.len() - 1];
570 parent_parts.len() == name_parts.len()
571 && parent_parts
572 .iter()
573 .zip(name_parts.iter())
574 .all(|(a, b)| a == b)
575}
576
577pub fn is_multi_file_layout(rel_path: &Path, qualified_name: &str) -> bool {
587 let name_parts: Vec<&str> = qualified_name.split('.').collect();
588 is_multi_file_parts(&stem_parts(rel_path), &name_parts)
589}
590
591pub fn renamed_unit_name(old_rel: &Path, old_name: &str, new_rel: &Path) -> Option<String> {
606 let name_parts: Vec<&str> = old_name.split('.').collect();
607 let old_stem = stem_parts(old_rel);
608 let new_stem = stem_parts(new_rel);
609
610 let suffix_matches = |haystack: &[String]| {
611 haystack.len() >= name_parts.len() && {
612 let prefix_len = haystack.len() - name_parts.len();
613 haystack[prefix_len..]
614 .iter()
615 .zip(name_parts.iter())
616 .all(|(a, b)| a == b)
617 }
618 };
619
620 if suffix_matches(&old_stem) {
621 let prefix_len = old_stem.len() - name_parts.len();
622 return (new_stem.len() >= prefix_len).then(|| new_stem[prefix_len..].join("."));
623 }
624 if !old_stem.is_empty() {
625 let old_parent = &old_stem[..old_stem.len() - 1];
626 if suffix_matches(old_parent) {
627 let prefix_len = old_parent.len() - name_parts.len();
628 if new_stem.is_empty() {
629 return None;
630 }
631 let new_parent = &new_stem[..new_stem.len() - 1];
632 return (new_parent.len() >= prefix_len).then(|| new_parent[prefix_len..].join("."));
633 }
634 }
635 None
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use std::path::{Path, PathBuf};
642
643 #[test]
645 fn is_unpinned_range_true_for_wildcards_and_digitless() {
646 assert!(is_unpinned_range(""));
647 assert!(is_unpinned_range("*"));
648 assert!(is_unpinned_range("x"));
649 assert!(is_unpinned_range("X"));
650 assert!(is_unpinned_range("latest"));
651 assert!(is_unpinned_range("LATEST"));
652 assert!(is_unpinned_range(" * ")); assert!(is_unpinned_range("workspace:*")); assert!(is_unpinned_range("beta"));
655 }
656
657 #[test]
658 fn is_unpinned_range_false_when_a_digit_is_present() {
659 assert!(!is_unpinned_range("1.0.0"));
660 assert!(!is_unpinned_range("^1.2"));
661 assert!(!is_unpinned_range("~0.1"));
662 assert!(!is_unpinned_range(">=2"));
663 assert!(!is_unpinned_range("18"));
664 }
665
666 #[test]
668 fn read_project_paths_with_honours_a_virtual_bynk_toml() {
669 let root = PathBuf::from("/nonexistent-bynk-test-root-57");
670 let mut overlay = HashMap::new();
671 overlay.insert(
672 root.join("bynk.toml"),
673 "[paths]\ninclude = [\"app\"]\nexclude = [\"vendor\"]\n".to_string(),
674 );
675 let paths = read_project_paths_with(&root, &overlay);
676 assert_eq!(paths.include, vec![PathBuf::from("app")]);
677 assert_eq!(paths.exclude, vec![PathBuf::from("vendor")]);
678 }
679
680 #[test]
681 fn read_project_paths_with_falls_back_to_conventional_with_no_overlay_entry() {
682 let root = PathBuf::from("/nonexistent-bynk-test-root-57-empty");
685 let paths = read_project_paths_with(&root, &HashMap::new());
686 let conventional = ProjectPaths::conventional(&root);
687 assert_eq!(paths.include, conventional.include);
688 assert_eq!(paths.exclude, conventional.exclude);
689 }
690
691 #[test]
694 fn read_project_paths_with_honours_three_or_more_include_entries() {
695 let root = PathBuf::from("/nonexistent-bynk-test-root-1113-many-includes");
696 let mut overlay = HashMap::new();
697 overlay.insert(
698 root.join("bynk.toml"),
699 "[paths]\ninclude = [\"src\", \"tests\", \"examples\"]\n".to_string(),
700 );
701 let paths = try_read_project_paths_with(&root, &overlay).expect("must parse");
702 assert_eq!(
703 paths.include,
704 vec![
705 PathBuf::from("src"),
706 PathBuf::from("tests"),
707 PathBuf::from("examples"),
708 ]
709 );
710 }
711
712 #[test]
714 fn render_package_json_renders_sorted_dependencies() {
715 let mut deps = std::collections::BTreeMap::new();
716 deps.insert("zod".to_string(), "^3.22.4".to_string());
717 deps.insert("hono".to_string(), "^4.0.0".to_string());
718 let out = render_package_json(&deps);
719 assert!(
721 out.find("\"hono\"").unwrap() < out.find("\"zod\"").unwrap(),
722 "dependencies render in sorted order:\n{out}"
723 );
724 assert!(out.contains("\"hono\": \"^4.0.0\""), "{out}");
725 }
726
727 #[test]
733 fn render_package_json_escapes_the_control_range() {
734 let mut deps = std::collections::BTreeMap::new();
735 deps.insert("pkg\nname".to_string(), "^1.0\u{1}0".to_string());
736 let out = render_package_json(&deps);
737 assert!(out.contains("\"pkg\\nname\""), "{out}");
738 assert!(out.contains("\"^1.0\\u00010\""), "{out}");
739 assert!(
742 !out.lines().any(|l| l.chars().any(|c| (c as u32) < 0x20)),
743 "a raw control character reached the output:\n{out:?}"
744 );
745 }
746
747 #[test]
748 fn render_package_json_escapes_structural_characters() {
749 let mut deps = std::collections::BTreeMap::new();
750 deps.insert("a\"b".to_string(), "c\\d".to_string());
751 let out = render_package_json(&deps);
752 assert!(out.contains(r#""a\"b": "c\\d""#), "{out}");
753 }
754
755 #[test]
757 fn normalize_rel_resolves_dot_and_parent() {
758 assert_eq!(
759 normalize_rel(Path::new("./tokens.binding.ts")),
760 PathBuf::from("tokens.binding.ts")
761 );
762 assert_eq!(normalize_rel(Path::new("a/./b")), PathBuf::from("a/b"));
763 assert_eq!(normalize_rel(Path::new("a/../b")), PathBuf::from("b"));
764 assert_eq!(normalize_rel(Path::new("a/b/../../c")), PathBuf::from("c"));
765 assert_eq!(normalize_rel(Path::new("a/b")), PathBuf::from("a/b"));
766 }
767
768 #[test]
769 fn normalize_rel_drops_root_and_pops_through_empty() {
770 assert_eq!(normalize_rel(Path::new("/a/b")), PathBuf::from("a/b"));
772 assert_eq!(normalize_rel(Path::new("../a")), PathBuf::from("a"));
774 }
775
776 #[test]
778 fn commons_dir_for_splits_dotted_name_into_dirs() {
779 assert_eq!(commons_dir_for("a.b.c"), PathBuf::from("a/b/c"));
780 assert_eq!(commons_dir_for("foo"), PathBuf::from("foo"));
781 }
782
783 #[test]
784 fn ts_output_path_sets_ts_extension() {
785 assert_eq!(
786 ts_output_path(Path::new("foo.bynk")),
787 PathBuf::from("foo.ts")
788 );
789 assert_eq!(
790 ts_output_path(Path::new("a/b.bynk")),
791 PathBuf::from("a/b.ts")
792 );
793 assert_eq!(ts_output_path(Path::new("foo")), PathBuf::from("foo.ts"));
794 }
795
796 #[test]
798 fn worker_paths_dasherise_and_root_under_workers() {
799 assert_eq!(worker_dir_name("commerce.payment"), "commerce-payment");
800 assert_eq!(worker_dir_name("plain"), "plain");
801 assert_eq!(
802 worker_handlers_source_path("commerce.payment"),
803 PathBuf::from("workers/commerce-payment/handlers.bynk")
804 );
805 assert_eq!(
806 worker_handlers_output_path("commerce.payment"),
807 PathBuf::from("workers/commerce-payment/handlers.ts")
808 );
809 }
810
811 #[test]
812 fn a_split_context_file_has_its_own_module_under_handlers() {
813 assert_eq!(
814 worker_file_source_path("shop.orders", Path::new("shop/orders/place.bynk")),
815 PathBuf::from("workers/shop-orders/handlers/place.bynk")
816 );
817 }
818
819 #[test]
821 fn unit_path_matches_single_file_layout() {
822 assert!(unit_path_matches(Path::new("a/b/c.bynk"), "a.b.c"));
823 assert!(unit_path_matches(Path::new("foo.bynk"), "foo"));
824 }
825
826 #[test]
827 fn unit_path_matches_multi_file_layout() {
828 assert!(unit_path_matches(Path::new("a/b/c/handlers.bynk"), "a.b.c"));
830 assert!(unit_path_matches(Path::new("a/b/c/anything.bynk"), "a.b.c"));
831 }
832
833 #[test]
834 fn unit_path_matches_rejects_misalignment() {
835 assert!(!unit_path_matches(Path::new("a/b.bynk"), "a.b.c"));
836 assert!(!unit_path_matches(Path::new("x/y/z.bynk"), "a.b.c"));
837 }
838
839 #[test]
841 fn is_multi_file_layout_true_only_for_directory_layout() {
842 assert!(is_multi_file_layout(Path::new("thing/a.bynk"), "thing"));
844 assert!(is_multi_file_layout(Path::new("thing/b.bynk"), "thing"));
845 assert!(is_multi_file_layout(Path::new("a/b/one.bynk"), "a.b"));
847 }
848
849 #[test]
850 fn is_multi_file_layout_false_for_single_file_and_misalignment() {
851 assert!(!is_multi_file_layout(Path::new("thing.bynk"), "thing"));
853 assert!(!is_multi_file_layout(Path::new("a/b.bynk"), "a.b"));
855 assert!(!is_multi_file_layout(Path::new("other/a.bynk"), "thing"));
857 }
858
859 #[test]
861 fn renamed_unit_name_single_file() {
862 assert_eq!(
863 renamed_unit_name(
864 Path::new("a/b/c.bynk"),
865 "a.b.c",
866 Path::new("a/b/renamed.bynk")
867 ),
868 Some("a.b.renamed".to_string())
869 );
870 assert_eq!(
871 renamed_unit_name(Path::new("foo.bynk"), "foo", Path::new("bar.bynk")),
872 Some("bar".to_string())
873 );
874 }
875
876 #[test]
877 fn renamed_unit_name_multi_file_member_rename_is_a_no_op() {
878 assert_eq!(
882 renamed_unit_name(
883 Path::new("a/b/c/old.bynk"),
884 "a.b.c",
885 Path::new("a/b/c/new.bynk")
886 ),
887 Some("a.b.c".to_string())
888 );
889 }
890
891 #[test]
892 fn renamed_unit_name_multi_file_directory_move() {
893 assert_eq!(
894 renamed_unit_name(
895 Path::new("a/b/c/handlers.bynk"),
896 "a.b.c",
897 Path::new("a/b/renamed/handlers.bynk")
898 ),
899 Some("a.b.renamed".to_string())
900 );
901 }
902
903 #[test]
904 fn renamed_unit_name_none_on_preexisting_misalignment() {
905 assert_eq!(
906 renamed_unit_name(Path::new("x/y/z.bynk"), "a.b.c", Path::new("x/y/w.bynk")),
907 None
908 );
909 }
910
911 #[test]
912 fn renamed_unit_name_tolerates_a_shared_include_root_prefix() {
913 assert_eq!(
919 renamed_unit_name(
920 Path::new("src/billing/charge.bynk"),
921 "billing.charge",
922 Path::new("src/billing/pay.bynk")
923 ),
924 Some("billing.pay".to_string())
925 );
926 assert_eq!(
928 renamed_unit_name(
929 Path::new("src/a/b/c/handlers.bynk"),
930 "a.b.c",
931 Path::new("src/a/b/renamed/handlers.bynk")
932 ),
933 Some("a.b.renamed".to_string())
934 );
935 }
936}
937
938#[cfg(test)]
939mod manifest_tests {
940 use super::*;
941
942 fn err(manifest: &str) -> String {
943 check_manifest_str(manifest)
944 .expect_err("the manifest is refused")
945 .to_string()
946 }
947
948 #[test]
950 fn the_documented_tables_are_accepted() {
951 check_manifest_str(
952 "[project]\nname = \"p\"\nversion = \"0.1.0\"\n\n[paths]\ninclude = [\"src\"]\nexclude = []\n\n\
953 [fmt]\nindent = \"tab\"\nmax_line_width = 100\n\n[lsp]\ndiagnostics_mode = \"live\"\n\
954 diagnostics_debounce_ms = 300\n\n[workers]\ncompatibility_flags = [\"nodejs_compat\"]\n",
955 )
956 .expect("accepted");
957 check_manifest_str("").expect("an empty manifest is accepted");
958 }
959
960 #[test]
961 fn a_planned_table_names_its_issue() {
962 assert!(err("[dependencies]\nx = \"1\"\n").contains("not yet supported (#843)"));
963 assert!(err("[deploy]\ngroups = []\n").contains("not yet supported (#551)"));
964 assert!(err("[workspace]\nmembers = []\n").contains("(#843)"));
965 }
966
967 #[test]
968 fn a_typod_table_suggests_the_nearest() {
969 assert!(err("[pahts]\n").contains("did you mean `[paths]`?"));
970 assert!(err("[fnt]\n").contains("did you mean `[fmt]`?"));
971 assert!(err("[zzzzzz]\n").contains("the tables are"));
972 }
973
974 #[test]
975 fn an_unknown_key_in_project_or_lsp_suggests_the_nearest() {
976 assert!(err("[project]\nnmae = \"p\"\n").contains("did you mean `name`?"));
977 assert!(
978 err("[lsp]\ndiagnostics_mod = \"live\"\n").contains("did you mean `diagnostics_mode`?")
979 );
980 assert!(err("[project]\nlicense = \"MIT\"\n").contains("its keys are `name`, `version`"));
981 }
982
983 #[test]
984 fn a_paths_key_keeps_its_own_error() {
985 assert!(matches!(
986 check_manifest_str("[paths]\nout = \"out\"\n"),
987 Err(ProjectPathsError::UnknownKey(k)) if k == "out"
988 ));
989 }
990
991 #[test]
992 fn a_top_level_key_points_at_its_table() {
993 assert!(err("name = \"p\"\n").contains("did you mean it under `[project]`?"));
994 }
995
996 #[test]
999 fn a_table_name_with_a_plain_value_must_be_a_table() {
1000 assert!(err("paths = \"src\"\n").contains("must be a table — write it as `[paths]`"));
1001 assert!(err("lsp = 3\n").contains("`[lsp]`"));
1002 assert!(err("fmt = true\n").contains("`[fmt]`"));
1003 check_manifest_str("paths = { include = [\"src\"] }\n").expect("accepted");
1005 }
1006
1007 #[test]
1009 fn a_planned_table_in_any_shape_names_its_issue() {
1010 assert!(err("[[dependencies]]\nname = \"acme\"\n").contains("(#843)"));
1011 assert!(err("dependencies = [\"acme\"]\n").contains("(#843)"));
1012 }
1013
1014 #[test]
1016 fn fmt_keys_are_left_to_the_formatter() {
1017 check_manifest_str("[fmt]\nanything = 1\n").expect("not this check's concern");
1018 }
1019
1020 #[test]
1023 fn workers_compatibility_flags_must_be_a_list_of_strings() {
1024 let msg = "`[workers]` `compatibility_flags` must be a list of strings";
1025 assert!(err("[workers]\ncompatibility_flags = \"nodejs_compat\"\n").contains(msg));
1026 assert!(err("[workers]\ncompatibility_flags = [\"a\", 1]\n").contains(msg));
1027 assert!(err("[workers]\ncompatibility_flags = [[\"a\"]]\n").contains(msg));
1028 assert!(
1029 err("[workers]\ncompatibility_flag = []\n")
1030 .contains("did you mean `compatibility_flags`?")
1031 );
1032 check_manifest_str("[workers]\ncompatibility_flags = [\"no_such_flag_yet\"]\n")
1033 .expect("flag names are not validated");
1034 check_manifest_str("[workers]\n").expect("an empty `[workers]` is accepted");
1035 }
1036
1037 #[test]
1038 fn workers_config_reads_the_flags_in_order() {
1039 let root = PathBuf::from("/nonexistent-bynk-test-root-1890");
1040 let read = |manifest: &str| {
1041 let overlay = HashMap::from([(root.join("bynk.toml"), manifest.to_string())]);
1042 try_read_workers_config_with(&root, &overlay)
1043 };
1044 assert_eq!(
1045 read("[workers]\ncompatibility_flags = [\"b\", \"a\", \"b\"]\n")
1046 .unwrap()
1047 .compatibility_flags,
1048 vec!["b", "a", "b"],
1049 "order and duplicates are kept; deduplication is the emitter's"
1050 );
1051 assert_eq!(
1052 read("[project]\nname = \"p\"\n").unwrap(),
1053 WorkersConfig::default()
1054 );
1055 assert_eq!(
1056 try_read_workers_config_with(&root, &HashMap::new()).unwrap(),
1057 WorkersConfig::default(),
1058 "no manifest at all"
1059 );
1060 assert!(matches!(
1061 read("[workers]\ncompatibility_flags = [true]\n"),
1062 Err(ProjectPathsError::NotAStringList { .. })
1063 ));
1064 assert!(matches!(
1065 read("[workers\n"),
1066 Err(ProjectPathsError::Malformed)
1067 ));
1068 }
1069
1070 #[test]
1071 fn a_malformed_manifest_is_malformed() {
1072 assert!(matches!(
1073 check_manifest_str("[project\n"),
1074 Err(ProjectPathsError::Malformed)
1075 ));
1076 }
1077}