Skip to main content

bynk_project/
paths.rs

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
8/// v0.17 [DECISION L] stub: a version range is *unpinned* — and rejected — when
9/// it is empty, `*`/`x`/`latest`, or otherwise carries no concrete version
10/// number. A pinned range names at least one digit (`^5`, `~1.2`, `1.2.3`,
11/// `>=1.0 <2`). No allow-list or registry check yet.
12pub 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
20/// Render the build output's minimal `package.json`: `"type": "module"`, and
21/// the adapter-declared dependencies when there are any.
22///
23/// #2148: the emitted modules are ES modules, and the emitted tsconfig's
24/// `module: NodeNext` takes each file's format from the nearest
25/// `package.json`. Declaring `"type": "module"` beside the output makes `tsc`
26/// check and build it as ES modules, under the same rules Node applies when it
27/// runs them (`.js` specifiers on relative imports, no CommonJS idioms).
28pub 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
43/// Normalise a relative path by resolving `.` and `..` components, so a binding
44/// clause like `./tokens.binding.ts` beside `src/tokens.bynk` yields the output
45/// path `tokens.binding.ts`.
46pub 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/// v0.113 (DECISION S): the project's source tree, read from `bynk.toml`'s
62/// `[paths]` section. Test-ness is a property of the `suite` declaration, not of
63/// a directory, so the layout is a flat **`include`** list of trees to compile
64/// and an **`exclude`** list of subtrees to skip — not the role-named
65/// `src`/`tests` split. Each `include` entry is a root walked for `.bynk` files;
66/// a file's identity path is relative to the `include` root that contains it.
67#[derive(Debug, Clone)]
68pub struct ProjectPaths {
69    /// Trees to compile, relative to the project root. Defaults to the
70    /// conventional roots that exist (`src`, and `tests` when present), else the
71    /// project root itself.
72    pub include: Vec<PathBuf>,
73    /// Subtrees to skip during discovery (monorepo, vendored, or generated
74    /// `.bynk`), relative to the project root.
75    pub exclude: Vec<PathBuf>,
76}
77
78impl ProjectPaths {
79    /// The default layout when `bynk.toml` declares no `[paths] include`: the
80    /// conventional `src`/`tests` roots that exist under `project_root`, or the
81    /// project root itself when neither does. This keeps a conventional
82    /// `src/`(+`tests/`) project working with no config, and lets a flat project
83    /// (`.bynk` at the root, no `src/`) compile with no config either.
84    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/// Like [`try_read_project_paths`], but honours `overlay` for `bynk.toml`
102/// itself — the in-memory test seam's (#57) one remaining disk read outside
103/// `discovery::read_source`, now routed through the same helper so a test
104/// can supply a virtual `bynk.toml` with no on-disk file at all. `#[cfg(test)]`
105/// because that's its only consumer today; drop the gate if a non-test caller
106/// needs it (`try_read_project_paths_with`, which this wraps, has none of that
107/// restriction — production code already reaches it through the always-on
108/// `try_read_project_paths`).
109#[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/// A problem in `bynk.toml` that [`try_read_project_paths`] surfaces instead
119/// of silently falling back to the conventional layout.
120#[derive(Debug)]
121pub enum ProjectPathsError {
122    /// `bynk.toml` exists but does not parse as TOML (e.g. a trailing comma).
123    Malformed,
124    /// `[paths]` has a key other than `include`/`exclude` — most likely a typo
125    /// (`inculde`) that was silently read as "no include list".
126    UnknownKey(String),
127    /// #1665: a top-level entry that isn't one of the manifest's tables
128    /// ([`MANIFEST_TABLES`]): a typo (`[pahts]`), or a table for a planned
129    /// feature (`[dependencies]`) that would otherwise read as working.
130    UnknownTable(String),
131    /// #1665: a key at the top level of `bynk.toml`, outside any table.
132    TopLevelKey(String),
133    /// #1665: a key in `[project]`, `[lsp]` or `[workers]` that the table
134    /// doesn't have. (`[paths]` reports [`Self::UnknownKey`]; `[fmt]` is
135    /// checked by its own reader, which owns its keys.)
136    UnknownTableKey { table: &'static str, key: String },
137    /// #1890: a key that must be a list of strings holds something else: a
138    /// bare string, a number, or a list with a non-string entry
139    /// (`[workers] compatibility_flags = ["a", 1]`).
140    NotAStringList {
141        table: &'static str,
142        key: &'static str,
143    },
144    /// #2138: a `[test.targets.<name>]` entry that is not well formed.
145    BadTestTarget { target: String, reason: String },
146}
147
148/// #1665: the tables `bynk.toml` may hold, each with the keys it accepts.
149/// `[fmt]`'s keys are `bynk-fmt`'s to check (`FmtConfig`, `deny_unknown_fields`),
150/// so its list here is empty and unchecked.
151pub 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
161/// Tables a user might write for a feature that is designed but not built,
162/// with the issue that tracks it.
163const PLANNED_TABLES: &[(&str, &str)] = &[
164    ("dependencies", "#843"),
165    ("dev-dependencies", "#843"),
166    ("workspace", "#843"),
167    ("deploy", "#551"),
168];
169
170/// The manifest's tables as
171/// `` `[project]`, `[paths]`, `[fmt]`, `[lsp]` <conj> `[workers]` ``, from
172/// [`MANIFEST_TABLES`], so a message can't fall behind it.
173fn 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
184/// The closest of `candidates` to `name`, if any is within two edits.
185fn 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
194/// Levenshtein distance over chars.
195fn 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                // #1770 review: a table's own name written as a plain value
233                // (`paths = "src"`).
234                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
275/// #1665: check `bynk.toml`'s table set and the keys of `[project]` and
276/// `[lsp]` ([`MANIFEST_TABLES`]). Every other reader takes only its own table
277/// and ignores the rest, so an unknown table (`[dependencies]`, a typo'd
278/// `[pahts]`) used to build cleanly with none of its intended behaviour.
279///
280/// Kept apart from [`try_read_project_paths_with`] on purpose: the CLIs call
281/// both and refuse an unknown table, while the language server reads `[paths]`
282/// alone and keeps serving, so an extra table can't cost an editor its
283/// `include` layout. `Ok` when there is no `bynk.toml`. A manifest that doesn't
284/// parse is [`ProjectPathsError::Malformed`].
285pub 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
296/// [`check_manifest`] over a manifest's text.
297pub 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        // #1770 review: a planned table names its issue whatever shape it is
303        // written in (`[[dependencies]]`, `dependencies = [...]`).
304        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        // #1770 review: a known table's name holding a plain value
315        // (`paths = "src"`) would otherwise read as an absent table.
316        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
346/// Read `bynk.toml`'s `[paths]` section, surfacing a malformed manifest — a
347/// parse failure or an unrecognised `[paths]` key — as an error instead of
348/// silently falling back to the conventional layout (the previous
349/// `read_project_paths` total form's behaviour, R3.8 — deleted in favour of
350/// this at all 18 of its callers, #1113).
351///
352/// R3.9 (#1113): `[paths] include` is no longer capped at one or two trees —
353/// [`crate::roots::Roots::trees`] walks every entry, so this no longer rejects
354/// a longer list.
355pub 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
364/// Like [`try_read_project_paths`], but honours `overlay` for `bynk.toml`
365/// itself, the same way `discovery::read_source` does for every other file.
366pub 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/// #1890: the project's `[workers]` table, settings for the Cloudflare
405/// Workers a `workers`-target build emits.
406#[derive(Debug, Clone, Default, PartialEq, Eq)]
407pub struct WorkersConfig {
408    /// Extra Cloudflare compatibility flags, in manifest order, for every
409    /// Worker's `wrangler.toml`, after the compiler's default set. The names
410    /// pass through unvalidated, since Cloudflare adds flags faster than a
411    /// list here could follow. Duplicates are kept here: the emitter drops
412    /// them and warns.
413    pub compatibility_flags: Vec<String>,
414}
415
416/// Read `bynk.toml`'s `[workers]` table, honouring `overlay` for `bynk.toml`
417/// the way [`try_read_project_paths_with`] does. No manifest, or no
418/// `[workers]` table, is the empty [`WorkersConfig`]. A manifest that doesn't
419/// parse is [`ProjectPathsError::Malformed`], and a `compatibility_flags`
420/// that isn't a list of strings is [`ProjectPathsError::NotAStringList`]. The
421/// shape is checked here as well as in [`check_manifest`], because some
422/// callers (the e2e harness) read the table without the whole-manifest check.
423pub 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
440/// [`try_read_workers_config_with`], reading `bynk.toml` from disk.
441pub 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/// #2138 (end-to-end testing track §3.8): a deployment `bynk test --against
451/// <name>` may run the `system` suites against, from `[test.targets.<name>]`.
452#[derive(Debug, Clone, Default, PartialEq, Eq)]
453pub struct TestTarget {
454    /// Each Worker's base URL, keyed by worker-directory name
455    /// (`url.shop-orders = "https://…"`).
456    pub urls: BTreeMap<String, String>,
457    /// The secrets the deployed Workers verify with, by name. Their values
458    /// come only from the runner's environment, never from `bynk.toml`.
459    pub secrets: Vec<String>,
460    /// `production = true` marks a deployment `--against` refuses.
461    pub production: bool,
462    /// The non-secret values the target was deployed with
463    /// (`vars = { PAYMENTS_PROCESSOR = "fake" }`).
464    pub vars: BTreeMap<String, String>,
465}
466
467/// #2138: read `bynk.toml`'s `[test.targets]`, by name. No manifest, or no
468/// `[test]` table, is empty. A malformed entry is
469/// [`ProjectPathsError::BadTestTarget`].
470pub 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
485/// The `[test]` table's `targets`, each checked for shape: `url` a table of
486/// strings (at least one), `secrets` a list of strings, `production` a
487/// boolean, `vars` a table of strings, and nothing else.
488fn 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
554/// #1890: the `bynk.project.duplicate_compatibility_flag` warning for one
555/// flag `[workers] compatibility_flags` repeats, against `manifest` (the
556/// project's `bynk.toml`). `default` says the repeat is of a flag every
557/// Worker already has, rather than of an earlier entry in the list. Built
558/// here, with the manifest's other diagnostics, rather than in `bynk-emit`,
559/// which only finds the duplicate while merging the list (R3.5 keeps
560/// diagnostics out of emission).
561pub 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
584/// The `[workers]` table's typed content. Only the value shapes are checked
585/// here; an unknown key is [`check_manifest_str`]'s to refuse.
586fn 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/// The project's `[unstable]` table (standard-library S3, #1963): the
605/// experimental first-party items it opts into using.
606#[derive(Debug, Clone, Default, PartialEq, Eq)]
607pub struct UnstableConfig {
608    /// Item paths (`bynk.cloudflare.D1`, `bynk.cloudflare.Kv.list`), in
609    /// manifest order. An entry covers itself and everything under it. Whether
610    /// each one names something experimental is the checker's to say, since it
611    /// holds the standard library's registry.
612    pub allow: Vec<String>,
613}
614
615/// Read `bynk.toml`'s `[unstable]` table, honouring `overlay` the way
616/// [`try_read_workers_config_with`] does. No manifest, or no `[unstable]`
617/// table, is the empty [`UnstableConfig`]; an `allow` that isn't a list of
618/// strings is [`ProjectPathsError::NotAStringList`].
619pub 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
636/// [`try_read_unstable_config_with`], reading `bynk.toml` from disk.
637pub 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
646/// The project's `[unstable]` table as both the build and the editor see it
647/// (#1963 Decision E): the overlay's `bynk.toml` when it holds one (the LSP puts
648/// the open buffer there), otherwise the one on disk (a CLI build's overlay holds
649/// only `.bynk` sources). A missing or malformed manifest reads as empty; its
650/// shape errors are `check_manifest`'s to report.
651pub 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
664/// The `[unstable]` table's typed content. Only the value shape is checked
665/// here; an unknown key is [`check_manifest_str`]'s to refuse.
666fn 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
697/// v0.8: directory name of a Worker for a given context, with dots replaced
698/// by dashes (`commerce.payment` → `commerce-payment`).
699pub fn worker_dir_name(context: &str) -> String {
700    context.replace('.', "-")
701}
702
703/// v0.8: project-relative synthetic source path of the workers-mode
704/// handlers file for a given context. Used so the emitter's relative-import
705/// machinery resolves correctly against the workers layout.
706pub fn worker_handlers_source_path(context: &str) -> PathBuf {
707    PathBuf::from(format!(
708        "workers/{}/handlers.bynk",
709        worker_dir_name(context)
710    ))
711}
712
713/// v0.8: project-relative output path of the workers-mode handlers file.
714pub fn worker_handlers_output_path(context: &str) -> PathBuf {
715    PathBuf::from(format!("workers/{}/handlers.ts", worker_dir_name(context)))
716}
717
718/// #1820: project-relative synthetic source path of one file's module in a
719/// context split across files, on Workers. Each file is its own module under
720/// `workers/<dir>/handlers/`, and `handlers.ts` re-exports them all, so the
721/// Worker's entry point and composition root, and every consumer, still
722/// import the context from `handlers.ts`. A file keeps its stem
723/// (`shop/orders/place.bynk` → `handlers/place.bynk`). Distinct files map to
724/// distinct modules because every file of a split unit sits directly in its
725/// `<name>/` directory: a `<name>.bynk` beside it is rejected
726/// (`bynk.project.file_and_directory`), as is a file in any other directory
727/// (`bynk.project.inconsistent_commons_name`). The emitted module is
728/// [`ts_output_path`] of this path.
729pub 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
737/// The src-stripped stem components of a path (`learner/uln.bynk` → `["learner",
738/// "uln"]`), dropping the extension and any non-`Normal` components.
739fn 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
750/// v0.9.1: shared between source-unit and test-unit path validation. The
751/// caller decides which root to strip from the file path before calling.
752///
753/// A file belongs to `qualified_name` when it is either the single file
754/// `<name>.bynk` (`single_file_match`: stem parts == name parts) or one file of
755/// the directory layout `<name>/*.bynk` (`multi_file_match`: parent-dir parts ==
756/// name parts). These two branches are the single source of truth the v0.132
757/// barrel trigger reads via [`is_multi_file_layout`].
758pub 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
769/// True when `stem_parts` is one file of the `<name>/*.bynk` directory layout —
770/// the file's parent-directory parts equal the name parts.
771fn 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
783/// v0.132: does `rel_path` (src-stripped) place `qualified_name` under a
784/// directory of that name — the `multi_file_match` branch of
785/// [`unit_path_matches`]?
786///
787/// This is the layout where production emits `out/<name>/*.ts` per file and no
788/// aggregate `out/<name>.ts`, so the test path's `import * as ns from
789/// "./<name>.js"` dangles and needs an aggregating barrel. A single-file commons
790/// (`<name>.bynk`) already owns `out/<name>.ts` and returns false, so a barrel
791/// keyed on this predicate can never collide with it.
792pub 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
797/// #302: the qualified name a file moved from `old_rel` to `new_rel` should now
798/// declare, preserving whichever [`unit_path_matches`] arrangement `old_rel`
799/// used to satisfy against `old_name` — the dotted stem for a single-file
800/// unit, or the dotted parent-directory for one file of a multi-file unit.
801/// Returns `None` if `old_rel`/`old_name` don't actually satisfy either
802/// arrangement (a pre-existing inconsistency the caller should not guess at).
803///
804/// `old_name` is matched as a **suffix** of `old_rel`'s stem/parent, not the
805/// whole thing: the LSP's caller passes project-relative paths, which (unlike
806/// the `source_path` `unit_path_matches` itself is checked against) still
807/// carry a leading `include`-root segment (e.g. `src/`) that the qualified
808/// name never mentions. Whatever prefix length that suffix match implies for
809/// `old_rel` is applied unchanged to `new_rel` — correct as long as the file
810/// stays under the same `include` root, which a rename/move normally does.
811pub 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    // -- is_unpinned_range ----------------------------------------------------
850    #[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("  *  ")); // trimmed before the checks
859        assert!(is_unpinned_range("workspace:*")); // no ascii digit
860        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    // -- read_project_paths_with (#57, in-memory test seam) -------------------
873    #[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        // No overlay entry and no real file at this (nonexistent) root — same
889        // fallback a missing on-disk `bynk.toml` gives.
890        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    /// R3.9 (#1113): three or more `[paths] include` entries all round-trip —
898    /// `try_read_project_paths_with` no longer caps the list at two.
899    #[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    // -- render_package_json --------------------------------------------------
919    #[test]
920    fn render_package_json_declares_es_modules() {
921        // #2148: with no dependencies the file is just the module-format marker.
922        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        // BTreeMap ordering keeps the file byte-stable across builds.
940        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    /// A package name and version range reach here from adapter declarations in
948    /// Bynk source, so they are arbitrary text. This module used to escape only
949    /// `"` and `\`, which let a control character through as a literal — and a
950    /// literal control character inside a JSON string is a parse error, so the
951    /// emitted `package.json` was invalid rather than merely odd.
952    #[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        // No raw control character survives into the rendered document (the
960        // pretty-printer's own newlines are all that remain).
961        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    // -- normalize_rel --------------------------------------------------------
976    #[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        // RootDir / Prefix components are dropped.
991        assert_eq!(normalize_rel(Path::new("/a/b")), PathBuf::from("a/b"));
992        // A leading `..` pops an empty stack (a no-op), so it vanishes.
993        assert_eq!(normalize_rel(Path::new("../a")), PathBuf::from("a"));
994    }
995
996    // -- commons_dir_for / ts_output_path -------------------------------------
997    #[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    // -- worker path helpers --------------------------------------------------
1017    #[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    // -- unit_path_matches ----------------------------------------------------
1040    #[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        // `a/b/c/<any>.bynk` declaring `a.b.c` (the directory is the unit).
1049        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    // -- is_multi_file_layout (v0.132 barrel trigger) -------------------------
1060    #[test]
1061    fn is_multi_file_layout_true_only_for_directory_layout() {
1062        // Directory layout: `<name>/*.bynk` — the branch with no `out/<name>.ts`.
1063        assert!(is_multi_file_layout(Path::new("thing/a.bynk"), "thing"));
1064        assert!(is_multi_file_layout(Path::new("thing/b.bynk"), "thing"));
1065        // Dotted commons split across `src/a/b/*.bynk`.
1066        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        // Single file `<name>.bynk` already owns `out/<name>.ts` — no barrel.
1072        assert!(!is_multi_file_layout(Path::new("thing.bynk"), "thing"));
1073        // Dotted single file `a/b.bynk` for `a.b` — the file *is* `out/a/b.ts`.
1074        assert!(!is_multi_file_layout(Path::new("a/b.bynk"), "a.b"));
1075        // Wrong directory — not this unit's file.
1076        assert!(!is_multi_file_layout(Path::new("other/a.bynk"), "thing"));
1077    }
1078
1079    // -- renamed_unit_name (#302) ----------------------------------------------
1080    #[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        // Renaming one member file within the same directory doesn't change
1099        // the unit's name — the qualified name is the directory, not the
1100        // filename.
1101        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        // The LSP passes project-relative paths (ADR 0198), which still carry
1134        // a split project's `src`/`tests` root segment — `unit_path_matches`
1135        // itself is only ever checked against the root-stripped `source_path`.
1136        // `old_name` must match as a *suffix*, and the same leading-segment
1137        // count is preserved onto `new_rel`.
1138        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        // Multi-file arrangement under the same prefix.
1147        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    /// #1665: every table the docs describe, with every key, is accepted.
1169    #[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    /// #1770 review: a known table's name holding a plain value is refused,
1217    /// not read as an absent table.
1218    #[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        // An inline table is a table.
1224        check_manifest_str("paths = { include = [\"src\"] }\n").expect("accepted");
1225    }
1226
1227    /// #1770 review: a planned table keeps its issue whatever shape it takes.
1228    #[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    /// `[fmt]` keys are `bynk-fmt`'s to check, not this one's.
1235    #[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    /// #1890: `[workers] compatibility_flags` must be a list of strings; the
1241    /// names themselves pass through.
1242    #[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    /// #1963: `[unstable] allow` must be a list of strings; whether each names
1258    /// something experimental is the checker's to say.
1259    #[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    /// #2096: the shapes `bynk.toml`'s JSON Schema accepts for the hand-read
1338    /// tables are the shapes their readers accept, and the shapes it flags for
1339    /// `compatibility_flags`/`allow` are refused. The schema is written apart
1340    /// from these readers (`manifest_schema`), so this pins the two together.
1341    #[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        // `[paths] include`: a bare string and a list both read as the one root.
1348        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        // `[workers] compatibility_flags` and `[unstable] allow`: lists of strings.
1359        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        // A bare string is a list only for `[paths]`: the schema flags it for
1377        // these two, and so do the readers.
1378        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        // #2138: `[test.targets.<name>]`.
1392        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}