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 ProjectAndPackage,
149 BadPackage { key: &'static str, reason: String },
151 BadDependency {
154 table: &'static str,
155 alias: String,
156 reason: String,
157 },
158 UnknownEntryKey {
160 table: &'static str,
161 entry: String,
162 key: String,
163 },
164 BadRegistry { name: String, reason: String },
166 RegistryCredential { name: String },
168 BadWorkspace { reason: String },
171 WorkspaceRootTable(String),
174 WorkspaceRootSources,
176 WorkspaceRoot,
179 MemberRegistries { workspace: PathBuf },
182 NestedWorkspace { workspace: PathBuf },
184}
185
186pub const MANIFEST_TABLES: &[(&str, &[&str])] = &[
190 ("project", &["name", "version"]),
191 (
192 "package",
193 &[
194 "name",
195 "version",
196 "description",
197 "license",
198 "repository",
199 "exports",
200 ],
201 ),
202 ("paths", &["include", "exclude"]),
203 ("fmt", &[]),
204 ("lsp", &["diagnostics_mode", "diagnostics_debounce_ms"]),
205 ("workers", &["compatibility_flags"]),
206 ("unstable", &["allow"]),
207 ("test", &["targets"]),
208 ("workspace", &["members", "exclude"]),
209];
210
211pub const MANIFEST_MAP_TABLES: &[(&str, &[&str])] = &[
217 ("dependencies", crate::manifest::DEPENDENCY_KEYS),
218 ("dev-dependencies", crate::manifest::DEPENDENCY_KEYS),
219 ("registries", crate::manifest::REGISTRY_KEYS),
220];
221
222const WORKSPACE_ROOT_TABLES: &[&str] = &["workspace", "registries"];
224
225const PLANNED_TABLES: &[(&str, &str)] = &[("deploy", "#551")];
228
229fn all_tables() -> impl Iterator<Item = &'static str> {
232 MANIFEST_TABLES
233 .iter()
234 .chain(MANIFEST_MAP_TABLES)
235 .map(|(t, _)| *t)
236}
237
238fn table_list(conj: &str) -> String {
242 let names: Vec<String> = all_tables().map(|t| format!("`[{t}]`")).collect();
243 match names.split_last() {
244 Some((last, rest)) if !rest.is_empty() => format!("{} {conj} {last}", rest.join(", ")),
245 _ => names.join(""),
246 }
247}
248
249pub(crate) fn did_you_mean<'a>(
251 name: &str,
252 candidates: impl IntoIterator<Item = &'a str>,
253) -> Option<&'a str> {
254 candidates
255 .into_iter()
256 .map(|c| (edit_distance(name, c), c))
257 .filter(|(d, _)| *d <= 2)
258 .min_by_key(|(d, _)| *d)
259 .map(|(_, c)| c)
260}
261
262fn edit_distance(a: &str, b: &str) -> usize {
264 let b: Vec<char> = b.chars().collect();
265 let mut row: Vec<usize> = (0..=b.len()).collect();
266 for (i, ca) in a.chars().enumerate() {
267 let mut prev = row[0];
268 row[0] = i + 1;
269 for (j, cb) in b.iter().enumerate() {
270 let cur = row[j + 1];
271 row[j + 1] = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(cur + 1);
272 prev = cur;
273 }
274 }
275 row[b.len()]
276}
277
278impl std::fmt::Display for ProjectPathsError {
279 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280 match self {
281 ProjectPathsError::Malformed => write!(f, "`bynk.toml` is not valid TOML"),
282 ProjectPathsError::UnknownKey(k) => {
283 write!(
284 f,
285 "`[paths]` has no key named `{k}` — did you mean `include` or `exclude`?"
286 )
287 }
288 ProjectPathsError::UnknownTable(name) => {
289 write!(f, "`bynk.toml` has no table named `[{name}]` — ")?;
290 if let Some((_, issue)) = PLANNED_TABLES.iter().find(|(t, _)| *t == name) {
291 write!(f, "it is planned but not yet supported ({issue})")
292 } else if let Some(t) = did_you_mean(name, all_tables()) {
293 write!(f, "did you mean `[{t}]`?")
294 } else {
295 write!(f, "the tables are {}", table_list("and"))
296 }
297 }
298 ProjectPathsError::TopLevelKey(key) => {
299 if all_tables().any(|t| t == key) {
302 return write!(
303 f,
304 "`{key}` in `bynk.toml` must be a table — write it as `[{key}]`"
305 );
306 }
307 write!(f, "`bynk.toml` has a key `{key}` outside any table — ")?;
308 match MANIFEST_TABLES
309 .iter()
310 .find(|(_, keys)| keys.contains(&key.as_str()))
311 {
312 Some((table, _)) => write!(f, "did you mean it under `[{table}]`?"),
313 None => write!(f, "keys belong in {}", table_list("or")),
314 }
315 }
316 ProjectPathsError::UnknownTableKey { table, key } => {
317 let keys = MANIFEST_TABLES
318 .iter()
319 .find(|(t, _)| t == table)
320 .map(|(_, k)| *k)
321 .unwrap_or_default();
322 write!(f, "`[{table}]` has no key named `{key}` — ")?;
323 match did_you_mean(key, keys.iter().copied()) {
324 Some(k) => write!(f, "did you mean `{k}`?"),
325 None => {
326 let list: Vec<String> = keys.iter().map(|k| format!("`{k}`")).collect();
327 write!(f, "its keys are {}", list.join(", "))
328 }
329 }
330 }
331 ProjectPathsError::NotAStringList { table, key } => write!(
332 f,
333 "`[{table}]` `{key}` must be a list of strings, like `{key} = [\"nodejs_compat\"]`"
334 ),
335 ProjectPathsError::BadTestTarget { target, reason } => {
336 write!(f, "`[test.targets.{target}]` {reason}")
337 }
338 ProjectPathsError::ProjectAndPackage => write!(
339 f,
340 "`bynk.toml` has both `[project]` and `[package]` — a package is named by `[package] name`, so remove `[project]`"
341 ),
342 ProjectPathsError::BadPackage { key, reason } => {
343 write!(f, "`[package]` `{key}` {reason}")
344 }
345 ProjectPathsError::BadDependency {
346 table,
347 alias,
348 reason,
349 } => write!(f, "`[{table}]` `{alias}` {reason}"),
350 ProjectPathsError::UnknownEntryKey { table, entry, key } => {
351 let keys = MANIFEST_MAP_TABLES
352 .iter()
353 .find(|(t, _)| t == table)
354 .map(|(_, k)| *k)
355 .unwrap_or_default();
356 let place = if *table == "registries" {
357 format!("`[registries.{entry}]`")
358 } else {
359 format!("`[{table}]` `{entry}`")
360 };
361 write!(f, "{place} has no key named `{key}` — ")?;
362 match did_you_mean(key, keys.iter().copied()) {
363 Some(k) => write!(f, "did you mean `{k}`?"),
364 None => {
365 let list: Vec<String> = keys.iter().map(|k| format!("`{k}`")).collect();
366 write!(f, "its keys are {}", list.join(", "))
367 }
368 }
369 }
370 ProjectPathsError::BadRegistry { name, reason } => {
371 write!(f, "`[registries.{name}]` {reason}")
372 }
373 ProjectPathsError::RegistryCredential { name } => write!(
374 f,
375 "`[registries.{name}]` has a `token` — credentials belong in `{}` or the user credentials file, never in `bynk.toml`",
376 crate::manifest::registry_token_var(name)
377 ),
378 ProjectPathsError::BadWorkspace { reason } => write!(f, "`[workspace]` {reason}"),
379 ProjectPathsError::WorkspaceRootTable(table) => write!(
380 f,
381 "a workspace root's `bynk.toml` holds only `[workspace]` and `[registries]` — `[{table}]` belongs in a member's `bynk.toml`"
382 ),
383 ProjectPathsError::WorkspaceRootSources => write!(
384 f,
385 "a workspace root holds no sources of its own — move them into a member"
386 ),
387 ProjectPathsError::WorkspaceRoot => write!(
388 f,
389 "this is a workspace root, not a project — run the command inside a member (`bynk check`, `fmt` and `test` run across every member from here)"
390 ),
391 ProjectPathsError::MemberRegistries { workspace } => write!(
392 f,
393 "`[registries]` belongs in the workspace root's `bynk.toml` (`{}`), not a member's",
394 workspace.join("bynk.toml").display()
395 ),
396 ProjectPathsError::NestedWorkspace { workspace } => write!(
397 f,
398 "this project is a member of the workspace at `{}`, so it can't declare a `[workspace]` of its own",
399 workspace.display()
400 ),
401 }
402 }
403}
404
405pub fn check_manifest(
416 project_root: &Path,
417 overlay: &HashMap<PathBuf, String>,
418) -> Result<(), ProjectPathsError> {
419 let toml_path = project_root.join("bynk.toml");
420 let Ok(content) = read_source(&toml_path, overlay) else {
421 return Ok(());
422 };
423 check_manifest_str(&content)?;
424 let doc = content
428 .parse::<toml::Table>()
429 .map_err(|_| ProjectPathsError::Malformed)?;
430 if (doc.contains_key("registries") || doc.contains_key("workspace"))
431 && let Some(workspace) = crate::workspace::find_parent_workspace(project_root)
432 {
433 return Err(if doc.contains_key("workspace") {
434 ProjectPathsError::NestedWorkspace { workspace }
435 } else {
436 ProjectPathsError::MemberRegistries { workspace }
437 });
438 }
439 Ok(())
440}
441
442pub fn check_manifest_str(content: &str) -> Result<(), ProjectPathsError> {
445 let doc = content
446 .parse::<toml::Table>()
447 .map_err(|_| ProjectPathsError::Malformed)?;
448 let mut declared: Vec<&'static str> = Vec::new();
449 for (name, value) in &doc {
450 if PLANNED_TABLES.iter().any(|(t, _)| t == name) {
453 return Err(ProjectPathsError::UnknownTable(name.clone()));
454 }
455 let known = MANIFEST_TABLES
456 .iter()
457 .map(|(t, keys)| (*t, *keys, false))
458 .chain(
459 MANIFEST_MAP_TABLES
460 .iter()
461 .map(|(t, keys)| (*t, *keys, true)),
462 )
463 .find(|(t, _, _)| t == name);
464 let Some((table, keys, is_map)) = known else {
465 return Err(if value.is_table() {
466 ProjectPathsError::UnknownTable(name.clone())
467 } else {
468 ProjectPathsError::TopLevelKey(name.clone())
469 });
470 };
471 let Some(entries) = value.as_table() else {
475 return Err(ProjectPathsError::TopLevelKey(name.clone()));
476 };
477 declared.push(table);
478 if is_map {
481 match table {
482 "dependencies" | "dev-dependencies" => {
483 crate::manifest::dependencies_from(table, entries)?;
484 }
485 _ => {
486 crate::manifest::registries_from(entries)?;
487 }
488 }
489 continue;
490 }
491 if keys.is_empty() {
492 continue;
493 }
494 for key in entries.keys() {
495 if !keys.contains(&key.as_str()) {
496 return Err(match table {
497 "paths" => ProjectPathsError::UnknownKey(key.clone()),
498 _ => ProjectPathsError::UnknownTableKey {
499 table,
500 key: key.clone(),
501 },
502 });
503 }
504 }
505 match table {
506 "workers" => drop(workers_config_from(entries)?),
507 "unstable" => drop(unstable_config_from(entries)?),
508 "test" => drop(test_targets_from(entries)?),
509 "package" => drop(crate::manifest::package_config_from(entries)?),
510 "workspace" => drop(crate::manifest::workspace_config_from(entries)?),
511 _ => {}
512 }
513 }
514 if declared.contains(&"project") && declared.contains(&"package") {
516 return Err(ProjectPathsError::ProjectAndPackage);
517 }
518 if declared.contains(&"workspace")
519 && let Some(other) = declared.iter().find(|t| !WORKSPACE_ROOT_TABLES.contains(t))
520 {
521 return Err(ProjectPathsError::WorkspaceRootTable((*other).to_string()));
522 }
523 Ok(())
524}
525
526pub fn try_read_project_paths(project_root: &Path) -> Result<ProjectPaths, ProjectPathsError> {
536 let toml_path = project_root.join("bynk.toml");
537 let overlay = match fs::read_to_string(&toml_path) {
538 Ok(text) => HashMap::from([(toml_path, text)]),
539 Err(_) => HashMap::new(),
540 };
541 try_read_project_paths_with(project_root, &overlay)
542}
543
544pub fn try_read_project_paths_with(
547 project_root: &Path,
548 overlay: &HashMap<PathBuf, String>,
549) -> Result<ProjectPaths, ProjectPathsError> {
550 let toml_path = project_root.join("bynk.toml");
551 let Ok(content) = read_source(&toml_path, overlay) else {
552 return Ok(ProjectPaths::conventional(project_root));
553 };
554 let Ok(doc) = content.parse::<toml::Table>() else {
555 return Err(ProjectPathsError::Malformed);
556 };
557 let paths = doc.get("paths").and_then(|v| v.as_table());
558 if let Some(t) = paths {
559 for k in t.keys() {
560 if k != "include" && k != "exclude" {
561 return Err(ProjectPathsError::UnknownKey(k.clone()));
562 }
563 }
564 }
565 let list = |key: &str| -> Vec<PathBuf> {
566 match paths.and_then(|t| t.get(key)) {
567 Some(toml::Value::Array(items)) => items
568 .iter()
569 .filter_map(|v| v.as_str())
570 .map(PathBuf::from)
571 .collect(),
572 Some(toml::Value::String(s)) => vec![PathBuf::from(s)],
573 _ => Vec::new(),
574 }
575 };
576 let mut include = list("include");
577 let exclude = list("exclude");
578 if include.is_empty() {
579 include = ProjectPaths::conventional(project_root).include;
580 }
581 Ok(ProjectPaths { include, exclude })
582}
583
584#[derive(Debug, Clone, Default, PartialEq, Eq)]
587pub struct WorkersConfig {
588 pub compatibility_flags: Vec<String>,
594}
595
596pub fn try_read_workers_config_with(
604 project_root: &Path,
605 overlay: &HashMap<PathBuf, String>,
606) -> Result<WorkersConfig, ProjectPathsError> {
607 let toml_path = project_root.join("bynk.toml");
608 let Ok(content) = read_source(&toml_path, overlay) else {
609 return Ok(WorkersConfig::default());
610 };
611 let doc = content
612 .parse::<toml::Table>()
613 .map_err(|_| ProjectPathsError::Malformed)?;
614 match doc.get("workers").and_then(|v| v.as_table()) {
615 Some(t) => workers_config_from(t),
616 None => Ok(WorkersConfig::default()),
617 }
618}
619
620pub fn try_read_workers_config(project_root: &Path) -> Result<WorkersConfig, ProjectPathsError> {
622 let toml_path = project_root.join("bynk.toml");
623 let overlay = match fs::read_to_string(&toml_path) {
624 Ok(text) => HashMap::from([(toml_path, text)]),
625 Err(_) => HashMap::new(),
626 };
627 try_read_workers_config_with(project_root, &overlay)
628}
629
630#[derive(Debug, Clone, Default, PartialEq, Eq)]
633pub struct TestTarget {
634 pub urls: BTreeMap<String, String>,
637 pub secrets: Vec<String>,
640 pub production: bool,
642 pub vars: BTreeMap<String, String>,
645}
646
647pub fn try_read_test_targets(
651 project_root: &Path,
652) -> Result<BTreeMap<String, TestTarget>, ProjectPathsError> {
653 let Ok(content) = fs::read_to_string(project_root.join("bynk.toml")) else {
654 return Ok(BTreeMap::new());
655 };
656 let doc = content
657 .parse::<toml::Table>()
658 .map_err(|_| ProjectPathsError::Malformed)?;
659 match doc.get("test").and_then(|v| v.as_table()) {
660 Some(t) => test_targets_from(t),
661 None => Ok(BTreeMap::new()),
662 }
663}
664
665fn test_targets_from(
669 table: &toml::Table,
670) -> Result<BTreeMap<String, TestTarget>, ProjectPathsError> {
671 let mut out = BTreeMap::new();
672 let Some(targets) = table.get("targets") else {
673 return Ok(out);
674 };
675 let Some(targets) = targets.as_table() else {
676 return Err(ProjectPathsError::BadTestTarget {
677 target: "…".to_string(),
678 reason: "must be a table of named targets, like `[test.targets.preview]`".to_string(),
679 });
680 };
681 for (name, entry) in targets {
682 let bad = |reason: &str| ProjectPathsError::BadTestTarget {
683 target: name.clone(),
684 reason: reason.to_string(),
685 };
686 let Some(entry) = entry.as_table() else {
687 return Err(bad("must be a table"));
688 };
689 let strings =
690 |v: &toml::Value, what: &str| -> Result<BTreeMap<String, String>, ProjectPathsError> {
691 v.as_table()
692 .ok_or_else(|| bad(&format!("`{what}` must be a table of strings")))?
693 .iter()
694 .map(|(k, v)| {
695 v.as_str()
696 .map(|s| (k.clone(), s.to_string()))
697 .ok_or_else(|| bad(&format!("`{what}.{k}` must be a string")))
698 })
699 .collect()
700 };
701 let mut target = TestTarget::default();
702 for (key, value) in entry {
703 match key.as_str() {
704 "url" => target.urls = strings(value, "url")?,
705 "vars" => target.vars = strings(value, "vars")?,
706 "secrets" => {
707 target.secrets = value
708 .as_array()
709 .and_then(|a| a.iter().map(|v| v.as_str().map(str::to_string)).collect())
710 .ok_or_else(|| bad("`secrets` must be a list of names"))?;
711 }
712 "production" => {
713 target.production = value
714 .as_bool()
715 .ok_or_else(|| bad("`production` must be `true` or `false`"))?;
716 }
717 other => {
718 return Err(bad(&format!(
719 "has no key named `{other}` — its keys are `url`, `secrets`, `vars` and `production`"
720 )));
721 }
722 }
723 }
724 if target.urls.is_empty() {
725 return Err(bad(
726 "names no Worker URL — add `url.<worker> = \"https://…\"`",
727 ));
728 }
729 out.insert(name.clone(), target);
730 }
731 Ok(out)
732}
733
734pub fn duplicate_compatibility_flag_warning(
742 flag: &str,
743 default: bool,
744 manifest: PathBuf,
745) -> crate::AttributedError {
746 let message = if default {
747 format!(
748 "`bynk.toml`'s `[workers] compatibility_flags` lists `{flag}`, which every Bynk Worker already has"
749 )
750 } else {
751 format!("`bynk.toml`'s `[workers] compatibility_flags` lists `{flag}` more than once")
752 };
753 let mut error = bynk_syntax::CompileError::new(
754 "bynk.project.duplicate_compatibility_flag",
755 bynk_syntax::span::Span::default(),
756 message,
757 );
758 error
759 .notes
760 .push("the duplicate is dropped; remove it from `bynk.toml`".to_string());
761 crate::AttributedError::new(Some(manifest), error)
762}
763
764fn workers_config_from(table: &toml::Table) -> Result<WorkersConfig, ProjectPathsError> {
767 let not_a_list = || ProjectPathsError::NotAStringList {
768 table: "workers",
769 key: "compatibility_flags",
770 };
771 let compatibility_flags = match table.get("compatibility_flags") {
772 None => Vec::new(),
773 Some(toml::Value::Array(items)) => items
774 .iter()
775 .map(|v| v.as_str().map(str::to_owned).ok_or_else(not_a_list))
776 .collect::<Result<_, _>>()?,
777 Some(_) => return Err(not_a_list()),
778 };
779 Ok(WorkersConfig {
780 compatibility_flags,
781 })
782}
783
784#[derive(Debug, Clone, Default, PartialEq, Eq)]
787pub struct UnstableConfig {
788 pub allow: Vec<String>,
793}
794
795pub fn try_read_unstable_config_with(
800 project_root: &Path,
801 overlay: &HashMap<PathBuf, String>,
802) -> Result<UnstableConfig, ProjectPathsError> {
803 let toml_path = project_root.join("bynk.toml");
804 let Ok(content) = read_source(&toml_path, overlay) else {
805 return Ok(UnstableConfig::default());
806 };
807 let doc = content
808 .parse::<toml::Table>()
809 .map_err(|_| ProjectPathsError::Malformed)?;
810 match doc.get("unstable").and_then(|v| v.as_table()) {
811 Some(t) => unstable_config_from(t),
812 None => Ok(UnstableConfig::default()),
813 }
814}
815
816pub fn try_read_unstable_config(project_root: &Path) -> Result<UnstableConfig, ProjectPathsError> {
818 let toml_path = project_root.join("bynk.toml");
819 let overlay = match fs::read_to_string(&toml_path) {
820 Ok(text) => HashMap::from([(toml_path, text)]),
821 Err(_) => HashMap::new(),
822 };
823 try_read_unstable_config_with(project_root, &overlay)
824}
825
826pub fn read_unstable_config(
832 project_root: &Path,
833 overlay: &HashMap<PathBuf, String>,
834) -> UnstableConfig {
835 let manifest = project_root.join("bynk.toml");
836 let read = if overlay.contains_key(&manifest) {
837 try_read_unstable_config_with(project_root, overlay)
838 } else {
839 try_read_unstable_config(project_root)
840 };
841 read.unwrap_or_default()
842}
843
844fn unstable_config_from(table: &toml::Table) -> Result<UnstableConfig, ProjectPathsError> {
847 let not_a_list = || ProjectPathsError::NotAStringList {
848 table: "unstable",
849 key: "allow",
850 };
851 let allow = match table.get("allow") {
852 None => Vec::new(),
853 Some(toml::Value::Array(items)) => items
854 .iter()
855 .map(|v| v.as_str().map(str::to_owned).ok_or_else(not_a_list))
856 .collect::<Result<_, _>>()?,
857 Some(_) => return Err(not_a_list()),
858 };
859 Ok(UnstableConfig { allow })
860}
861
862pub fn commons_dir_for(name: &str) -> PathBuf {
868 let mut p = PathBuf::new();
869 let mut parts = name.split('.');
870 let first = parts.next().unwrap_or_default();
871 match crate::packages::PackageId::decode(first) {
872 Some(id) => p.push(id.out_dir()),
873 None => p.push(first),
874 }
875 for part in parts {
876 p.push(part);
877 }
878 p
879}
880
881pub fn ts_output_path(source: &Path) -> PathBuf {
882 let mut out = source.to_path_buf();
883 out.set_extension("ts");
884 out
885}
886
887pub fn worker_dir_name(context: &str) -> String {
890 context.replace('.', "-")
891}
892
893pub fn worker_handlers_source_path(context: &str) -> PathBuf {
897 PathBuf::from(format!(
898 "workers/{}/handlers.bynk",
899 worker_dir_name(context)
900 ))
901}
902
903pub fn worker_handlers_output_path(context: &str) -> PathBuf {
905 PathBuf::from(format!("workers/{}/handlers.ts", worker_dir_name(context)))
906}
907
908pub fn worker_file_source_path(context: &str, source: &Path) -> PathBuf {
920 let stem = source.file_stem().unwrap_or_default().to_string_lossy();
921 PathBuf::from(format!(
922 "workers/{}/handlers/{stem}.bynk",
923 worker_dir_name(context)
924 ))
925}
926
927fn stem_parts(rel_path: &Path) -> Vec<String> {
930 rel_path
931 .with_extension("")
932 .components()
933 .filter_map(|c| match c {
934 Component::Normal(s) => Some(s.to_string_lossy().to_string()),
935 _ => None,
936 })
937 .collect()
938}
939
940pub fn unit_path_matches(rel_path: &Path, qualified_name: &str) -> bool {
949 let dir = commons_dir_for(qualified_name);
952 let owned: Vec<String> = dir
953 .components()
954 .map(|c| c.as_os_str().to_string_lossy().into_owned())
955 .collect();
956 let name_parts: Vec<&str> = owned.iter().map(String::as_str).collect();
957 let stem_parts = stem_parts(rel_path);
958 let single_file_match = stem_parts.len() == name_parts.len()
959 && stem_parts
960 .iter()
961 .zip(name_parts.iter())
962 .all(|(a, b)| a == b);
963 single_file_match || is_multi_file_parts(&stem_parts, &name_parts)
964}
965
966fn is_multi_file_parts(stem_parts: &[String], name_parts: &[&str]) -> bool {
969 if stem_parts.is_empty() {
970 return false;
971 }
972 let parent_parts = &stem_parts[..stem_parts.len() - 1];
973 parent_parts.len() == name_parts.len()
974 && parent_parts
975 .iter()
976 .zip(name_parts.iter())
977 .all(|(a, b)| a == b)
978}
979
980pub fn is_multi_file_layout(rel_path: &Path, qualified_name: &str) -> bool {
990 let name_parts: Vec<&str> = qualified_name.split('.').collect();
991 is_multi_file_parts(&stem_parts(rel_path), &name_parts)
992}
993
994pub fn renamed_unit_name(old_rel: &Path, old_name: &str, new_rel: &Path) -> Option<String> {
1009 let name_parts: Vec<&str> = old_name.split('.').collect();
1010 let old_stem = stem_parts(old_rel);
1011 let new_stem = stem_parts(new_rel);
1012
1013 let suffix_matches = |haystack: &[String]| {
1014 haystack.len() >= name_parts.len() && {
1015 let prefix_len = haystack.len() - name_parts.len();
1016 haystack[prefix_len..]
1017 .iter()
1018 .zip(name_parts.iter())
1019 .all(|(a, b)| a == b)
1020 }
1021 };
1022
1023 if suffix_matches(&old_stem) {
1024 let prefix_len = old_stem.len() - name_parts.len();
1025 return (new_stem.len() >= prefix_len).then(|| new_stem[prefix_len..].join("."));
1026 }
1027 if !old_stem.is_empty() {
1028 let old_parent = &old_stem[..old_stem.len() - 1];
1029 if suffix_matches(old_parent) {
1030 let prefix_len = old_parent.len() - name_parts.len();
1031 if new_stem.is_empty() {
1032 return None;
1033 }
1034 let new_parent = &new_stem[..new_stem.len() - 1];
1035 return (new_parent.len() >= prefix_len).then(|| new_parent[prefix_len..].join("."));
1036 }
1037 }
1038 None
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043 use super::*;
1044 use std::path::{Path, PathBuf};
1045
1046 #[test]
1048 fn is_unpinned_range_true_for_wildcards_and_digitless() {
1049 assert!(is_unpinned_range(""));
1050 assert!(is_unpinned_range("*"));
1051 assert!(is_unpinned_range("x"));
1052 assert!(is_unpinned_range("X"));
1053 assert!(is_unpinned_range("latest"));
1054 assert!(is_unpinned_range("LATEST"));
1055 assert!(is_unpinned_range(" * ")); assert!(is_unpinned_range("workspace:*")); assert!(is_unpinned_range("beta"));
1058 }
1059
1060 #[test]
1061 fn is_unpinned_range_false_when_a_digit_is_present() {
1062 assert!(!is_unpinned_range("1.0.0"));
1063 assert!(!is_unpinned_range("^1.2"));
1064 assert!(!is_unpinned_range("~0.1"));
1065 assert!(!is_unpinned_range(">=2"));
1066 assert!(!is_unpinned_range("18"));
1067 }
1068
1069 #[test]
1071 fn read_project_paths_with_honours_a_virtual_bynk_toml() {
1072 let root = PathBuf::from("/nonexistent-bynk-test-root-57");
1073 let mut overlay = HashMap::new();
1074 overlay.insert(
1075 root.join("bynk.toml"),
1076 "[paths]\ninclude = [\"app\"]\nexclude = [\"vendor\"]\n".to_string(),
1077 );
1078 let paths = read_project_paths_with(&root, &overlay);
1079 assert_eq!(paths.include, vec![PathBuf::from("app")]);
1080 assert_eq!(paths.exclude, vec![PathBuf::from("vendor")]);
1081 }
1082
1083 #[test]
1084 fn read_project_paths_with_falls_back_to_conventional_with_no_overlay_entry() {
1085 let root = PathBuf::from("/nonexistent-bynk-test-root-57-empty");
1088 let paths = read_project_paths_with(&root, &HashMap::new());
1089 let conventional = ProjectPaths::conventional(&root);
1090 assert_eq!(paths.include, conventional.include);
1091 assert_eq!(paths.exclude, conventional.exclude);
1092 }
1093
1094 #[test]
1097 fn read_project_paths_with_honours_three_or_more_include_entries() {
1098 let root = PathBuf::from("/nonexistent-bynk-test-root-1113-many-includes");
1099 let mut overlay = HashMap::new();
1100 overlay.insert(
1101 root.join("bynk.toml"),
1102 "[paths]\ninclude = [\"src\", \"tests\", \"examples\"]\n".to_string(),
1103 );
1104 let paths = try_read_project_paths_with(&root, &overlay).expect("must parse");
1105 assert_eq!(
1106 paths.include,
1107 vec![
1108 PathBuf::from("src"),
1109 PathBuf::from("tests"),
1110 PathBuf::from("examples"),
1111 ]
1112 );
1113 }
1114
1115 #[test]
1117 fn render_package_json_declares_es_modules() {
1118 let bare = render_package_json(&std::collections::BTreeMap::new());
1120 assert_eq!(bare, "{\n \"type\": \"module\"\n}\n");
1121 let mut deps = std::collections::BTreeMap::new();
1122 deps.insert("jose".to_string(), "^5.0.0".to_string());
1123 let with_deps = render_package_json(&deps);
1124 assert_eq!(
1125 with_deps,
1126 "{\n \"type\": \"module\",\n \"dependencies\": {\n \"jose\": \"^5.0.0\"\n }\n}\n"
1127 );
1128 }
1129
1130 #[test]
1131 fn render_package_json_renders_sorted_dependencies() {
1132 let mut deps = std::collections::BTreeMap::new();
1133 deps.insert("zod".to_string(), "^3.22.4".to_string());
1134 deps.insert("hono".to_string(), "^4.0.0".to_string());
1135 let out = render_package_json(&deps);
1136 assert!(
1138 out.find("\"hono\"").unwrap() < out.find("\"zod\"").unwrap(),
1139 "dependencies render in sorted order:\n{out}"
1140 );
1141 assert!(out.contains("\"hono\": \"^4.0.0\""), "{out}");
1142 }
1143
1144 #[test]
1150 fn render_package_json_escapes_the_control_range() {
1151 let mut deps = std::collections::BTreeMap::new();
1152 deps.insert("pkg\nname".to_string(), "^1.0\u{1}0".to_string());
1153 let out = render_package_json(&deps);
1154 assert!(out.contains("\"pkg\\nname\""), "{out}");
1155 assert!(out.contains("\"^1.0\\u00010\""), "{out}");
1156 assert!(
1159 !out.lines().any(|l| l.chars().any(|c| (c as u32) < 0x20)),
1160 "a raw control character reached the output:\n{out:?}"
1161 );
1162 }
1163
1164 #[test]
1165 fn render_package_json_escapes_structural_characters() {
1166 let mut deps = std::collections::BTreeMap::new();
1167 deps.insert("a\"b".to_string(), "c\\d".to_string());
1168 let out = render_package_json(&deps);
1169 assert!(out.contains(r#""a\"b": "c\\d""#), "{out}");
1170 }
1171
1172 #[test]
1174 fn normalize_rel_resolves_dot_and_parent() {
1175 assert_eq!(
1176 normalize_rel(Path::new("./tokens.binding.ts")),
1177 PathBuf::from("tokens.binding.ts")
1178 );
1179 assert_eq!(normalize_rel(Path::new("a/./b")), PathBuf::from("a/b"));
1180 assert_eq!(normalize_rel(Path::new("a/../b")), PathBuf::from("b"));
1181 assert_eq!(normalize_rel(Path::new("a/b/../../c")), PathBuf::from("c"));
1182 assert_eq!(normalize_rel(Path::new("a/b")), PathBuf::from("a/b"));
1183 }
1184
1185 #[test]
1186 fn normalize_rel_drops_root_and_pops_through_empty() {
1187 assert_eq!(normalize_rel(Path::new("/a/b")), PathBuf::from("a/b"));
1189 assert_eq!(normalize_rel(Path::new("../a")), PathBuf::from("a"));
1191 }
1192
1193 #[test]
1195 fn commons_dir_for_splits_dotted_name_into_dirs() {
1196 assert_eq!(commons_dir_for("a.b.c"), PathBuf::from("a/b/c"));
1197 assert_eq!(commons_dir_for("foo"), PathBuf::from("foo"));
1198 }
1199
1200 #[test]
1201 fn ts_output_path_sets_ts_extension() {
1202 assert_eq!(
1203 ts_output_path(Path::new("foo.bynk")),
1204 PathBuf::from("foo.ts")
1205 );
1206 assert_eq!(
1207 ts_output_path(Path::new("a/b.bynk")),
1208 PathBuf::from("a/b.ts")
1209 );
1210 assert_eq!(ts_output_path(Path::new("foo")), PathBuf::from("foo.ts"));
1211 }
1212
1213 #[test]
1215 fn worker_paths_dasherise_and_root_under_workers() {
1216 assert_eq!(worker_dir_name("commerce.payment"), "commerce-payment");
1217 assert_eq!(worker_dir_name("plain"), "plain");
1218 assert_eq!(
1219 worker_handlers_source_path("commerce.payment"),
1220 PathBuf::from("workers/commerce-payment/handlers.bynk")
1221 );
1222 assert_eq!(
1223 worker_handlers_output_path("commerce.payment"),
1224 PathBuf::from("workers/commerce-payment/handlers.ts")
1225 );
1226 }
1227
1228 #[test]
1229 fn a_split_context_file_has_its_own_module_under_handlers() {
1230 assert_eq!(
1231 worker_file_source_path("shop.orders", Path::new("shop/orders/place.bynk")),
1232 PathBuf::from("workers/shop-orders/handlers/place.bynk")
1233 );
1234 }
1235
1236 #[test]
1238 fn unit_path_matches_single_file_layout() {
1239 assert!(unit_path_matches(Path::new("a/b/c.bynk"), "a.b.c"));
1240 assert!(unit_path_matches(Path::new("foo.bynk"), "foo"));
1241 }
1242
1243 #[test]
1244 fn unit_path_matches_multi_file_layout() {
1245 assert!(unit_path_matches(Path::new("a/b/c/handlers.bynk"), "a.b.c"));
1247 assert!(unit_path_matches(Path::new("a/b/c/anything.bynk"), "a.b.c"));
1248 }
1249
1250 #[test]
1251 fn unit_path_matches_rejects_misalignment() {
1252 assert!(!unit_path_matches(Path::new("a/b.bynk"), "a.b.c"));
1253 assert!(!unit_path_matches(Path::new("x/y/z.bynk"), "a.b.c"));
1254 }
1255
1256 #[test]
1258 fn is_multi_file_layout_true_only_for_directory_layout() {
1259 assert!(is_multi_file_layout(Path::new("thing/a.bynk"), "thing"));
1261 assert!(is_multi_file_layout(Path::new("thing/b.bynk"), "thing"));
1262 assert!(is_multi_file_layout(Path::new("a/b/one.bynk"), "a.b"));
1264 }
1265
1266 #[test]
1267 fn is_multi_file_layout_false_for_single_file_and_misalignment() {
1268 assert!(!is_multi_file_layout(Path::new("thing.bynk"), "thing"));
1270 assert!(!is_multi_file_layout(Path::new("a/b.bynk"), "a.b"));
1272 assert!(!is_multi_file_layout(Path::new("other/a.bynk"), "thing"));
1274 }
1275
1276 #[test]
1278 fn renamed_unit_name_single_file() {
1279 assert_eq!(
1280 renamed_unit_name(
1281 Path::new("a/b/c.bynk"),
1282 "a.b.c",
1283 Path::new("a/b/renamed.bynk")
1284 ),
1285 Some("a.b.renamed".to_string())
1286 );
1287 assert_eq!(
1288 renamed_unit_name(Path::new("foo.bynk"), "foo", Path::new("bar.bynk")),
1289 Some("bar".to_string())
1290 );
1291 }
1292
1293 #[test]
1294 fn renamed_unit_name_multi_file_member_rename_is_a_no_op() {
1295 assert_eq!(
1299 renamed_unit_name(
1300 Path::new("a/b/c/old.bynk"),
1301 "a.b.c",
1302 Path::new("a/b/c/new.bynk")
1303 ),
1304 Some("a.b.c".to_string())
1305 );
1306 }
1307
1308 #[test]
1309 fn renamed_unit_name_multi_file_directory_move() {
1310 assert_eq!(
1311 renamed_unit_name(
1312 Path::new("a/b/c/handlers.bynk"),
1313 "a.b.c",
1314 Path::new("a/b/renamed/handlers.bynk")
1315 ),
1316 Some("a.b.renamed".to_string())
1317 );
1318 }
1319
1320 #[test]
1321 fn renamed_unit_name_none_on_preexisting_misalignment() {
1322 assert_eq!(
1323 renamed_unit_name(Path::new("x/y/z.bynk"), "a.b.c", Path::new("x/y/w.bynk")),
1324 None
1325 );
1326 }
1327
1328 #[test]
1329 fn renamed_unit_name_tolerates_a_shared_include_root_prefix() {
1330 assert_eq!(
1336 renamed_unit_name(
1337 Path::new("src/billing/charge.bynk"),
1338 "billing.charge",
1339 Path::new("src/billing/pay.bynk")
1340 ),
1341 Some("billing.pay".to_string())
1342 );
1343 assert_eq!(
1345 renamed_unit_name(
1346 Path::new("src/a/b/c/handlers.bynk"),
1347 "a.b.c",
1348 Path::new("src/a/b/renamed/handlers.bynk")
1349 ),
1350 Some("a.b.renamed".to_string())
1351 );
1352 }
1353}
1354
1355#[cfg(test)]
1356mod manifest_tests {
1357 use super::*;
1358
1359 fn err(manifest: &str) -> String {
1360 check_manifest_str(manifest)
1361 .expect_err("the manifest is refused")
1362 .to_string()
1363 }
1364
1365 #[test]
1367 fn the_documented_tables_are_accepted() {
1368 check_manifest_str(
1369 "[project]\nname = \"p\"\nversion = \"0.1.0\"\n\n[paths]\ninclude = [\"src\"]\nexclude = []\n\n\
1370 [fmt]\nindent = \"tab\"\nmax_line_width = 100\n\n[lsp]\ndiagnostics_mode = \"live\"\n\
1371 diagnostics_debounce_ms = 300\n\n[workers]\ncompatibility_flags = [\"nodejs_compat\"]\n",
1372 )
1373 .expect("accepted");
1374 check_manifest_str("").expect("an empty manifest is accepted");
1375 }
1376
1377 #[test]
1378 fn a_planned_table_names_its_issue() {
1379 assert!(err("[deploy]\ngroups = []\n").contains("not yet supported (#551)"));
1380 }
1381
1382 #[test]
1383 fn a_typod_table_suggests_the_nearest() {
1384 assert!(err("[pahts]\n").contains("did you mean `[paths]`?"));
1385 assert!(err("[fnt]\n").contains("did you mean `[fmt]`?"));
1386 assert!(err("[zzzzzz]\n").contains("the tables are"));
1387 }
1388
1389 #[test]
1390 fn an_unknown_key_in_project_or_lsp_suggests_the_nearest() {
1391 assert!(err("[project]\nnmae = \"p\"\n").contains("did you mean `name`?"));
1392 assert!(
1393 err("[lsp]\ndiagnostics_mod = \"live\"\n").contains("did you mean `diagnostics_mode`?")
1394 );
1395 assert!(err("[project]\nlicense = \"MIT\"\n").contains("its keys are `name`, `version`"));
1396 }
1397
1398 #[test]
1399 fn a_paths_key_keeps_its_own_error() {
1400 assert!(matches!(
1401 check_manifest_str("[paths]\nout = \"out\"\n"),
1402 Err(ProjectPathsError::UnknownKey(k)) if k == "out"
1403 ));
1404 }
1405
1406 #[test]
1407 fn a_top_level_key_points_at_its_table() {
1408 assert!(err("name = \"p\"\n").contains("did you mean it under `[project]`?"));
1409 }
1410
1411 #[test]
1414 fn a_table_name_with_a_plain_value_must_be_a_table() {
1415 assert!(err("paths = \"src\"\n").contains("must be a table — write it as `[paths]`"));
1416 assert!(err("lsp = 3\n").contains("`[lsp]`"));
1417 assert!(err("fmt = true\n").contains("`[fmt]`"));
1418 check_manifest_str("paths = { include = [\"src\"] }\n").expect("accepted");
1420 }
1421
1422 #[test]
1424 fn a_planned_table_in_any_shape_names_its_issue() {
1425 assert!(err("[[deploy]]\nname = \"acme\"\n").contains("(#551)"));
1426 assert!(err("deploy = [\"acme\"]\n").contains("(#551)"));
1427 }
1428
1429 #[test]
1431 fn a_map_table_in_another_shape_must_be_a_table() {
1432 assert_eq!(
1433 err("[[dependencies]]\nname = \"acme\"\n"),
1434 "`dependencies` in `bynk.toml` must be a table — write it as `[dependencies]`"
1435 );
1436 assert_eq!(
1437 err("registries = [\"acme\"]\n"),
1438 "`registries` in `bynk.toml` must be a table — write it as `[registries]`"
1439 );
1440 assert_eq!(
1441 err("[dependencis]\n"),
1442 "`bynk.toml` has no table named `[dependencis]` — did you mean `[dependencies]`?"
1443 );
1444 }
1445
1446 #[test]
1448 fn fmt_keys_are_left_to_the_formatter() {
1449 check_manifest_str("[fmt]\nanything = 1\n").expect("not this check's concern");
1450 }
1451
1452 #[test]
1455 fn workers_compatibility_flags_must_be_a_list_of_strings() {
1456 let msg = "`[workers]` `compatibility_flags` must be a list of strings";
1457 assert!(err("[workers]\ncompatibility_flags = \"nodejs_compat\"\n").contains(msg));
1458 assert!(err("[workers]\ncompatibility_flags = [\"a\", 1]\n").contains(msg));
1459 assert!(err("[workers]\ncompatibility_flags = [[\"a\"]]\n").contains(msg));
1460 assert!(
1461 err("[workers]\ncompatibility_flag = []\n")
1462 .contains("did you mean `compatibility_flags`?")
1463 );
1464 check_manifest_str("[workers]\ncompatibility_flags = [\"no_such_flag_yet\"]\n")
1465 .expect("flag names are not validated");
1466 check_manifest_str("[workers]\n").expect("an empty `[workers]` is accepted");
1467 }
1468
1469 #[test]
1472 fn unstable_allow_must_be_a_list_of_strings() {
1473 let msg = "`[unstable]` `allow` must be a list of strings";
1474 assert!(err("[unstable]\nallow = \"bynk.cloudflare.D1\"\n").contains(msg));
1475 assert!(err("[unstable]\nallow = [\"a\", 1]\n").contains(msg));
1476 assert!(err("[unstable]\nallows = []\n").contains("did you mean `allow`?"));
1477 check_manifest_str("[unstable]\nallow = [\"bynk.anything\"]\n")
1478 .expect("item paths are the checker's to validate");
1479 check_manifest_str("[unstable]\n").expect("an empty `[unstable]` is accepted");
1480 }
1481
1482 #[test]
1483 fn unstable_config_reads_the_entries_in_order() {
1484 let root = PathBuf::from("/nonexistent-bynk-test-root-1963");
1485 let read = |manifest: &str| {
1486 let overlay = HashMap::from([(root.join("bynk.toml"), manifest.to_string())]);
1487 try_read_unstable_config_with(&root, &overlay)
1488 };
1489 assert_eq!(
1490 read("[unstable]\nallow = [\"b.x\", \"a.y\"]\n")
1491 .unwrap()
1492 .allow,
1493 vec!["b.x", "a.y"]
1494 );
1495 assert_eq!(
1496 read("[project]\nname = \"p\"\n").unwrap(),
1497 UnstableConfig::default()
1498 );
1499 assert!(matches!(
1500 read("[unstable]\nallow = 3\n"),
1501 Err(ProjectPathsError::NotAStringList {
1502 table: "unstable",
1503 key: "allow"
1504 })
1505 ));
1506 }
1507
1508 #[test]
1509 fn workers_config_reads_the_flags_in_order() {
1510 let root = PathBuf::from("/nonexistent-bynk-test-root-1890");
1511 let read = |manifest: &str| {
1512 let overlay = HashMap::from([(root.join("bynk.toml"), manifest.to_string())]);
1513 try_read_workers_config_with(&root, &overlay)
1514 };
1515 assert_eq!(
1516 read("[workers]\ncompatibility_flags = [\"b\", \"a\", \"b\"]\n")
1517 .unwrap()
1518 .compatibility_flags,
1519 vec!["b", "a", "b"],
1520 "order and duplicates are kept; deduplication is the emitter's"
1521 );
1522 assert_eq!(
1523 read("[project]\nname = \"p\"\n").unwrap(),
1524 WorkersConfig::default()
1525 );
1526 assert_eq!(
1527 try_read_workers_config_with(&root, &HashMap::new()).unwrap(),
1528 WorkersConfig::default(),
1529 "no manifest at all"
1530 );
1531 assert!(matches!(
1532 read("[workers]\ncompatibility_flags = [true]\n"),
1533 Err(ProjectPathsError::NotAStringList { .. })
1534 ));
1535 assert!(matches!(
1536 read("[workers\n"),
1537 Err(ProjectPathsError::Malformed)
1538 ));
1539 }
1540
1541 #[test]
1542 fn a_malformed_manifest_is_malformed() {
1543 assert!(matches!(
1544 check_manifest_str("[project\n"),
1545 Err(ProjectPathsError::Malformed)
1546 ));
1547 }
1548
1549 #[test]
1554 fn the_readers_accept_the_shapes_the_manifest_schema_offers() {
1555 let root = PathBuf::from("/nonexistent-bynk-test-root-2096");
1556 let overlay =
1557 |manifest: &str| HashMap::from([(root.join("bynk.toml"), manifest.to_string())]);
1558
1559 for manifest in [
1561 "[paths]\ninclude = \"src\"\n",
1562 "[paths]\ninclude = [\"src\"]\n",
1563 ] {
1564 check_manifest_str(manifest).expect("the manifest check accepts it");
1565 let paths = try_read_project_paths_with(&root, &overlay(manifest))
1566 .unwrap_or_else(|e| panic!("{manifest:?}: {e}"));
1567 assert_eq!(paths.include, vec![PathBuf::from("src")], "{manifest:?}");
1568 }
1569
1570 let manifest = "[workers]\ncompatibility_flags = [\"x\"]\n";
1572 check_manifest_str(manifest).expect("the manifest check accepts it");
1573 assert_eq!(
1574 try_read_workers_config_with(&root, &overlay(manifest))
1575 .unwrap()
1576 .compatibility_flags,
1577 vec!["x"]
1578 );
1579 let manifest = "[unstable]\nallow = [\"x\"]\n";
1580 check_manifest_str(manifest).expect("the manifest check accepts it");
1581 assert_eq!(
1582 try_read_unstable_config_with(&root, &overlay(manifest))
1583 .unwrap()
1584 .allow,
1585 vec!["x"]
1586 );
1587
1588 let manifest = "[workers]\ncompatibility_flags = \"x\"\n";
1591 assert!(matches!(
1592 try_read_workers_config_with(&root, &overlay(manifest)),
1593 Err(ProjectPathsError::NotAStringList { .. })
1594 ));
1595 assert!(check_manifest_str(manifest).is_err());
1596 let manifest = "[unstable]\nallow = \"x\"\n";
1597 assert!(matches!(
1598 try_read_unstable_config_with(&root, &overlay(manifest)),
1599 Err(ProjectPathsError::NotAStringList { .. })
1600 ));
1601 assert!(check_manifest_str(manifest).is_err());
1602
1603 let manifest = "[test.targets.preview]\nurl.shop-orders = \"https://x\"\nsecrets = [\"S\"]\nvars = { P = \"fake\" }\nproduction = false\n";
1605 check_manifest_str(manifest).expect("a well-formed target is accepted");
1606 let targets = test_targets_from(
1607 manifest.parse::<toml::Table>().unwrap()["test"]
1608 .as_table()
1609 .unwrap(),
1610 )
1611 .unwrap();
1612 let preview = &targets["preview"];
1613 assert_eq!(preview.urls["shop-orders"], "https://x");
1614 assert_eq!(preview.secrets, ["S"]);
1615 assert_eq!(preview.vars["P"], "fake");
1616 assert!(!preview.production);
1617 for (manifest, needle) in [
1618 ("[test.targets.p]\nsecrets = []\n", "names no Worker URL"),
1619 (
1620 "[test.targets.p]\nurl.a = \"x\"\nsecret = \"S\"\n",
1621 "no key named `secret`",
1622 ),
1623 (
1624 "[test.targets.p]\nurl.a = \"x\"\nproduction = \"yes\"\n",
1625 "`production` must be",
1626 ),
1627 ("[test.targets.p]\nurl.a = 1\n", "`url.a` must be a string"),
1628 ("[test]\ntarget = 1\n", "no key named `target`"),
1629 ] {
1630 let err = check_manifest_str(manifest)
1631 .expect_err(manifest)
1632 .to_string();
1633 assert!(err.contains(needle), "{manifest:?}: {err}");
1634 }
1635 }
1636}