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