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