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    /// A key that must be a boolean holds something else
145    /// (`[workers] workers_dev = "no"`).
146    NotABool {
147        table: &'static str,
148        key: &'static str,
149    },
150    /// #2138: a `[test.targets.<name>]` entry that is not well formed.
151    BadTestTarget { target: String, reason: String },
152    /// #2207 (`project_and_package`): both `[project]` and `[package]`, so the
153    /// project would have two names.
154    ProjectAndPackage,
155    /// #2207: a `[package]` key whose value isn't well formed (decision D).
156    BadPackage { key: &'static str, reason: String },
157    /// #2207: a `[dependencies]`/`[dev-dependencies]` entry that isn't well
158    /// formed (decisions A and G).
159    BadDependency {
160        table: &'static str,
161        alias: String,
162        reason: String,
163    },
164    /// #2207: a key a map table's entry doesn't have.
165    UnknownEntryKey {
166        table: &'static str,
167        entry: String,
168        key: String,
169    },
170    /// #2207: a `[registries.<name>]` entry that isn't well formed (decision E).
171    BadRegistry { name: String, reason: String },
172    /// #2207 (decision E): a credential written into `[registries.<name>]`.
173    RegistryCredential { name: String },
174    /// #2207: a `[workspace]` that isn't well formed, or whose members can't
175    /// be resolved (decision C).
176    BadWorkspace { reason: String },
177    /// #2207 (decision C): a table other than `[workspace]`/`[registries]` in
178    /// a workspace root's manifest.
179    WorkspaceRootTable(String),
180    /// #2207 (decision C): a workspace root holding sources of its own.
181    WorkspaceRootSources,
182    /// #2207 (decision C): a project command run at a workspace root, which is
183    /// not a project.
184    WorkspaceRoot,
185    /// #2207 (decision C): a workspace member declaring `[registries]`, which
186    /// only the root may.
187    MemberRegistries { workspace: PathBuf },
188    /// #2207 (decision C): a workspace member that is itself a workspace.
189    NestedWorkspace { workspace: PathBuf },
190}
191
192/// #1665: the tables `bynk.toml` may hold, each with the keys it accepts.
193/// `[fmt]`'s keys are `bynk-fmt`'s to check (`FmtConfig`, `deny_unknown_fields`),
194/// so its list here is empty and unchecked.
195pub const MANIFEST_TABLES: &[(&str, &[&str])] = &[
196    ("project", &["name", "version"]),
197    (
198        "package",
199        &[
200            "name",
201            "version",
202            "description",
203            "license",
204            "repository",
205            "exports",
206        ],
207    ),
208    ("paths", &["include", "exclude"]),
209    ("fmt", &[]),
210    ("lsp", &["diagnostics_mode", "diagnostics_debounce_ms"]),
211    ("workers", &["compatibility_flags", "workers_dev"]),
212    ("unstable", &["allow"]),
213    ("test", &["targets"]),
214    ("workspace", &["members", "exclude"]),
215];
216
217/// #2207 (packaging slice 1): the **map tables**, each with the keys one of
218/// its entries accepts. A map table's own keys are names the user chooses
219/// (dependency aliases, registry names), checked against decision G's grammar;
220/// each entry is a table of fixed keys, refused with did-you-mean like
221/// [`MANIFEST_TABLES`]' keys are.
222pub const MANIFEST_MAP_TABLES: &[(&str, &[&str])] = &[
223    ("dependencies", crate::manifest::DEPENDENCY_KEYS),
224    ("dev-dependencies", crate::manifest::DEPENDENCY_KEYS),
225    ("registries", crate::manifest::REGISTRY_KEYS),
226];
227
228/// The tables a workspace root's `bynk.toml` may hold (decision C).
229const WORKSPACE_ROOT_TABLES: &[&str] = &["workspace", "registries"];
230
231/// Tables a user might write for a feature that is designed but not built,
232/// with the issue that tracks it.
233const PLANNED_TABLES: &[(&str, &str)] = &[("deploy", "#551")];
234
235/// Every table name `bynk.toml` accepts, fixed-key and map tables alike, in
236/// documentation order.
237fn all_tables() -> impl Iterator<Item = &'static str> {
238    MANIFEST_TABLES
239        .iter()
240        .chain(MANIFEST_MAP_TABLES)
241        .map(|(t, _)| *t)
242}
243
244/// The manifest's tables as
245/// `` `[project]`, `[paths]`, `[fmt]`, `[lsp]` <conj> `[workers]` ``, from
246/// [`MANIFEST_TABLES`], so a message can't fall behind it.
247fn table_list(conj: &str) -> String {
248    let names: Vec<String> = all_tables().map(|t| format!("`[{t}]`")).collect();
249    match names.split_last() {
250        Some((last, rest)) if !rest.is_empty() => format!("{} {conj} {last}", rest.join(", ")),
251        _ => names.join(""),
252    }
253}
254
255/// The closest of `candidates` to `name`, if any is within two edits.
256pub(crate) fn did_you_mean<'a>(
257    name: &str,
258    candidates: impl IntoIterator<Item = &'a str>,
259) -> Option<&'a str> {
260    candidates
261        .into_iter()
262        .map(|c| (edit_distance(name, c), c))
263        .filter(|(d, _)| *d <= 2)
264        .min_by_key(|(d, _)| *d)
265        .map(|(_, c)| c)
266}
267
268/// Levenshtein distance over chars.
269fn edit_distance(a: &str, b: &str) -> usize {
270    let b: Vec<char> = b.chars().collect();
271    let mut row: Vec<usize> = (0..=b.len()).collect();
272    for (i, ca) in a.chars().enumerate() {
273        let mut prev = row[0];
274        row[0] = i + 1;
275        for (j, cb) in b.iter().enumerate() {
276            let cur = row[j + 1];
277            row[j + 1] = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(cur + 1);
278            prev = cur;
279        }
280    }
281    row[b.len()]
282}
283
284impl std::fmt::Display for ProjectPathsError {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        match self {
287            ProjectPathsError::Malformed => write!(f, "`bynk.toml` is not valid TOML"),
288            ProjectPathsError::UnknownKey(k) => {
289                write!(
290                    f,
291                    "`[paths]` has no key named `{k}` — did you mean `include` or `exclude`?"
292                )
293            }
294            ProjectPathsError::UnknownTable(name) => {
295                write!(f, "`bynk.toml` has no table named `[{name}]` — ")?;
296                if let Some((_, issue)) = PLANNED_TABLES.iter().find(|(t, _)| *t == name) {
297                    write!(f, "it is planned but not yet supported ({issue})")
298                } else if let Some(t) = did_you_mean(name, all_tables()) {
299                    write!(f, "did you mean `[{t}]`?")
300                } else {
301                    write!(f, "the tables are {}", table_list("and"))
302                }
303            }
304            ProjectPathsError::TopLevelKey(key) => {
305                // #1770 review: a table's own name written as a plain value
306                // (`paths = "src"`).
307                if all_tables().any(|t| t == key) {
308                    return write!(
309                        f,
310                        "`{key}` in `bynk.toml` must be a table — write it as `[{key}]`"
311                    );
312                }
313                write!(f, "`bynk.toml` has a key `{key}` outside any table — ")?;
314                match MANIFEST_TABLES
315                    .iter()
316                    .find(|(_, keys)| keys.contains(&key.as_str()))
317                {
318                    Some((table, _)) => write!(f, "did you mean it under `[{table}]`?"),
319                    None => write!(f, "keys belong in {}", table_list("or")),
320                }
321            }
322            ProjectPathsError::UnknownTableKey { table, key } => {
323                let keys = MANIFEST_TABLES
324                    .iter()
325                    .find(|(t, _)| t == table)
326                    .map(|(_, k)| *k)
327                    .unwrap_or_default();
328                write!(f, "`[{table}]` has no key named `{key}` — ")?;
329                match did_you_mean(key, keys.iter().copied()) {
330                    Some(k) => write!(f, "did you mean `{k}`?"),
331                    None => {
332                        let list: Vec<String> = keys.iter().map(|k| format!("`{k}`")).collect();
333                        write!(f, "its keys are {}", list.join(", "))
334                    }
335                }
336            }
337            ProjectPathsError::NotAStringList { table, key } => write!(
338                f,
339                "`[{table}]` `{key}` must be a list of strings, like `{key} = [\"nodejs_compat\"]`"
340            ),
341            ProjectPathsError::NotABool { table, key } => write!(
342                f,
343                "`[{table}]` `{key}` must be `true` or `false`, like `{key} = false`"
344            ),
345            ProjectPathsError::BadTestTarget { target, reason } => {
346                write!(f, "`[test.targets.{target}]` {reason}")
347            }
348            ProjectPathsError::ProjectAndPackage => write!(
349                f,
350                "`bynk.toml` has both `[project]` and `[package]` — a package is named by `[package] name`, so remove `[project]`"
351            ),
352            ProjectPathsError::BadPackage { key, reason } => {
353                write!(f, "`[package]` `{key}` {reason}")
354            }
355            ProjectPathsError::BadDependency {
356                table,
357                alias,
358                reason,
359            } => write!(f, "`[{table}]` `{alias}` {reason}"),
360            ProjectPathsError::UnknownEntryKey { table, entry, key } => {
361                let keys = MANIFEST_MAP_TABLES
362                    .iter()
363                    .find(|(t, _)| t == table)
364                    .map(|(_, k)| *k)
365                    .unwrap_or_default();
366                let place = if *table == "registries" {
367                    format!("`[registries.{entry}]`")
368                } else {
369                    format!("`[{table}]` `{entry}`")
370                };
371                write!(f, "{place} has no key named `{key}` — ")?;
372                match did_you_mean(key, keys.iter().copied()) {
373                    Some(k) => write!(f, "did you mean `{k}`?"),
374                    None => {
375                        let list: Vec<String> = keys.iter().map(|k| format!("`{k}`")).collect();
376                        write!(f, "its keys are {}", list.join(", "))
377                    }
378                }
379            }
380            ProjectPathsError::BadRegistry { name, reason } => {
381                write!(f, "`[registries.{name}]` {reason}")
382            }
383            ProjectPathsError::RegistryCredential { name } => write!(
384                f,
385                "`[registries.{name}]` has a `token` — credentials belong in `{}` or the user credentials file, never in `bynk.toml`",
386                crate::manifest::registry_token_var(name)
387            ),
388            ProjectPathsError::BadWorkspace { reason } => write!(f, "`[workspace]` {reason}"),
389            ProjectPathsError::WorkspaceRootTable(table) => write!(
390                f,
391                "a workspace root's `bynk.toml` holds only `[workspace]` and `[registries]` — `[{table}]` belongs in a member's `bynk.toml`"
392            ),
393            ProjectPathsError::WorkspaceRootSources => write!(
394                f,
395                "a workspace root holds no sources of its own — move them into a member"
396            ),
397            ProjectPathsError::WorkspaceRoot => write!(
398                f,
399                "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)"
400            ),
401            ProjectPathsError::MemberRegistries { workspace } => write!(
402                f,
403                "`[registries]` belongs in the workspace root's `bynk.toml` (`{}`), not a member's",
404                workspace.join("bynk.toml").display()
405            ),
406            ProjectPathsError::NestedWorkspace { workspace } => write!(
407                f,
408                "this project is a member of the workspace at `{}`, so it can't declare a `[workspace]` of its own",
409                workspace.display()
410            ),
411        }
412    }
413}
414
415/// #1665: check `bynk.toml`'s table set and the keys of `[project]` and
416/// `[lsp]` ([`MANIFEST_TABLES`]). Every other reader takes only its own table
417/// and ignores the rest, so an unknown table (`[dependencies]`, a typo'd
418/// `[pahts]`) used to build cleanly with none of its intended behaviour.
419///
420/// Kept apart from [`try_read_project_paths_with`] on purpose: the CLIs call
421/// both and refuse an unknown table, while the language server reads `[paths]`
422/// alone and keeps serving, so an extra table can't cost an editor its
423/// `include` layout. `Ok` when there is no `bynk.toml`. A manifest that doesn't
424/// parse is [`ProjectPathsError::Malformed`].
425pub fn check_manifest(
426    project_root: &Path,
427    overlay: &HashMap<PathBuf, String>,
428) -> Result<(), ProjectPathsError> {
429    let toml_path = project_root.join("bynk.toml");
430    let Ok(content) = read_source(&toml_path, overlay) else {
431        return Ok(());
432    };
433    check_manifest_str(&content)?;
434    // #2207 (decision C): a workspace member may not declare `[registries]`
435    // or a `[workspace]` of its own. Finding the workspace reads the disk, so
436    // it happens only when one of those tables is there to refuse.
437    let doc = content
438        .parse::<toml::Table>()
439        .map_err(|_| ProjectPathsError::Malformed)?;
440    if (doc.contains_key("registries") || doc.contains_key("workspace"))
441        && let Some(workspace) = crate::workspace::find_parent_workspace(project_root)
442    {
443        return Err(if doc.contains_key("workspace") {
444            ProjectPathsError::NestedWorkspace { workspace }
445        } else {
446            ProjectPathsError::MemberRegistries { workspace }
447        });
448    }
449    Ok(())
450}
451
452/// [`check_manifest`] over a manifest's text: every check that needs no
453/// other file.
454pub fn check_manifest_str(content: &str) -> Result<(), ProjectPathsError> {
455    let doc = content
456        .parse::<toml::Table>()
457        .map_err(|_| ProjectPathsError::Malformed)?;
458    let mut declared: Vec<&'static str> = Vec::new();
459    for (name, value) in &doc {
460        // #1770 review: a planned table names its issue whatever shape it is
461        // written in (`[[deploy]]`, `deploy = [...]`).
462        if PLANNED_TABLES.iter().any(|(t, _)| t == name) {
463            return Err(ProjectPathsError::UnknownTable(name.clone()));
464        }
465        let known = MANIFEST_TABLES
466            .iter()
467            .map(|(t, keys)| (*t, *keys, false))
468            .chain(
469                MANIFEST_MAP_TABLES
470                    .iter()
471                    .map(|(t, keys)| (*t, *keys, true)),
472            )
473            .find(|(t, _, _)| t == name);
474        let Some((table, keys, is_map)) = known else {
475            return Err(if value.is_table() {
476                ProjectPathsError::UnknownTable(name.clone())
477            } else {
478                ProjectPathsError::TopLevelKey(name.clone())
479            });
480        };
481        // #1770 review: a known table's name holding a plain value
482        // (`paths = "src"`, `dependencies = ["acme"]`) would otherwise read as
483        // an absent table.
484        let Some(entries) = value.as_table() else {
485            return Err(ProjectPathsError::TopLevelKey(name.clone()));
486        };
487        declared.push(table);
488        // #2207: a map table's keys are names the user chooses; its readers
489        // check each name's grammar, then each entry's keys.
490        if is_map {
491            match table {
492                "dependencies" | "dev-dependencies" => {
493                    crate::manifest::dependencies_from(table, entries)?;
494                }
495                _ => {
496                    crate::manifest::registries_from(entries)?;
497                }
498            }
499            continue;
500        }
501        if keys.is_empty() {
502            continue;
503        }
504        for key in entries.keys() {
505            if !keys.contains(&key.as_str()) {
506                return Err(match table {
507                    "paths" => ProjectPathsError::UnknownKey(key.clone()),
508                    _ => ProjectPathsError::UnknownTableKey {
509                        table,
510                        key: key.clone(),
511                    },
512                });
513            }
514        }
515        match table {
516            "workers" => drop(workers_config_from(entries)?),
517            "unstable" => drop(unstable_config_from(entries)?),
518            "test" => drop(test_targets_from(entries)?),
519            "package" => drop(crate::manifest::package_config_from(entries)?),
520            "workspace" => drop(crate::manifest::workspace_config_from(entries)?),
521            _ => {}
522        }
523    }
524    // #2207: the rules across tables.
525    if declared.contains(&"project") && declared.contains(&"package") {
526        return Err(ProjectPathsError::ProjectAndPackage);
527    }
528    if declared.contains(&"workspace")
529        && let Some(other) = declared.iter().find(|t| !WORKSPACE_ROOT_TABLES.contains(t))
530    {
531        return Err(ProjectPathsError::WorkspaceRootTable((*other).to_string()));
532    }
533    Ok(())
534}
535
536/// Read `bynk.toml`'s `[paths]` section, surfacing a malformed manifest — a
537/// parse failure or an unrecognised `[paths]` key — as an error instead of
538/// silently falling back to the conventional layout (the previous
539/// `read_project_paths` total form's behaviour, R3.8 — deleted in favour of
540/// this at all 18 of its callers, #1113).
541///
542/// R3.9 (#1113): `[paths] include` is no longer capped at one or two trees —
543/// [`crate::roots::Roots::trees`] walks every entry, so this no longer rejects
544/// a longer list.
545pub fn try_read_project_paths(project_root: &Path) -> Result<ProjectPaths, ProjectPathsError> {
546    let toml_path = project_root.join("bynk.toml");
547    let overlay = match fs::read_to_string(&toml_path) {
548        Ok(text) => HashMap::from([(toml_path, text)]),
549        Err(_) => HashMap::new(),
550    };
551    try_read_project_paths_with(project_root, &overlay)
552}
553
554/// Like [`try_read_project_paths`], but honours `overlay` for `bynk.toml`
555/// itself, the same way `discovery::read_source` does for every other file.
556pub fn try_read_project_paths_with(
557    project_root: &Path,
558    overlay: &HashMap<PathBuf, String>,
559) -> Result<ProjectPaths, ProjectPathsError> {
560    let toml_path = project_root.join("bynk.toml");
561    let Ok(content) = read_source(&toml_path, overlay) else {
562        return Ok(ProjectPaths::conventional(project_root));
563    };
564    let Ok(doc) = content.parse::<toml::Table>() else {
565        return Err(ProjectPathsError::Malformed);
566    };
567    let paths = doc.get("paths").and_then(|v| v.as_table());
568    if let Some(t) = paths {
569        for k in t.keys() {
570            if k != "include" && k != "exclude" {
571                return Err(ProjectPathsError::UnknownKey(k.clone()));
572            }
573        }
574    }
575    let list = |key: &str| -> Vec<PathBuf> {
576        match paths.and_then(|t| t.get(key)) {
577            Some(toml::Value::Array(items)) => items
578                .iter()
579                .filter_map(|v| v.as_str())
580                .map(PathBuf::from)
581                .collect(),
582            Some(toml::Value::String(s)) => vec![PathBuf::from(s)],
583            _ => Vec::new(),
584        }
585    };
586    let mut include = list("include");
587    let exclude = list("exclude");
588    if include.is_empty() {
589        include = ProjectPaths::conventional(project_root).include;
590    }
591    Ok(ProjectPaths { include, exclude })
592}
593
594/// #1890: the project's `[workers]` table, settings for the Cloudflare
595/// Workers a `workers`-target build emits.
596#[derive(Debug, Clone, Default, PartialEq, Eq)]
597pub struct WorkersConfig {
598    /// Extra Cloudflare compatibility flags, in manifest order, for every
599    /// Worker's `wrangler.toml`, after the compiler's default set. The names
600    /// pass through unvalidated, since Cloudflare adds flags faster than a
601    /// list here could follow. Duplicates are kept here: the emitter drops
602    /// them and warns.
603    pub compatibility_flags: Vec<String>,
604    /// `workers_dev` for every Worker's `wrangler.toml`: whether the Worker is
605    /// served on its `*.workers.dev` URL. `None` leaves the key out, so
606    /// Cloudflare's default (on) applies.
607    pub workers_dev: Option<bool>,
608}
609
610/// Read `bynk.toml`'s `[workers]` table, honouring `overlay` for `bynk.toml`
611/// the way [`try_read_project_paths_with`] does. No manifest, or no
612/// `[workers]` table, is the empty [`WorkersConfig`]. A manifest that doesn't
613/// parse is [`ProjectPathsError::Malformed`], and a `compatibility_flags`
614/// that isn't a list of strings is [`ProjectPathsError::NotAStringList`]. The
615/// shape is checked here as well as in [`check_manifest`], because some
616/// callers (the e2e harness) read the table without the whole-manifest check.
617pub fn try_read_workers_config_with(
618    project_root: &Path,
619    overlay: &HashMap<PathBuf, String>,
620) -> Result<WorkersConfig, ProjectPathsError> {
621    let toml_path = project_root.join("bynk.toml");
622    let Ok(content) = read_source(&toml_path, overlay) else {
623        return Ok(WorkersConfig::default());
624    };
625    let doc = content
626        .parse::<toml::Table>()
627        .map_err(|_| ProjectPathsError::Malformed)?;
628    match doc.get("workers").and_then(|v| v.as_table()) {
629        Some(t) => workers_config_from(t),
630        None => Ok(WorkersConfig::default()),
631    }
632}
633
634/// [`try_read_workers_config_with`], reading `bynk.toml` from disk.
635pub fn try_read_workers_config(project_root: &Path) -> Result<WorkersConfig, ProjectPathsError> {
636    let toml_path = project_root.join("bynk.toml");
637    let overlay = match fs::read_to_string(&toml_path) {
638        Ok(text) => HashMap::from([(toml_path, text)]),
639        Err(_) => HashMap::new(),
640    };
641    try_read_workers_config_with(project_root, &overlay)
642}
643
644/// #2138 (end-to-end testing track §3.8): a deployment `bynk test --against
645/// <name>` may run the `system` suites against, from `[test.targets.<name>]`.
646#[derive(Debug, Clone, Default, PartialEq, Eq)]
647pub struct TestTarget {
648    /// Each Worker's base URL, keyed by worker-directory name
649    /// (`url.shop-orders = "https://…"`).
650    pub urls: BTreeMap<String, String>,
651    /// The secrets the deployed Workers verify with, by name. Their values
652    /// come only from the runner's environment, never from `bynk.toml`.
653    pub secrets: Vec<String>,
654    /// `production = true` marks a deployment `--against` refuses.
655    pub production: bool,
656    /// The non-secret values the target was deployed with
657    /// (`vars = { PAYMENTS_PROCESSOR = "fake" }`).
658    pub vars: BTreeMap<String, String>,
659}
660
661/// #2138: read `bynk.toml`'s `[test.targets]`, by name. No manifest, or no
662/// `[test]` table, is empty. A malformed entry is
663/// [`ProjectPathsError::BadTestTarget`].
664pub fn try_read_test_targets(
665    project_root: &Path,
666) -> Result<BTreeMap<String, TestTarget>, ProjectPathsError> {
667    let Ok(content) = fs::read_to_string(project_root.join("bynk.toml")) else {
668        return Ok(BTreeMap::new());
669    };
670    let doc = content
671        .parse::<toml::Table>()
672        .map_err(|_| ProjectPathsError::Malformed)?;
673    match doc.get("test").and_then(|v| v.as_table()) {
674        Some(t) => test_targets_from(t),
675        None => Ok(BTreeMap::new()),
676    }
677}
678
679/// The `[test]` table's `targets`, each checked for shape: `url` a table of
680/// strings (at least one), `secrets` a list of strings, `production` a
681/// boolean, `vars` a table of strings, and nothing else.
682fn test_targets_from(
683    table: &toml::Table,
684) -> Result<BTreeMap<String, TestTarget>, ProjectPathsError> {
685    let mut out = BTreeMap::new();
686    let Some(targets) = table.get("targets") else {
687        return Ok(out);
688    };
689    let Some(targets) = targets.as_table() else {
690        return Err(ProjectPathsError::BadTestTarget {
691            target: "…".to_string(),
692            reason: "must be a table of named targets, like `[test.targets.preview]`".to_string(),
693        });
694    };
695    for (name, entry) in targets {
696        let bad = |reason: &str| ProjectPathsError::BadTestTarget {
697            target: name.clone(),
698            reason: reason.to_string(),
699        };
700        let Some(entry) = entry.as_table() else {
701            return Err(bad("must be a table"));
702        };
703        let strings =
704            |v: &toml::Value, what: &str| -> Result<BTreeMap<String, String>, ProjectPathsError> {
705                v.as_table()
706                    .ok_or_else(|| bad(&format!("`{what}` must be a table of strings")))?
707                    .iter()
708                    .map(|(k, v)| {
709                        v.as_str()
710                            .map(|s| (k.clone(), s.to_string()))
711                            .ok_or_else(|| bad(&format!("`{what}.{k}` must be a string")))
712                    })
713                    .collect()
714            };
715        let mut target = TestTarget::default();
716        for (key, value) in entry {
717            match key.as_str() {
718                "url" => target.urls = strings(value, "url")?,
719                "vars" => target.vars = strings(value, "vars")?,
720                "secrets" => {
721                    target.secrets = value
722                        .as_array()
723                        .and_then(|a| a.iter().map(|v| v.as_str().map(str::to_string)).collect())
724                        .ok_or_else(|| bad("`secrets` must be a list of names"))?;
725                }
726                "production" => {
727                    target.production = value
728                        .as_bool()
729                        .ok_or_else(|| bad("`production` must be `true` or `false`"))?;
730                }
731                other => {
732                    return Err(bad(&format!(
733                        "has no key named `{other}` — its keys are `url`, `secrets`, `vars` and `production`"
734                    )));
735                }
736            }
737        }
738        if target.urls.is_empty() {
739            return Err(bad(
740                "names no Worker URL — add `url.<worker> = \"https://…\"`",
741            ));
742        }
743        out.insert(name.clone(), target);
744    }
745    Ok(out)
746}
747
748/// #1890: the `bynk.project.duplicate_compatibility_flag` warning for one
749/// flag `[workers] compatibility_flags` repeats, against `manifest` (the
750/// project's `bynk.toml`). `default` says the repeat is of a flag every
751/// Worker already has, rather than of an earlier entry in the list. Built
752/// here, with the manifest's other diagnostics, rather than in `bynk-emit`,
753/// which only finds the duplicate while merging the list (R3.5 keeps
754/// diagnostics out of emission).
755pub fn duplicate_compatibility_flag_warning(
756    flag: &str,
757    default: bool,
758    manifest: PathBuf,
759) -> crate::AttributedError {
760    let message = if default {
761        format!(
762            "`bynk.toml`'s `[workers] compatibility_flags` lists `{flag}`, which every Bynk Worker already has"
763        )
764    } else {
765        format!("`bynk.toml`'s `[workers] compatibility_flags` lists `{flag}` more than once")
766    };
767    let mut error = bynk_syntax::CompileError::new(
768        "bynk.project.duplicate_compatibility_flag",
769        bynk_syntax::span::Span::default(),
770        message,
771    );
772    error
773        .notes
774        .push("the duplicate is dropped; remove it from `bynk.toml`".to_string());
775    crate::AttributedError::new(Some(manifest), error)
776}
777
778/// The `[workers]` table's typed content. Only the value shapes are checked
779/// here; an unknown key is [`check_manifest_str`]'s to refuse.
780fn workers_config_from(table: &toml::Table) -> Result<WorkersConfig, ProjectPathsError> {
781    let not_a_list = || ProjectPathsError::NotAStringList {
782        table: "workers",
783        key: "compatibility_flags",
784    };
785    let compatibility_flags = match table.get("compatibility_flags") {
786        None => Vec::new(),
787        Some(toml::Value::Array(items)) => items
788            .iter()
789            .map(|v| v.as_str().map(str::to_owned).ok_or_else(not_a_list))
790            .collect::<Result<_, _>>()?,
791        Some(_) => return Err(not_a_list()),
792    };
793    let workers_dev = match table.get("workers_dev") {
794        None => None,
795        Some(toml::Value::Boolean(b)) => Some(*b),
796        Some(_) => {
797            return Err(ProjectPathsError::NotABool {
798                table: "workers",
799                key: "workers_dev",
800            });
801        }
802    };
803    Ok(WorkersConfig {
804        compatibility_flags,
805        workers_dev,
806    })
807}
808
809/// The project's `[unstable]` table (standard-library S3, #1963): the
810/// experimental first-party items it opts into using.
811#[derive(Debug, Clone, Default, PartialEq, Eq)]
812pub struct UnstableConfig {
813    /// Item paths (`bynk.cloudflare.D1`, `bynk.cloudflare.Kv.list`), in
814    /// manifest order. An entry covers itself and everything under it. Whether
815    /// each one names something experimental is the checker's to say, since it
816    /// holds the standard library's registry.
817    pub allow: Vec<String>,
818}
819
820/// Read `bynk.toml`'s `[unstable]` table, honouring `overlay` the way
821/// [`try_read_workers_config_with`] does. No manifest, or no `[unstable]`
822/// table, is the empty [`UnstableConfig`]; an `allow` that isn't a list of
823/// strings is [`ProjectPathsError::NotAStringList`].
824pub fn try_read_unstable_config_with(
825    project_root: &Path,
826    overlay: &HashMap<PathBuf, String>,
827) -> Result<UnstableConfig, ProjectPathsError> {
828    let toml_path = project_root.join("bynk.toml");
829    let Ok(content) = read_source(&toml_path, overlay) else {
830        return Ok(UnstableConfig::default());
831    };
832    let doc = content
833        .parse::<toml::Table>()
834        .map_err(|_| ProjectPathsError::Malformed)?;
835    match doc.get("unstable").and_then(|v| v.as_table()) {
836        Some(t) => unstable_config_from(t),
837        None => Ok(UnstableConfig::default()),
838    }
839}
840
841/// [`try_read_unstable_config_with`], reading `bynk.toml` from disk.
842pub fn try_read_unstable_config(project_root: &Path) -> Result<UnstableConfig, ProjectPathsError> {
843    let toml_path = project_root.join("bynk.toml");
844    let overlay = match fs::read_to_string(&toml_path) {
845        Ok(text) => HashMap::from([(toml_path, text)]),
846        Err(_) => HashMap::new(),
847    };
848    try_read_unstable_config_with(project_root, &overlay)
849}
850
851/// The project's `[unstable]` table as both the build and the editor see it
852/// (#1963 Decision E): the overlay's `bynk.toml` when it holds one (the LSP puts
853/// the open buffer there), otherwise the one on disk (a CLI build's overlay holds
854/// only `.bynk` sources). A missing or malformed manifest reads as empty; its
855/// shape errors are `check_manifest`'s to report.
856pub fn read_unstable_config(
857    project_root: &Path,
858    overlay: &HashMap<PathBuf, String>,
859) -> UnstableConfig {
860    let manifest = project_root.join("bynk.toml");
861    let read = if overlay.contains_key(&manifest) {
862        try_read_unstable_config_with(project_root, overlay)
863    } else {
864        try_read_unstable_config(project_root)
865    };
866    read.unwrap_or_default()
867}
868
869/// The `[unstable]` table's typed content. Only the value shape is checked
870/// here; an unknown key is [`check_manifest_str`]'s to refuse.
871fn unstable_config_from(table: &toml::Table) -> Result<UnstableConfig, ProjectPathsError> {
872    let not_a_list = || ProjectPathsError::NotAStringList {
873        table: "unstable",
874        key: "allow",
875    };
876    let allow = match table.get("allow") {
877        None => Vec::new(),
878        Some(toml::Value::Array(items)) => items
879            .iter()
880            .map(|v| v.as_str().map(str::to_owned).ok_or_else(not_a_list))
881            .collect::<Result<_, _>>()?,
882        Some(_) => return Err(not_a_list()),
883    };
884    Ok(UnstableConfig { allow })
885}
886
887/// The path a unit's name maps to, relative to a source or output root:
888/// `a.b` is `a/b`. A dependency's canonical unit name (#2223,
889/// [`crate::packages::PackageId::encoded`]) maps under
890/// `deps/<org>/<package>/`, so every name-to-path site lays a dependency's
891/// units out there without knowing about packages.
892pub fn commons_dir_for(name: &str) -> PathBuf {
893    let mut p = PathBuf::new();
894    let mut parts = name.split('.');
895    let first = parts.next().unwrap_or_default();
896    match crate::packages::PackageId::decode(first) {
897        Some(id) => p.push(id.out_dir()),
898        None => p.push(first),
899    }
900    for part in parts {
901        p.push(part);
902    }
903    p
904}
905
906pub fn ts_output_path(source: &Path) -> PathBuf {
907    let mut out = source.to_path_buf();
908    out.set_extension("ts");
909    out
910}
911
912/// v0.8: directory name of a Worker for a given context, with dots replaced
913/// by dashes (`commerce.payment` → `commerce-payment`).
914pub fn worker_dir_name(context: &str) -> String {
915    context.replace('.', "-")
916}
917
918/// v0.8: project-relative synthetic source path of the workers-mode
919/// handlers file for a given context. Used so the emitter's relative-import
920/// machinery resolves correctly against the workers layout.
921pub fn worker_handlers_source_path(context: &str) -> PathBuf {
922    PathBuf::from(format!(
923        "workers/{}/handlers.bynk",
924        worker_dir_name(context)
925    ))
926}
927
928/// v0.8: project-relative output path of the workers-mode handlers file.
929pub fn worker_handlers_output_path(context: &str) -> PathBuf {
930    PathBuf::from(format!("workers/{}/handlers.ts", worker_dir_name(context)))
931}
932
933/// #1820: project-relative synthetic source path of one file's module in a
934/// context split across files, on Workers. Each file is its own module under
935/// `workers/<dir>/handlers/`, and `handlers.ts` re-exports them all, so the
936/// Worker's entry point and composition root, and every consumer, still
937/// import the context from `handlers.ts`. A file keeps its stem
938/// (`shop/orders/place.bynk` → `handlers/place.bynk`). Distinct files map to
939/// distinct modules because every file of a split unit sits directly in its
940/// `<name>/` directory: a `<name>.bynk` beside it is rejected
941/// (`bynk.project.file_and_directory`), as is a file in any other directory
942/// (`bynk.project.inconsistent_commons_name`). The emitted module is
943/// [`ts_output_path`] of this path.
944pub fn worker_file_source_path(context: &str, source: &Path) -> PathBuf {
945    let stem = source.file_stem().unwrap_or_default().to_string_lossy();
946    PathBuf::from(format!(
947        "workers/{}/handlers/{stem}.bynk",
948        worker_dir_name(context)
949    ))
950}
951
952/// The src-stripped stem components of a path (`learner/uln.bynk` → `["learner",
953/// "uln"]`), dropping the extension and any non-`Normal` components.
954fn stem_parts(rel_path: &Path) -> Vec<String> {
955    rel_path
956        .with_extension("")
957        .components()
958        .filter_map(|c| match c {
959            Component::Normal(s) => Some(s.to_string_lossy().to_string()),
960            _ => None,
961        })
962        .collect()
963}
964
965/// v0.9.1: shared between source-unit and test-unit path validation. The
966/// caller decides which root to strip from the file path before calling.
967///
968/// A file belongs to `qualified_name` when it is either the single file
969/// `<name>.bynk` (`single_file_match`: stem parts == name parts) or one file of
970/// the directory layout `<name>/*.bynk` (`multi_file_match`: parent-dir parts ==
971/// name parts). These two branches are the single source of truth the v0.132
972/// barrel trigger reads via [`is_multi_file_layout`].
973pub fn unit_path_matches(rel_path: &Path, qualified_name: &str) -> bool {
974    // #2223: a dependency unit's canonical name maps under `deps/…`, the
975    // path its file is given once its package is known.
976    let dir = commons_dir_for(qualified_name);
977    let owned: Vec<String> = dir
978        .components()
979        .map(|c| c.as_os_str().to_string_lossy().into_owned())
980        .collect();
981    let name_parts: Vec<&str> = owned.iter().map(String::as_str).collect();
982    let stem_parts = stem_parts(rel_path);
983    let single_file_match = stem_parts.len() == name_parts.len()
984        && stem_parts
985            .iter()
986            .zip(name_parts.iter())
987            .all(|(a, b)| a == b);
988    single_file_match || is_multi_file_parts(&stem_parts, &name_parts)
989}
990
991/// True when `stem_parts` is one file of the `<name>/*.bynk` directory layout —
992/// the file's parent-directory parts equal the name parts.
993fn is_multi_file_parts(stem_parts: &[String], name_parts: &[&str]) -> bool {
994    if stem_parts.is_empty() {
995        return false;
996    }
997    let parent_parts = &stem_parts[..stem_parts.len() - 1];
998    parent_parts.len() == name_parts.len()
999        && parent_parts
1000            .iter()
1001            .zip(name_parts.iter())
1002            .all(|(a, b)| a == b)
1003}
1004
1005/// v0.132: does `rel_path` (src-stripped) place `qualified_name` under a
1006/// directory of that name — the `multi_file_match` branch of
1007/// [`unit_path_matches`]?
1008///
1009/// This is the layout where production emits `out/<name>/*.ts` per file and no
1010/// aggregate `out/<name>.ts`, so the test path's `import * as ns from
1011/// "./<name>.js"` dangles and needs an aggregating barrel. A single-file commons
1012/// (`<name>.bynk`) already owns `out/<name>.ts` and returns false, so a barrel
1013/// keyed on this predicate can never collide with it.
1014pub fn is_multi_file_layout(rel_path: &Path, qualified_name: &str) -> bool {
1015    let name_parts: Vec<&str> = qualified_name.split('.').collect();
1016    is_multi_file_parts(&stem_parts(rel_path), &name_parts)
1017}
1018
1019/// #302: the qualified name a file moved from `old_rel` to `new_rel` should now
1020/// declare, preserving whichever [`unit_path_matches`] arrangement `old_rel`
1021/// used to satisfy against `old_name` — the dotted stem for a single-file
1022/// unit, or the dotted parent-directory for one file of a multi-file unit.
1023/// Returns `None` if `old_rel`/`old_name` don't actually satisfy either
1024/// arrangement (a pre-existing inconsistency the caller should not guess at).
1025///
1026/// `old_name` is matched as a **suffix** of `old_rel`'s stem/parent, not the
1027/// whole thing: the LSP's caller passes project-relative paths, which (unlike
1028/// the `source_path` `unit_path_matches` itself is checked against) still
1029/// carry a leading `include`-root segment (e.g. `src/`) that the qualified
1030/// name never mentions. Whatever prefix length that suffix match implies for
1031/// `old_rel` is applied unchanged to `new_rel` — correct as long as the file
1032/// stays under the same `include` root, which a rename/move normally does.
1033pub fn renamed_unit_name(old_rel: &Path, old_name: &str, new_rel: &Path) -> Option<String> {
1034    let name_parts: Vec<&str> = old_name.split('.').collect();
1035    let old_stem = stem_parts(old_rel);
1036    let new_stem = stem_parts(new_rel);
1037
1038    let suffix_matches = |haystack: &[String]| {
1039        haystack.len() >= name_parts.len() && {
1040            let prefix_len = haystack.len() - name_parts.len();
1041            haystack[prefix_len..]
1042                .iter()
1043                .zip(name_parts.iter())
1044                .all(|(a, b)| a == b)
1045        }
1046    };
1047
1048    if suffix_matches(&old_stem) {
1049        let prefix_len = old_stem.len() - name_parts.len();
1050        return (new_stem.len() >= prefix_len).then(|| new_stem[prefix_len..].join("."));
1051    }
1052    if !old_stem.is_empty() {
1053        let old_parent = &old_stem[..old_stem.len() - 1];
1054        if suffix_matches(old_parent) {
1055            let prefix_len = old_parent.len() - name_parts.len();
1056            if new_stem.is_empty() {
1057                return None;
1058            }
1059            let new_parent = &new_stem[..new_stem.len() - 1];
1060            return (new_parent.len() >= prefix_len).then(|| new_parent[prefix_len..].join("."));
1061        }
1062    }
1063    None
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068    use super::*;
1069    use std::path::{Path, PathBuf};
1070
1071    // -- is_unpinned_range ----------------------------------------------------
1072    #[test]
1073    fn is_unpinned_range_true_for_wildcards_and_digitless() {
1074        assert!(is_unpinned_range(""));
1075        assert!(is_unpinned_range("*"));
1076        assert!(is_unpinned_range("x"));
1077        assert!(is_unpinned_range("X"));
1078        assert!(is_unpinned_range("latest"));
1079        assert!(is_unpinned_range("LATEST"));
1080        assert!(is_unpinned_range("  *  ")); // trimmed before the checks
1081        assert!(is_unpinned_range("workspace:*")); // no ascii digit
1082        assert!(is_unpinned_range("beta"));
1083    }
1084
1085    #[test]
1086    fn is_unpinned_range_false_when_a_digit_is_present() {
1087        assert!(!is_unpinned_range("1.0.0"));
1088        assert!(!is_unpinned_range("^1.2"));
1089        assert!(!is_unpinned_range("~0.1"));
1090        assert!(!is_unpinned_range(">=2"));
1091        assert!(!is_unpinned_range("18"));
1092    }
1093
1094    // -- read_project_paths_with (#57, in-memory test seam) -------------------
1095    #[test]
1096    fn read_project_paths_with_honours_a_virtual_bynk_toml() {
1097        let root = PathBuf::from("/nonexistent-bynk-test-root-57");
1098        let mut overlay = HashMap::new();
1099        overlay.insert(
1100            root.join("bynk.toml"),
1101            "[paths]\ninclude = [\"app\"]\nexclude = [\"vendor\"]\n".to_string(),
1102        );
1103        let paths = read_project_paths_with(&root, &overlay);
1104        assert_eq!(paths.include, vec![PathBuf::from("app")]);
1105        assert_eq!(paths.exclude, vec![PathBuf::from("vendor")]);
1106    }
1107
1108    #[test]
1109    fn read_project_paths_with_falls_back_to_conventional_with_no_overlay_entry() {
1110        // No overlay entry and no real file at this (nonexistent) root — same
1111        // fallback a missing on-disk `bynk.toml` gives.
1112        let root = PathBuf::from("/nonexistent-bynk-test-root-57-empty");
1113        let paths = read_project_paths_with(&root, &HashMap::new());
1114        let conventional = ProjectPaths::conventional(&root);
1115        assert_eq!(paths.include, conventional.include);
1116        assert_eq!(paths.exclude, conventional.exclude);
1117    }
1118
1119    /// R3.9 (#1113): three or more `[paths] include` entries all round-trip —
1120    /// `try_read_project_paths_with` no longer caps the list at two.
1121    #[test]
1122    fn read_project_paths_with_honours_three_or_more_include_entries() {
1123        let root = PathBuf::from("/nonexistent-bynk-test-root-1113-many-includes");
1124        let mut overlay = HashMap::new();
1125        overlay.insert(
1126            root.join("bynk.toml"),
1127            "[paths]\ninclude = [\"src\", \"tests\", \"examples\"]\n".to_string(),
1128        );
1129        let paths = try_read_project_paths_with(&root, &overlay).expect("must parse");
1130        assert_eq!(
1131            paths.include,
1132            vec![
1133                PathBuf::from("src"),
1134                PathBuf::from("tests"),
1135                PathBuf::from("examples"),
1136            ]
1137        );
1138    }
1139
1140    // -- render_package_json --------------------------------------------------
1141    #[test]
1142    fn render_package_json_declares_es_modules() {
1143        // #2148: with no dependencies the file is just the module-format marker.
1144        let bare = render_package_json(&std::collections::BTreeMap::new());
1145        assert_eq!(bare, "{\n  \"type\": \"module\"\n}\n");
1146        let mut deps = std::collections::BTreeMap::new();
1147        deps.insert("jose".to_string(), "^5.0.0".to_string());
1148        let with_deps = render_package_json(&deps);
1149        assert_eq!(
1150            with_deps,
1151            "{\n  \"type\": \"module\",\n  \"dependencies\": {\n    \"jose\": \"^5.0.0\"\n  }\n}\n"
1152        );
1153    }
1154
1155    #[test]
1156    fn render_package_json_renders_sorted_dependencies() {
1157        let mut deps = std::collections::BTreeMap::new();
1158        deps.insert("zod".to_string(), "^3.22.4".to_string());
1159        deps.insert("hono".to_string(), "^4.0.0".to_string());
1160        let out = render_package_json(&deps);
1161        // BTreeMap ordering keeps the file byte-stable across builds.
1162        assert!(
1163            out.find("\"hono\"").unwrap() < out.find("\"zod\"").unwrap(),
1164            "dependencies render in sorted order:\n{out}"
1165        );
1166        assert!(out.contains("\"hono\": \"^4.0.0\""), "{out}");
1167    }
1168
1169    /// A package name and version range reach here from adapter declarations in
1170    /// Bynk source, so they are arbitrary text. This module used to escape only
1171    /// `"` and `\`, which let a control character through as a literal — and a
1172    /// literal control character inside a JSON string is a parse error, so the
1173    /// emitted `package.json` was invalid rather than merely odd.
1174    #[test]
1175    fn render_package_json_escapes_the_control_range() {
1176        let mut deps = std::collections::BTreeMap::new();
1177        deps.insert("pkg\nname".to_string(), "^1.0\u{1}0".to_string());
1178        let out = render_package_json(&deps);
1179        assert!(out.contains("\"pkg\\nname\""), "{out}");
1180        assert!(out.contains("\"^1.0\\u00010\""), "{out}");
1181        // No raw control character survives into the rendered document (the
1182        // pretty-printer's own newlines are all that remain).
1183        assert!(
1184            !out.lines().any(|l| l.chars().any(|c| (c as u32) < 0x20)),
1185            "a raw control character reached the output:\n{out:?}"
1186        );
1187    }
1188
1189    #[test]
1190    fn render_package_json_escapes_structural_characters() {
1191        let mut deps = std::collections::BTreeMap::new();
1192        deps.insert("a\"b".to_string(), "c\\d".to_string());
1193        let out = render_package_json(&deps);
1194        assert!(out.contains(r#""a\"b": "c\\d""#), "{out}");
1195    }
1196
1197    // -- normalize_rel --------------------------------------------------------
1198    #[test]
1199    fn normalize_rel_resolves_dot_and_parent() {
1200        assert_eq!(
1201            normalize_rel(Path::new("./tokens.binding.ts")),
1202            PathBuf::from("tokens.binding.ts")
1203        );
1204        assert_eq!(normalize_rel(Path::new("a/./b")), PathBuf::from("a/b"));
1205        assert_eq!(normalize_rel(Path::new("a/../b")), PathBuf::from("b"));
1206        assert_eq!(normalize_rel(Path::new("a/b/../../c")), PathBuf::from("c"));
1207        assert_eq!(normalize_rel(Path::new("a/b")), PathBuf::from("a/b"));
1208    }
1209
1210    #[test]
1211    fn normalize_rel_drops_root_and_pops_through_empty() {
1212        // RootDir / Prefix components are dropped.
1213        assert_eq!(normalize_rel(Path::new("/a/b")), PathBuf::from("a/b"));
1214        // A leading `..` pops an empty stack (a no-op), so it vanishes.
1215        assert_eq!(normalize_rel(Path::new("../a")), PathBuf::from("a"));
1216    }
1217
1218    // -- commons_dir_for / ts_output_path -------------------------------------
1219    #[test]
1220    fn commons_dir_for_splits_dotted_name_into_dirs() {
1221        assert_eq!(commons_dir_for("a.b.c"), PathBuf::from("a/b/c"));
1222        assert_eq!(commons_dir_for("foo"), PathBuf::from("foo"));
1223    }
1224
1225    #[test]
1226    fn ts_output_path_sets_ts_extension() {
1227        assert_eq!(
1228            ts_output_path(Path::new("foo.bynk")),
1229            PathBuf::from("foo.ts")
1230        );
1231        assert_eq!(
1232            ts_output_path(Path::new("a/b.bynk")),
1233            PathBuf::from("a/b.ts")
1234        );
1235        assert_eq!(ts_output_path(Path::new("foo")), PathBuf::from("foo.ts"));
1236    }
1237
1238    // -- worker path helpers --------------------------------------------------
1239    #[test]
1240    fn worker_paths_dasherise_and_root_under_workers() {
1241        assert_eq!(worker_dir_name("commerce.payment"), "commerce-payment");
1242        assert_eq!(worker_dir_name("plain"), "plain");
1243        assert_eq!(
1244            worker_handlers_source_path("commerce.payment"),
1245            PathBuf::from("workers/commerce-payment/handlers.bynk")
1246        );
1247        assert_eq!(
1248            worker_handlers_output_path("commerce.payment"),
1249            PathBuf::from("workers/commerce-payment/handlers.ts")
1250        );
1251    }
1252
1253    #[test]
1254    fn a_split_context_file_has_its_own_module_under_handlers() {
1255        assert_eq!(
1256            worker_file_source_path("shop.orders", Path::new("shop/orders/place.bynk")),
1257            PathBuf::from("workers/shop-orders/handlers/place.bynk")
1258        );
1259    }
1260
1261    // -- unit_path_matches ----------------------------------------------------
1262    #[test]
1263    fn unit_path_matches_single_file_layout() {
1264        assert!(unit_path_matches(Path::new("a/b/c.bynk"), "a.b.c"));
1265        assert!(unit_path_matches(Path::new("foo.bynk"), "foo"));
1266    }
1267
1268    #[test]
1269    fn unit_path_matches_multi_file_layout() {
1270        // `a/b/c/<any>.bynk` declaring `a.b.c` (the directory is the unit).
1271        assert!(unit_path_matches(Path::new("a/b/c/handlers.bynk"), "a.b.c"));
1272        assert!(unit_path_matches(Path::new("a/b/c/anything.bynk"), "a.b.c"));
1273    }
1274
1275    #[test]
1276    fn unit_path_matches_rejects_misalignment() {
1277        assert!(!unit_path_matches(Path::new("a/b.bynk"), "a.b.c"));
1278        assert!(!unit_path_matches(Path::new("x/y/z.bynk"), "a.b.c"));
1279    }
1280
1281    // -- is_multi_file_layout (v0.132 barrel trigger) -------------------------
1282    #[test]
1283    fn is_multi_file_layout_true_only_for_directory_layout() {
1284        // Directory layout: `<name>/*.bynk` — the branch with no `out/<name>.ts`.
1285        assert!(is_multi_file_layout(Path::new("thing/a.bynk"), "thing"));
1286        assert!(is_multi_file_layout(Path::new("thing/b.bynk"), "thing"));
1287        // Dotted commons split across `src/a/b/*.bynk`.
1288        assert!(is_multi_file_layout(Path::new("a/b/one.bynk"), "a.b"));
1289    }
1290
1291    #[test]
1292    fn is_multi_file_layout_false_for_single_file_and_misalignment() {
1293        // Single file `<name>.bynk` already owns `out/<name>.ts` — no barrel.
1294        assert!(!is_multi_file_layout(Path::new("thing.bynk"), "thing"));
1295        // Dotted single file `a/b.bynk` for `a.b` — the file *is* `out/a/b.ts`.
1296        assert!(!is_multi_file_layout(Path::new("a/b.bynk"), "a.b"));
1297        // Wrong directory — not this unit's file.
1298        assert!(!is_multi_file_layout(Path::new("other/a.bynk"), "thing"));
1299    }
1300
1301    // -- renamed_unit_name (#302) ----------------------------------------------
1302    #[test]
1303    fn renamed_unit_name_single_file() {
1304        assert_eq!(
1305            renamed_unit_name(
1306                Path::new("a/b/c.bynk"),
1307                "a.b.c",
1308                Path::new("a/b/renamed.bynk")
1309            ),
1310            Some("a.b.renamed".to_string())
1311        );
1312        assert_eq!(
1313            renamed_unit_name(Path::new("foo.bynk"), "foo", Path::new("bar.bynk")),
1314            Some("bar".to_string())
1315        );
1316    }
1317
1318    #[test]
1319    fn renamed_unit_name_multi_file_member_rename_is_a_no_op() {
1320        // Renaming one member file within the same directory doesn't change
1321        // the unit's name — the qualified name is the directory, not the
1322        // filename.
1323        assert_eq!(
1324            renamed_unit_name(
1325                Path::new("a/b/c/old.bynk"),
1326                "a.b.c",
1327                Path::new("a/b/c/new.bynk")
1328            ),
1329            Some("a.b.c".to_string())
1330        );
1331    }
1332
1333    #[test]
1334    fn renamed_unit_name_multi_file_directory_move() {
1335        assert_eq!(
1336            renamed_unit_name(
1337                Path::new("a/b/c/handlers.bynk"),
1338                "a.b.c",
1339                Path::new("a/b/renamed/handlers.bynk")
1340            ),
1341            Some("a.b.renamed".to_string())
1342        );
1343    }
1344
1345    #[test]
1346    fn renamed_unit_name_none_on_preexisting_misalignment() {
1347        assert_eq!(
1348            renamed_unit_name(Path::new("x/y/z.bynk"), "a.b.c", Path::new("x/y/w.bynk")),
1349            None
1350        );
1351    }
1352
1353    #[test]
1354    fn renamed_unit_name_tolerates_a_shared_include_root_prefix() {
1355        // The LSP passes project-relative paths (ADR 0198), which still carry
1356        // a split project's `src`/`tests` root segment — `unit_path_matches`
1357        // itself is only ever checked against the root-stripped `source_path`.
1358        // `old_name` must match as a *suffix*, and the same leading-segment
1359        // count is preserved onto `new_rel`.
1360        assert_eq!(
1361            renamed_unit_name(
1362                Path::new("src/billing/charge.bynk"),
1363                "billing.charge",
1364                Path::new("src/billing/pay.bynk")
1365            ),
1366            Some("billing.pay".to_string())
1367        );
1368        // Multi-file arrangement under the same prefix.
1369        assert_eq!(
1370            renamed_unit_name(
1371                Path::new("src/a/b/c/handlers.bynk"),
1372                "a.b.c",
1373                Path::new("src/a/b/renamed/handlers.bynk")
1374            ),
1375            Some("a.b.renamed".to_string())
1376        );
1377    }
1378}
1379
1380#[cfg(test)]
1381mod manifest_tests {
1382    use super::*;
1383
1384    fn err(manifest: &str) -> String {
1385        check_manifest_str(manifest)
1386            .expect_err("the manifest is refused")
1387            .to_string()
1388    }
1389
1390    /// #1665: every table the docs describe, with every key, is accepted.
1391    #[test]
1392    fn the_documented_tables_are_accepted() {
1393        check_manifest_str(
1394            "[project]\nname = \"p\"\nversion = \"0.1.0\"\n\n[paths]\ninclude = [\"src\"]\nexclude = []\n\n\
1395             [fmt]\nindent = \"tab\"\nmax_line_width = 100\n\n[lsp]\ndiagnostics_mode = \"live\"\n\
1396             diagnostics_debounce_ms = 300\n\n[workers]\ncompatibility_flags = [\"nodejs_compat\"]\n",
1397        )
1398        .expect("accepted");
1399        check_manifest_str("").expect("an empty manifest is accepted");
1400    }
1401
1402    #[test]
1403    fn a_planned_table_names_its_issue() {
1404        assert!(err("[deploy]\ngroups = []\n").contains("not yet supported (#551)"));
1405    }
1406
1407    #[test]
1408    fn a_typod_table_suggests_the_nearest() {
1409        assert!(err("[pahts]\n").contains("did you mean `[paths]`?"));
1410        assert!(err("[fnt]\n").contains("did you mean `[fmt]`?"));
1411        assert!(err("[zzzzzz]\n").contains("the tables are"));
1412    }
1413
1414    #[test]
1415    fn an_unknown_key_in_project_or_lsp_suggests_the_nearest() {
1416        assert!(err("[project]\nnmae = \"p\"\n").contains("did you mean `name`?"));
1417        assert!(
1418            err("[lsp]\ndiagnostics_mod = \"live\"\n").contains("did you mean `diagnostics_mode`?")
1419        );
1420        assert!(err("[project]\nlicense = \"MIT\"\n").contains("its keys are `name`, `version`"));
1421    }
1422
1423    #[test]
1424    fn a_paths_key_keeps_its_own_error() {
1425        assert!(matches!(
1426            check_manifest_str("[paths]\nout = \"out\"\n"),
1427            Err(ProjectPathsError::UnknownKey(k)) if k == "out"
1428        ));
1429    }
1430
1431    #[test]
1432    fn a_top_level_key_points_at_its_table() {
1433        assert!(err("name = \"p\"\n").contains("did you mean it under `[project]`?"));
1434    }
1435
1436    /// #1770 review: a known table's name holding a plain value is refused,
1437    /// not read as an absent table.
1438    #[test]
1439    fn a_table_name_with_a_plain_value_must_be_a_table() {
1440        assert!(err("paths = \"src\"\n").contains("must be a table — write it as `[paths]`"));
1441        assert!(err("lsp = 3\n").contains("`[lsp]`"));
1442        assert!(err("fmt = true\n").contains("`[fmt]`"));
1443        // An inline table is a table.
1444        check_manifest_str("paths = { include = [\"src\"] }\n").expect("accepted");
1445    }
1446
1447    /// #1770 review: a planned table keeps its issue whatever shape it takes.
1448    #[test]
1449    fn a_planned_table_in_any_shape_names_its_issue() {
1450        assert!(err("[[deploy]]\nname = \"acme\"\n").contains("(#551)"));
1451        assert!(err("deploy = [\"acme\"]\n").contains("(#551)"));
1452    }
1453
1454    /// #2207: a map table written in another shape is told to be a table.
1455    #[test]
1456    fn a_map_table_in_another_shape_must_be_a_table() {
1457        assert_eq!(
1458            err("[[dependencies]]\nname = \"acme\"\n"),
1459            "`dependencies` in `bynk.toml` must be a table — write it as `[dependencies]`"
1460        );
1461        assert_eq!(
1462            err("registries = [\"acme\"]\n"),
1463            "`registries` in `bynk.toml` must be a table — write it as `[registries]`"
1464        );
1465        assert_eq!(
1466            err("[dependencis]\n"),
1467            "`bynk.toml` has no table named `[dependencis]` — did you mean `[dependencies]`?"
1468        );
1469    }
1470
1471    /// `[fmt]` keys are `bynk-fmt`'s to check, not this one's.
1472    #[test]
1473    fn fmt_keys_are_left_to_the_formatter() {
1474        check_manifest_str("[fmt]\nanything = 1\n").expect("not this check's concern");
1475    }
1476
1477    /// #1890: `[workers] compatibility_flags` must be a list of strings; the
1478    /// names themselves pass through.
1479    #[test]
1480    fn workers_compatibility_flags_must_be_a_list_of_strings() {
1481        let msg = "`[workers]` `compatibility_flags` must be a list of strings";
1482        assert!(err("[workers]\ncompatibility_flags = \"nodejs_compat\"\n").contains(msg));
1483        assert!(err("[workers]\ncompatibility_flags = [\"a\", 1]\n").contains(msg));
1484        assert!(err("[workers]\ncompatibility_flags = [[\"a\"]]\n").contains(msg));
1485        assert!(
1486            err("[workers]\ncompatibility_flag = []\n")
1487                .contains("did you mean `compatibility_flags`?")
1488        );
1489        check_manifest_str("[workers]\ncompatibility_flags = [\"no_such_flag_yet\"]\n")
1490            .expect("flag names are not validated");
1491        check_manifest_str("[workers]\n").expect("an empty `[workers]` is accepted");
1492    }
1493
1494    /// #1963: `[unstable] allow` must be a list of strings; whether each names
1495    /// something experimental is the checker's to say.
1496    #[test]
1497    fn unstable_allow_must_be_a_list_of_strings() {
1498        let msg = "`[unstable]` `allow` must be a list of strings";
1499        assert!(err("[unstable]\nallow = \"bynk.cloudflare.D1\"\n").contains(msg));
1500        assert!(err("[unstable]\nallow = [\"a\", 1]\n").contains(msg));
1501        assert!(err("[unstable]\nallows = []\n").contains("did you mean `allow`?"));
1502        check_manifest_str("[unstable]\nallow = [\"bynk.anything\"]\n")
1503            .expect("item paths are the checker's to validate");
1504        check_manifest_str("[unstable]\n").expect("an empty `[unstable]` is accepted");
1505    }
1506
1507    #[test]
1508    fn unstable_config_reads_the_entries_in_order() {
1509        let root = PathBuf::from("/nonexistent-bynk-test-root-1963");
1510        let read = |manifest: &str| {
1511            let overlay = HashMap::from([(root.join("bynk.toml"), manifest.to_string())]);
1512            try_read_unstable_config_with(&root, &overlay)
1513        };
1514        assert_eq!(
1515            read("[unstable]\nallow = [\"b.x\", \"a.y\"]\n")
1516                .unwrap()
1517                .allow,
1518            vec!["b.x", "a.y"]
1519        );
1520        assert_eq!(
1521            read("[project]\nname = \"p\"\n").unwrap(),
1522            UnstableConfig::default()
1523        );
1524        assert!(matches!(
1525            read("[unstable]\nallow = 3\n"),
1526            Err(ProjectPathsError::NotAStringList {
1527                table: "unstable",
1528                key: "allow"
1529            })
1530        ));
1531    }
1532
1533    #[test]
1534    fn workers_config_reads_the_flags_in_order() {
1535        let root = PathBuf::from("/nonexistent-bynk-test-root-1890");
1536        let read = |manifest: &str| {
1537            let overlay = HashMap::from([(root.join("bynk.toml"), manifest.to_string())]);
1538            try_read_workers_config_with(&root, &overlay)
1539        };
1540        assert_eq!(
1541            read("[workers]\ncompatibility_flags = [\"b\", \"a\", \"b\"]\n")
1542                .unwrap()
1543                .compatibility_flags,
1544            vec!["b", "a", "b"],
1545            "order and duplicates are kept; deduplication is the emitter's"
1546        );
1547        assert_eq!(
1548            read("[project]\nname = \"p\"\n").unwrap(),
1549            WorkersConfig::default()
1550        );
1551        assert_eq!(
1552            try_read_workers_config_with(&root, &HashMap::new()).unwrap(),
1553            WorkersConfig::default(),
1554            "no manifest at all"
1555        );
1556        assert!(matches!(
1557            read("[workers]\ncompatibility_flags = [true]\n"),
1558            Err(ProjectPathsError::NotAStringList { .. })
1559        ));
1560        assert!(matches!(
1561            read("[workers\n"),
1562            Err(ProjectPathsError::Malformed)
1563        ));
1564    }
1565
1566    #[test]
1567    fn a_malformed_manifest_is_malformed() {
1568        assert!(matches!(
1569            check_manifest_str("[project\n"),
1570            Err(ProjectPathsError::Malformed)
1571        ));
1572    }
1573
1574    /// #2096: the shapes `bynk.toml`'s JSON Schema accepts for the hand-read
1575    /// tables are the shapes their readers accept, and the shapes it flags for
1576    /// `compatibility_flags`/`allow` are refused. The schema is written apart
1577    /// from these readers (`manifest_schema`), so this pins the two together.
1578    #[test]
1579    fn the_readers_accept_the_shapes_the_manifest_schema_offers() {
1580        let root = PathBuf::from("/nonexistent-bynk-test-root-2096");
1581        let overlay =
1582            |manifest: &str| HashMap::from([(root.join("bynk.toml"), manifest.to_string())]);
1583
1584        // `[paths] include`: a bare string and a list both read as the one root.
1585        for manifest in [
1586            "[paths]\ninclude = \"src\"\n",
1587            "[paths]\ninclude = [\"src\"]\n",
1588        ] {
1589            check_manifest_str(manifest).expect("the manifest check accepts it");
1590            let paths = try_read_project_paths_with(&root, &overlay(manifest))
1591                .unwrap_or_else(|e| panic!("{manifest:?}: {e}"));
1592            assert_eq!(paths.include, vec![PathBuf::from("src")], "{manifest:?}");
1593        }
1594
1595        // `[workers] compatibility_flags` and `[unstable] allow`: lists of strings.
1596        let manifest = "[workers]\ncompatibility_flags = [\"x\"]\n";
1597        check_manifest_str(manifest).expect("the manifest check accepts it");
1598        assert_eq!(
1599            try_read_workers_config_with(&root, &overlay(manifest))
1600                .unwrap()
1601                .compatibility_flags,
1602            vec!["x"]
1603        );
1604        let manifest = "[unstable]\nallow = [\"x\"]\n";
1605        check_manifest_str(manifest).expect("the manifest check accepts it");
1606        assert_eq!(
1607            try_read_unstable_config_with(&root, &overlay(manifest))
1608                .unwrap()
1609                .allow,
1610            vec!["x"]
1611        );
1612
1613        // A bare string is a list only for `[paths]`: the schema flags it for
1614        // these two, and so do the readers.
1615        let manifest = "[workers]\ncompatibility_flags = \"x\"\n";
1616        assert!(matches!(
1617            try_read_workers_config_with(&root, &overlay(manifest)),
1618            Err(ProjectPathsError::NotAStringList { .. })
1619        ));
1620        assert!(check_manifest_str(manifest).is_err());
1621        let manifest = "[unstable]\nallow = \"x\"\n";
1622        assert!(matches!(
1623            try_read_unstable_config_with(&root, &overlay(manifest)),
1624            Err(ProjectPathsError::NotAStringList { .. })
1625        ));
1626        assert!(check_manifest_str(manifest).is_err());
1627
1628        // #2138: `[test.targets.<name>]`.
1629        let manifest = "[test.targets.preview]\nurl.shop-orders = \"https://x\"\nsecrets = [\"S\"]\nvars = { P = \"fake\" }\nproduction = false\n";
1630        check_manifest_str(manifest).expect("a well-formed target is accepted");
1631        let targets = test_targets_from(
1632            manifest.parse::<toml::Table>().unwrap()["test"]
1633                .as_table()
1634                .unwrap(),
1635        )
1636        .unwrap();
1637        let preview = &targets["preview"];
1638        assert_eq!(preview.urls["shop-orders"], "https://x");
1639        assert_eq!(preview.secrets, ["S"]);
1640        assert_eq!(preview.vars["P"], "fake");
1641        assert!(!preview.production);
1642        for (manifest, needle) in [
1643            ("[test.targets.p]\nsecrets = []\n", "names no Worker URL"),
1644            (
1645                "[test.targets.p]\nurl.a = \"x\"\nsecret = \"S\"\n",
1646                "no key named `secret`",
1647            ),
1648            (
1649                "[test.targets.p]\nurl.a = \"x\"\nproduction = \"yes\"\n",
1650                "`production` must be",
1651            ),
1652            ("[test.targets.p]\nurl.a = 1\n", "`url.a` must be a string"),
1653            ("[test]\ntarget = 1\n", "no key named `target`"),
1654        ] {
1655            let err = check_manifest_str(manifest)
1656                .expect_err(manifest)
1657                .to_string();
1658            assert!(err.contains(needle), "{manifest:?}: {err}");
1659        }
1660    }
1661}