Skip to main content

bynk_emit/emitter/
wrangler.rs

1//! `wrangler.toml` generation per Worker (v0.8 §4.4 / §4.5).
2//!
3//! Each context becomes a Cloudflare Worker with its own wrangler config.
4//! Service Bindings are declared for every consumed context. Durable
5//! Object bindings + `exports` entries are declared for every agent.
6
7use crate::emitter::toml_doc::{TomlBlock, TomlDocument, TomlEntry, TomlValue};
8use crate::project::worker_dir_name;
9pub(crate) use bynk_check::worker_bindings::{BindingSource, WorkerBinding};
10
11/// Compile-time pinned compatibility date. Cloudflare uses this to lock
12/// Workers runtime behaviour. When to bump it, and the gate a bump must pass,
13/// is the policy in `design/bynk-release-discipline.md` ("Part 3 — The Workers
14/// compatibility date", #1677): reviewed at each release, and moved only after
15/// the workerd smokes pass on the new date.
16pub const COMPATIBILITY_DATE: &str = "2026-07-01";
17
18/// #1890: the compatibility flags every emitted Worker carries, ahead of the
19/// project's own (`bynk.toml`'s `[workers] compatibility_flags`).
20///
21/// `global_fetch_strictly_public` makes `fetch()` to a URL go out over the
22/// public internet even when the URL is another Worker on the same account
23/// or zone. Without it Cloudflare refuses such a fetch (error 1042, or a
24/// fast `404` from a `workers.dev` sibling), so a Bynk `Fetch` to a sibling
25/// service's public URL failed where the same URL worked from outside. In
26/// Bynk, `Fetch` to a URL means over the public internet, which is exactly
27/// this flag's behaviour. Worker-to-Worker calls inside one project use
28/// Service Bindings, which the flag leaves alone.
29///
30/// Reviewed with [`COMPATIBILITY_DATE`] (`design/bynk-release-discipline.md`,
31/// Part 3): a date bump can turn a flag on by default, which makes it
32/// redundant here.
33pub const DEFAULT_COMPATIBILITY_FLAGS: &[&str] = &["global_fetch_strictly_public"];
34
35/// #1890: the flags a Worker's `wrangler.toml` carries, and the duplicates
36/// dropped on the way. [`DEFAULT_COMPATIBILITY_FLAGS`] come first, then
37/// `extra` (the manifest's list) in order. A name already present, whether a
38/// default or earlier in `extra`, is dropped and returned in the second list,
39/// once per extra occurrence, for the caller to warn about. Names are not
40/// otherwise checked.
41pub fn compatibility_flags(extra: &[String]) -> (Vec<String>, Vec<String>) {
42    let mut flags: Vec<String> = DEFAULT_COMPATIBILITY_FLAGS
43        .iter()
44        .map(|f| f.to_string())
45        .collect();
46    let mut duplicates = Vec::new();
47    for flag in extra {
48        if flags.contains(flag) {
49            duplicates.push(flag.clone());
50        } else {
51            flags.push(flag.clone());
52        }
53    }
54    (flags, duplicates)
55}
56
57/// #1732: the oldest wrangler whose bundled `workerd` serves
58/// [`COMPATIBILITY_DATE`]. An older `workerd` refuses a newer date outright
59/// (`wrangler dev` exits: "This Worker requires compatibility date …"), so
60/// `bynk doctor` warns about a wrangler below this. Set by the same
61/// compatibility-date review that sets the date (Part 3's lag rule keeps it at
62/// least three months old). `bynk-emit/tests/compat_date_policy.rs` checks the
63/// policy doc names both.
64pub const WRANGLER_MIN: &str = "4.107.0";
65
66/// Events track, slice 0 (spine #936, ADR 0284): the class name of a
67/// publishing context's fan-out Durable Object (`emitter::events_fanout`).
68/// Shared with `emitter::workers` (the `Env` field name + `deps.
69/// __eventsDispatch` call it drives) and `emitter::events_fanout` (the class
70/// this name must actually export) so the three can never drift apart.
71///
72/// Double-underscore-prefixed, matching every other compiler-synthesised
73/// identifier in emitted output (`__events`, `__eventsDispatch`,
74/// `__makeLedger`, …) — a Bynk `agent` name can never start with `_` (a
75/// parse error, checked directly: `agent _Foo { … }` fails with
76/// `expected identifier after \`agent\`, found \`_\``), so an agent
77/// coincidentally named the same as this synthetic class is structurally
78/// impossible, not merely unlikely.
79pub const EVENTS_FANOUT_CLASS_NAME: &str = "__EventsFanout";
80
81/// Deploy-time sentinel in generated Worker configuration. The driver replaces
82/// this with the persistent Cloudflare KV namespace id immediately before a
83/// remote Wrangler command runs.
84pub const KV_NAMESPACE_ID_PLACEHOLDER: &str = "<KV_NAMESPACE_ID>";
85
86/// The bare `Kv`'s binding (`KV`), re-exported for `bynk deploy`, which reads
87/// generated configs and tells the bare namespace from derived instances by
88/// it (S4b, #1967).
89pub use bynk_check::firstparty::KV_BINDING_NAME;
90
91/// #1883, slice 1b: the `[assets]` block for a Worker that ships static files.
92/// `directory` is the Worker's own assets directory
93/// ([`crate::project::WORKER_ASSETS_DIR`]), beside — never containing — its
94/// compiled code. The three routing settings are set explicitly rather than
95/// inherited (#1883 [DECISION C], checked against the wrangler reference):
96///
97/// - `run_worker_first = false`: a matching asset is served without running
98///   the Worker, which is what makes an asset public by construction.
99/// - `not_found_handling = "none"`: a request no asset matches falls through
100///   to the Worker, so every handler stays reachable.
101/// - `html_handling = "none"`: an asset is served only at its exact path (the
102///   default would also serve `foo.html` at `/foo`), so the route-conflict
103///   check can compare paths exactly.
104pub(crate) fn assets_block() -> TomlBlock {
105    TomlBlock::table(
106        "assets",
107        vec![
108            TomlEntry::kv(
109                "directory",
110                TomlValue::str(format!("./{}", crate::project::WORKER_ASSETS_DIR)),
111            ),
112            TomlEntry::kv("run_worker_first", TomlValue::Bool(false)),
113            TomlEntry::kv("not_found_handling", TomlValue::str("none")),
114            TomlEntry::kv("html_handling", TomlValue::str("none")),
115        ],
116    )
117}
118
119/// L7 (#2414): `[cache]` turning Workers Cache on for a Worker whose context
120/// declares an `EdgeCache` instance. Cloudflare then answers a `GET` or `HEAD`
121/// from its edge cache when it holds a fresh response, without running the
122/// Worker, and stores what the Worker sends as RFC 9111 allows: which is why
123/// such a Worker sends `no-store` on every response but a `@cache` route's
124/// `2xx` (`workers_entry`). A Worker without an instance gets no stanza.
125pub(crate) fn edge_cache_block() -> TomlBlock {
126    TomlBlock::table(
127        "cache",
128        vec![TomlEntry::kv("enabled", TomlValue::Bool(true))],
129    )
130}
131
132/// #2321: the explicit module rule for a Worker whose adapter closure reaches
133/// an included WebAssembly module: `**/*.wasm` is compiled by Wrangler to a
134/// `WebAssembly.Module` (the shim's `import module from "./x.wasm"`). Wrangler's
135/// default rules already say this; stating it keeps the dependency visible and
136/// stops it changing under us. `fallthrough = true` leaves the other default
137/// rules in force.
138pub(crate) fn wasm_rule_block() -> TomlBlock {
139    TomlBlock::array_table(
140        "rules",
141        vec![
142            TomlEntry::kv("type", TomlValue::str("CompiledWasm")),
143            TomlEntry::kv("globs", TomlValue::Array(vec![TomlValue::str("**/*.wasm")])),
144            TomlEntry::kv("fallthrough", TomlValue::Bool(true)),
145        ],
146    )
147}
148
149/// #1883, slice 1b: the `_headers` file written into a Worker's assets
150/// directory beside its files. Every served path carries its content hash, so
151/// a file under [`bynk_check::assets::SERVED_PREFIX`] never changes and may
152/// be cached for a year. Without it the platform's default is `max-age=0,
153/// must-revalidate`. The glob is built from the prefix, so the two cannot
154/// drift (review of #1904).
155pub(crate) fn asset_headers() -> String {
156    format!(
157        "{}*\n  Cache-Control: public, max-age=31536000, immutable\n",
158        bynk_check::assets::SERVED_PREFIX
159    )
160}
161
162/// State-migrations S3 (#2248): one context's agent class renames
163/// (`(old, new)`) and removals, as `exports` tombstones.
164#[derive(Debug, Default)]
165pub(crate) struct ClassTombstones {
166    pub renamed: Vec<(String, String)>,
167    pub deleted: Vec<String>,
168}
169
170impl ClassTombstones {
171    /// From the context's declarations: each agent's `replaces`, and each
172    /// `removed agent`, skipping an old name still declared live (the checker
173    /// already refuses that).
174    pub(crate) fn of(table: &bynk_check::symbols::UnitTable) -> Self {
175        let mut renamed: Vec<(String, String)> = table
176            .agents
177            .values()
178            .filter_map(|a| {
179                let old = a.replaces.as_ref()?;
180                (!table.agents.contains_key(&old.name))
181                    .then(|| (old.name.clone(), a.name.name.clone()))
182            })
183            .collect();
184        renamed.sort();
185        let mut deleted: Vec<String> = table
186            .removed_agents
187            .iter()
188            .filter(|r| !table.agents.contains_key(&r.name.name))
189            .map(|r| r.name.name.clone())
190            .collect();
191        deleted.sort();
192        deleted.dedup();
193        ClassTombstones { renamed, deleted }
194    }
195}
196
197/// P6.x cutover slice 2 (#1191, narrowed from #1187): `crons`/`queues` arrive
198/// pre-collected, sorted and deduped by the caller (`project.rs`'s own
199/// `emit_wrangler_toml` call site) rather than being walked here off
200/// `table.services`. Both used to match a handler's cron-schedule kind and a
201/// service's queue-binding protocol directly, straight off the syntax tree —
202/// this file's entire raw-syntax footprint, per #1191's own grounding.
203/// Relocating just these two matches removed `wrangler.rs` from the
204/// `ast_importers` probe outright — no `bynk_ir` equivalent exists to route
205/// through instead: nothing builds a project-wide service IR at the call site
206/// (#1191's Framing), and after #1542's Slice D2 nothing builds one anywhere.
207///
208/// P7.3 (#1303): builds a [`TomlDocument`] rather than writing text directly
209/// — the caller (`project.rs`) prints it via `emitter::toml_doc::
210/// print_toml_document`. What gets built, in what order, under what gates,
211/// is unchanged; only the construction shape moved from direct `writeln!`
212/// into pushing typed blocks. String-escaping moved with it: every value
213/// below is escaped unconditionally by the printer now, not selectively by
214/// this function (`emitter/toml_doc.rs`'s own module doc has the reasoning).
215pub(crate) fn emit_wrangler_toml(
216    context: &str,
217    // #1944: every binding this Worker declares, [`worker_bindings`]' list —
218    // the same one its `Env` interface and the duplicate check read.
219    bindings: &[WorkerBinding],
220    // v0.10a: every `on cron "expr"` schedule in the context, sorted+deduped.
221    // Sorting is load-bearing, not cosmetic (review of #1192): the caller
222    // walks `table.services`, a `HashMap`, so unsorted input makes
223    // `wrangler.toml` non-reproducible across runs.
224    crons: &[String],
225    // v0.10b/v0.44: every `from queue("name")` service's bound queue name,
226    // sorted+deduped (same reproducibility requirement as `crons`).
227    queues: &[String],
228    // #1890: the merged flag list, [`compatibility_flags`]' first result.
229    // The same for every Worker in the project, so the caller merges (and
230    // warns about duplicates) once, not per Worker.
231    flags: &[String],
232    // State-migrations S3 (#2248): the context's declared class renames and
233    // removals, each emitted as an `exports` tombstone.
234    tombstones: &ClassTombstones,
235) -> TomlDocument {
236    let name = worker_dir_name(context);
237    let mut doc = TomlDocument::new(
238        "Generated by bynkc — do not edit by hand.",
239        vec![
240            TomlEntry::kv("name", TomlValue::str(name)),
241            TomlEntry::kv("main", TomlValue::str("index.ts")),
242            TomlEntry::kv("compatibility_date", TomlValue::str(COMPATIBILITY_DATE)),
243            TomlEntry::kv(
244                "compatibility_flags",
245                TomlValue::Array(flags.iter().map(TomlValue::str).collect()),
246            ),
247        ],
248    );
249
250    let mut class_names: Vec<&str> = Vec::new();
251    for binding in bindings {
252        let name = TomlValue::str(binding.name.clone());
253        let block = match &binding.source {
254            BindingSource::Service { context } => TomlBlock::array_table(
255                "services",
256                vec![
257                    TomlEntry::kv("binding", name),
258                    TomlEntry::kv("service", TomlValue::str(worker_dir_name(context))),
259                ],
260            ),
261            BindingSource::Kv => TomlBlock::array_table(
262                "kv_namespaces",
263                vec![
264                    TomlEntry::kv("binding", name),
265                    TomlEntry::with_comment(
266                        "id",
267                        TomlValue::str(KV_NAMESPACE_ID_PLACEHOLDER),
268                        "set at deploy time",
269                    ),
270                ],
271            ),
272            // S4a (#1966): an instance's own placeholder, named for its binding,
273            // so deploy (#1967) can mint and materialise each one apart.
274            BindingSource::KvInstance { .. } => TomlBlock::array_table(
275                "kv_namespaces",
276                vec![
277                    TomlEntry::kv("binding", name),
278                    TomlEntry::with_comment(
279                        "id",
280                        TomlValue::str(kv_instance_id_placeholder(&binding.name)),
281                        "set at deploy time",
282                    ),
283                ],
284            ),
285            // P5 (#2323): the bucket's derived name, which `bynk deploy`
286            // environment-qualifies; R2 bucket names are scoped to the
287            // account, so the name is a function of the Worker and the
288            // capability alone.
289            BindingSource::R2Instance { jurisdiction, .. } => {
290                let mut entries = vec![
291                    TomlEntry::kv("binding", name),
292                    TomlEntry::kv(
293                        "bucket_name",
294                        TomlValue::str(r2_bucket_name(&worker_dir_name(context), &binding.name)),
295                    ),
296                ];
297                if let Some(jurisdiction) = jurisdiction {
298                    entries.push(TomlEntry::kv(
299                        "jurisdiction",
300                        TomlValue::str(jurisdiction.clone()),
301                    ));
302                }
303                TomlBlock::array_table("r2_buckets", entries)
304            }
305            // P3 (#2342): the limiter's own placeholder `namespace_id`, which
306            // `bynk deploy` allocates in the ledger and materialises; the
307            // limit and the window are configuration, not identity.
308            BindingSource::RateLimitInstance { limit, period, .. } => TomlBlock::array_table(
309                "ratelimits",
310                vec![
311                    TomlEntry::kv("name", name),
312                    TomlEntry::with_comment(
313                        "namespace_id",
314                        TomlValue::str(ratelimit_namespace_placeholder(&binding.name)),
315                        "set at deploy time",
316                    ),
317                    TomlEntry::kv(
318                        "simple",
319                        TomlValue::InlineTable(vec![
320                            ("limit", TomlValue::Int(*limit)),
321                            ("period", TomlValue::Int(*period)),
322                        ]),
323                    ),
324                ],
325            ),
326            // Interop I5 (#1972): the Worker as the declaration names it,
327            // not a project context's `worker_dir_name`.
328            BindingSource::ForeignService { worker, .. } => TomlBlock::array_table(
329                "services",
330                vec![
331                    TomlEntry::kv("binding", name),
332                    TomlEntry::kv("service", TomlValue::str(worker.clone())),
333                ],
334            ),
335            BindingSource::Agent { class_name } | BindingSource::EventsFanout { class_name } => {
336                class_names.push(class_name);
337                TomlBlock::array_table(
338                    "durable_objects.bindings",
339                    vec![
340                        TomlEntry::kv("name", name),
341                        TomlEntry::kv("class_name", TomlValue::str(class_name.clone())),
342                    ],
343                )
344            }
345        };
346        doc.push_block(block);
347    }
348    // #1796: each class is declared in Cloudflare's declarative `exports`
349    // map, not a `[[migrations]]` list. Migrations are picked by tag: Wrangler
350    // uploads only the ones after the Worker's applied tag, so the single
351    // fixed `v1` this used to emit registered the classes of the *first*
352    // deploy and no class added after it (a new agent, or the fan-out class
353    // arriving with a context's first `emit`). `exports` has no tag. On every
354    // deploy Cloudflare compares the declared set with the Worker's
355    // namespaces and creates what's missing, so the config stays a pure
356    // function of the source and the ledger still records nothing (ADR 0194
357    // D1's principle). Wrangler reads `exports` from 4.107.0, which is
358    // [`WRANGLER_MIN`], and `wrangler dev` takes each class's backend from
359    // it too.
360    //
361    // Every class is SQLite-backed (#1779, ADR 0438): the emitted agent uses
362    // only `storage.get`/`storage.put` on its single `"state"` key, which a
363    // SQLite-backed class supports unchanged, and the fan-out class uses no
364    // storage at all. A Worker whose classes an older Bynk created
365    // key-value-backed (`new_classes`, ≤ 0.309.10, Workers Paid only: the
366    // Free plan refuses that backend with code 10097) is refused
367    // (`storage_type_mismatch`, per Cloudflare's `exports` docs), because
368    // Cloudflare can't change a backend in place. That is a documented
369    // pre-1.0 break: such a Worker is torn down and redeployed. A Worker
370    // whose classes the `new_sqlite_classes` migration created takes them
371    // over into `exports` with their state (#1809, live).
372    //
373    // State-migrations S3 (#2248): a class leaves only through a declaration.
374    // `agent New replaces Old` emits a `renamed` tombstone (Cloudflare moves
375    // Old's stored data to New) and `removed agent Old` a `deleted` one (the
376    // data is destroyed, after a named confirmation at deploy). A class that
377    // simply disappears from source still fails the deploy with Cloudflare's
378    // `orphaned_provisioned_namespace` (confirmed live in #1809) — and, before
379    // that, `bynk check` with `bynk.agent.class_removed`. A tombstoned class
380    // gets no binding.
381    for class_name in class_names {
382        doc.push_block(TomlBlock::keyed_table(
383            "exports",
384            class_name.to_string(),
385            vec![
386                TomlEntry::kv("type", TomlValue::str("durable-object")),
387                TomlEntry::kv("storage", TomlValue::str("sqlite")),
388            ],
389        ));
390    }
391    let mut tombstones_sorted: Vec<(&str, Option<&str>)> = tombstones
392        .renamed
393        .iter()
394        .map(|(old, new)| (old.as_str(), Some(new.as_str())))
395        .chain(tombstones.deleted.iter().map(|old| (old.as_str(), None)))
396        .collect();
397    tombstones_sorted.sort();
398    for (old, renamed_to) in tombstones_sorted {
399        let mut entries = vec![TomlEntry::kv("type", TomlValue::str("durable-object"))];
400        match renamed_to {
401            Some(new) => {
402                entries.push(TomlEntry::kv("state", TomlValue::str("renamed")));
403                entries.push(TomlEntry::kv("renamed_to", TomlValue::str(new)));
404            }
405            None => entries.push(TomlEntry::kv("state", TomlValue::str("deleted"))),
406        }
407        doc.push_block(TomlBlock::keyed_table("exports", old.to_string(), entries));
408    }
409
410    // v0.10a: cron triggers. Cloudflare uses a single `[triggers]` table with a
411    // `crons` array aggregating every `on cron` schedule in the context.
412    // Already sorted+deduped by the caller (#1191).
413    if !crons.is_empty() {
414        let quoted = crons.iter().map(TomlValue::str).collect();
415        doc.push_block(TomlBlock::table(
416            "triggers",
417            vec![TomlEntry::kv("crons", TomlValue::Array(quoted))],
418        ));
419    }
420
421    // v0.10b: queue consumers. Each `on queue "name"` becomes a
422    // `[[queues.consumers]]` binding. Already sorted+deduped by the caller
423    // (#1191).
424    for name in queues {
425        doc.push_block(TomlBlock::array_table(
426            "queues.consumers",
427            vec![
428                TomlEntry::kv("queue", TomlValue::str(name.clone())),
429                TomlEntry::kv("max_batch_size", TomlValue::Int(10)),
430            ],
431        ));
432    }
433
434    doc
435}
436
437/// Every binding a Worker's `wrangler.toml` declares, in emission order:
438/// Service Bindings by target, then those to foreign Workers by capability,
439/// then the KV namespaces, then the Durable Objects by class name. [`emit_wrangler_toml`] writes exactly this list,
440/// the Worker's `Env` interface (`emitter::workers`) types it, and the
441/// duplicate check (`bynk_check::worker_bindings`) reads it, so a new kind of
442/// binding is declared, typed and checked from one place (#1944). Each kind
443/// derives its names independently, so two can meet (an agent `Kv` and the KV
444/// namespace are both `KV`); names are never adjusted to avoid that, because
445/// deployed Workers depend on them. Two members with one name would also be
446/// invalid TypeScript in `Env`, so the duplicate check must run first.
447pub(crate) fn worker_bindings(
448    // The context's agents (`UnitTable::agents`' keys), in any order.
449    agents: &[String],
450    consumes: &[String],
451    needs_kv: bool,
452    // The context's derived instances, each its binding with a
453    // `KvInstance` (S4a, #1966) or `ForeignService` (interop I5, #1972)
454    // source, in capability order.
455    instances: &[WorkerBinding],
456    uses_emit: bool,
457) -> Vec<WorkerBinding> {
458    let mut bindings = Vec::new();
459    let mut sorted_consumes: Vec<&String> = consumes.iter().collect();
460    sorted_consumes.sort();
461    bindings.extend(sorted_consumes.into_iter().map(|t| service_binding(t)));
462    // Interop I5 (#1972): Service Bindings to foreign Workers, beside the
463    // ones to project contexts.
464    bindings.extend(
465        instances
466            .iter()
467            .filter(|b| matches!(b.source, BindingSource::ForeignService { .. }))
468            .cloned(),
469    );
470    if needs_kv {
471        bindings.push(kv_binding());
472    }
473    bindings.extend(
474        instances
475            .iter()
476            .filter(|b| matches!(b.source, BindingSource::KvInstance { .. }))
477            .cloned(),
478    );
479    // P5 (#2323): the R2 buckets, after the KV namespaces.
480    bindings.extend(
481        instances
482            .iter()
483            .filter(|b| matches!(b.source, BindingSource::R2Instance { .. }))
484            .cloned(),
485    );
486    // P3 (#2342): the rate limiters, after the buckets.
487    bindings.extend(
488        instances
489            .iter()
490            .filter(|b| matches!(b.source, BindingSource::RateLimitInstance { .. }))
491            .cloned(),
492    );
493    // Events track, slice 0 (spine #936, ADR 0284): a context whose handlers
494    // emit gets its own fan-out DO among the agents' — Cloudflare only cares
495    // that `index.ts` (this Worker's `main`) exports a class with this name,
496    // not which generated file it came from. Sources by provenance, not by
497    // name: an agent that happened to share the fan-out's class name is
498    // still an agent.
499    let mut objects: Vec<(&str, WorkerBinding)> = agents
500        .iter()
501        .map(|class_name| (class_name.as_str(), agent_binding(class_name)))
502        .collect();
503    if uses_emit {
504        objects.push((EVENTS_FANOUT_CLASS_NAME, events_fanout_binding()));
505    }
506    objects.sort_by(|a, b| a.0.cmp(b.0));
507    bindings.extend(objects.into_iter().map(|(_, binding)| binding));
508    bindings
509}
510
511/// #1950: the bindings a handler's `deps.env` type exposes on Workers — the
512/// projection of [`worker_bindings`] handlers reach directly, which
513/// `BindingSource`'s variant docs state per kind. `consumed` is the consumed
514/// contexts the handler calls (#1961), `agents` every local agent when the
515/// handler constructs one (else empty); order as in [`worker_bindings`].
516///
517/// Built by the same constructors, so each name and source is the Worker's
518/// own: a called context is never an adapter (an adapter may not declare a
519/// `service`, `bynk.adapter.disallowed_item`), and the Worker binds every
520/// context any of its bodies calls, so it has a Service Binding; every local
521/// agent has a Durable Object binding. The
522/// KV namespace, the events fan-out and subscribers' Service Bindings never
523/// reach handlers.
524pub(crate) fn handler_bindings(consumed: &[String], agents: &[String]) -> Vec<WorkerBinding> {
525    let mut consumed: Vec<&String> = consumed.iter().collect();
526    consumed.sort();
527    let mut agents: Vec<&String> = agents.iter().collect();
528    agents.sort();
529    consumed
530        .into_iter()
531        .map(|t| service_binding(t))
532        .chain(agents.into_iter().map(|a| agent_binding(a)))
533        .collect()
534}
535
536/// The TypeScript type a binding has on the Worker's `env`, for both its
537/// `Env` interface and a handler's `deps.env`.
538pub(crate) fn binding_ts_type(source: &BindingSource) -> &'static str {
539    match source {
540        BindingSource::Service { .. } | BindingSource::ForeignService { .. } => "__ServiceBinding",
541        BindingSource::Kv | BindingSource::KvInstance { .. } => "__KVNamespace",
542        BindingSource::R2Instance { .. } => "__R2Bucket",
543        BindingSource::RateLimitInstance { .. } => "__RateLimiter",
544        BindingSource::Agent { .. } | BindingSource::EventsFanout { .. } => {
545            "__DurableObjectNamespace"
546        }
547    }
548}
549
550// #1958: the constructors below are the only callers of the two name
551// functions, so every place that declares, types or reads a binding (the
552// emitted `env.X`, the fan-out's routing table, the integration harness)
553// gets its name from the one constructor for its kind. A constructor is
554// `pub(crate)` exactly when something outside this file reads its name.
555
556/// The Service Binding to context `target`'s Worker.
557pub(crate) fn service_binding(target: &str) -> WorkerBinding {
558    WorkerBinding {
559        name: consumed_binding_name(target),
560        source: BindingSource::Service {
561            context: target.to_string(),
562        },
563    }
564}
565
566/// Interop I5 (#1972): the status of the stand-in for a foreign Worker that
567/// `bynk dev` binds by default and the integration test harness binds always.
568/// 503, not a 2xx: the stand-in served nothing, and code that ignores the
569/// status should not mistake it for the Worker's answer.
570pub const FOREIGN_SERVICE_DOUBLE_STATUS: u16 = 503;
571
572/// Interop I5 (#1972): the body of that stand-in's every response, saying
573/// what it is. `bynk dev` adds how to reach the real Worker instead.
574pub fn foreign_service_double_body(binding: &str, worker: &str) -> String {
575    format!(
576        "bynk: `{binding}` is bound to a stand-in for Worker `{worker}`, which is not part of this project."
577    )
578}
579
580/// S4a (#1966): the deploy-time id placeholder of a derived KV instance's
581/// `[[kv_namespaces]]` entry: `KV_SESSIONS` → `<KV_SESSIONS_ID>`.
582pub fn kv_instance_id_placeholder(binding: &str) -> String {
583    format!("<{binding}_ID>")
584}
585
586/// P3 (#2342): the placeholder `namespace_id` of rate limiter `binding`,
587/// which `bynk deploy` replaces with the id the ledger allocates. Miniflare
588/// takes it as it is, so `bynk dev` runs with it.
589pub fn ratelimit_namespace_placeholder(binding: &str) -> String {
590    format!("<{binding}_NAMESPACE>")
591}
592
593/// The bare KV namespace binding the cloudflare adapter's `Kv` reads. Its one
594/// TypeScript read is the default of `WorkersKv`'s `binding` parameter in
595/// `bynk-std`'s `cloudflare.binding.ts`, pinned to `KV_BINDING_NAME` by a test.
596fn kv_binding() -> WorkerBinding {
597    WorkerBinding {
598        name: bynk_check::firstparty::KV_BINDING_NAME.to_string(),
599        source: BindingSource::Kv,
600    }
601}
602
603/// The Durable Object binding of agent `class_name`.
604pub(crate) fn agent_binding(class_name: &str) -> WorkerBinding {
605    WorkerBinding {
606        name: agent_binding_name(class_name),
607        source: BindingSource::Agent {
608            class_name: class_name.to_string(),
609        },
610    }
611}
612
613/// The Durable Object binding of the events fan-out class.
614pub(crate) fn events_fanout_binding() -> WorkerBinding {
615    WorkerBinding {
616        name: agent_binding_name(EVENTS_FANOUT_CLASS_NAME),
617        source: BindingSource::EventsFanout {
618            class_name: EVENTS_FANOUT_CLASS_NAME.to_string(),
619        },
620    }
621}
622
623/// Service Binding identifier for a consumed context: uppercase with
624/// underscores. `commerce.payment` → `COMMERCE_PAYMENT`.
625fn consumed_binding_name(target: &str) -> String {
626    target.replace('.', "_").to_uppercase()
627}
628
629/// Durable Object binding identifier for an agent class. We use the
630/// class name in screaming snake case so handlers can grab it by a
631/// predictable name (`OrderEntity` → `ORDER_ENTITY`), the casing derived
632/// capabilities' bindings share.
633fn agent_binding_name(class_name: &str) -> String {
634    bynk_check::worker_bindings::screaming_snake(class_name)
635}
636
637// ---------------------------------------------------------------------------
638// P7.4 (#1305): structural post-emission patches — closes R7.6 ("downstream
639// consumers couple to nodes, never to emitted text") and R8.20 ("deploy-time
640// placeholders are typed, not textual"). `bynk deploy`/`bynk dev --remote`
641// and `--emit js` both need to rewrite one field of an *already-emitted*
642// `wrangler.toml` after compilation — not build one from scratch (that's
643// `emit_wrangler_toml`/`TomlDocument`, P7.3's own construction-side tree).
644//
645// This is the read-an-existing-document side, and needs a different tool
646// than `TomlDocument`'s own from-scratch builder or a plain `toml::Table`
647// parse: both discard everything they don't model (`toml::Table` round-trips
648// through a `BTreeMap` with no comment/order trivia at all — a first attempt
649// using it here silently dropped the "Generated by bynkc" banner and every
650// inline comment, and re-sorted the whole document alphabetically, on every
651// patch). `toml_edit::DocumentMut` is format-preserving: parse, mutate the
652// one value that actually changed, re-serialise, and everything else survives
653// byte-for-byte. That's the real shape "immune to a reformat, scoped to the
654// actual field" needs — not just "found the right field," but "touched
655// nothing else."
656// ---------------------------------------------------------------------------
657
658/// Set `item`'s value to `s`, preserving whatever leading/trailing
659/// whitespace and comments already decorated it — e.g. the KV namespace
660/// id's own `# set at deploy time` note survives materialisation (stale
661/// once set, but that's the same text a caller would have seen under the
662/// old substring-replace behaviour too; not this slice's concern to change).
663fn set_string_preserving_decor(item: &mut toml_edit::Item, s: &str) {
664    let decor = item
665        .as_value()
666        .map(|v| v.decor().clone())
667        .unwrap_or_default();
668    let mut new_value = toml_edit::Value::from(s);
669    *new_value.decor_mut() = decor;
670    *item = toml_edit::Item::Value(new_value);
671}
672
673/// The deploy-time placeholder a `[[kv_namespaces]]` entry for `binding`
674/// carries until `bynk deploy` materialises it: [`KV_NAMESPACE_ID_PLACEHOLDER`]
675/// for the bare `KV`, [`kv_instance_id_placeholder`] for a derived instance
676/// (S4b, #1967).
677pub fn kv_id_placeholder(binding: &str) -> String {
678    if binding == bynk_check::firstparty::KV_BINDING_NAME {
679        KV_NAMESPACE_ID_PLACEHOLDER.to_string()
680    } else {
681        kv_instance_id_placeholder(binding)
682    }
683}
684
685/// S4b (#1967): the account-facing title stem of a derived instance's KV
686/// namespace, `<worker>-<capability, kebab>`: `shop-cart` and `KV_SESSIONS`
687/// give `shop-cart-sessions`, `KV_PRICE_CACHE` gives `shop-cart-price-cache`.
688/// Read off the binding, which is a function of the capability's name, so a
689/// renamed capability is a new title. `bynk deploy` environment-qualifies it.
690pub fn kv_instance_title(worker: &str, binding: &str) -> String {
691    let prefix = format!("{}_", bynk_check::firstparty::KV_BINDING_NAME);
692    let stem = binding.strip_prefix(&prefix).unwrap_or(binding);
693    format!("{worker}-{}", stem.to_ascii_lowercase().replace('_', "-"))
694}
695
696/// P5 (#2323): the account-facing name of an `R2` instance's bucket,
697/// `<worker>-<capability, kebab>`: `shop-cart` and `R2_ARCHIVES` give
698/// `shop-cart-archives`. R2 bucket names are scoped to the account (not the
699/// Worker), so the Worker's name carries the uniqueness. `bynk deploy`
700/// environment-qualifies it.
701pub fn r2_bucket_name(worker: &str, binding: &str) -> String {
702    let prefix = format!("{}_", bynk_check::firstparty::R2_BINDING_PREFIX);
703    let stem = binding.strip_prefix(&prefix).unwrap_or(binding);
704    format!("{worker}-{}", stem.to_ascii_lowercase().replace('_', "-"))
705}
706
707/// A generated `wrangler.toml`'s `[[r2_buckets]]` entries as `(binding,
708/// bucket_name, jurisdiction)`, in file order; an entry missing a binding or
709/// a name is skipped.
710pub fn r2_bucket_entries(text: &str) -> Result<Vec<(String, String, Option<String>)>, String> {
711    let doc: toml_edit::DocumentMut = text
712        .parse()
713        .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
714    Ok(doc
715        .get("r2_buckets")
716        .and_then(|i| i.as_array_of_tables())
717        .into_iter()
718        .flatten()
719        .filter_map(|t| {
720            Some((
721                t.get("binding")?.as_str()?.to_string(),
722                t.get("bucket_name")?.as_str()?.to_string(),
723                t.get("jurisdiction")
724                    .and_then(|j| j.as_str())
725                    .map(str::to_string),
726            ))
727        })
728        .collect())
729}
730
731/// P5 (#2323): point `[[r2_buckets]]` entries at their deploy-time bucket
732/// names, structurally, by binding: each entry whose binding `names` lists gets
733/// that `bucket_name`. The generated name is the unqualified derived one
734/// ([`r2_bucket_name`]); an environment qualifies it. Unchanged text when
735/// nothing differs, as [`materialise_kv_namespace_ids`].
736pub fn materialise_r2_bucket_names(
737    text: &str,
738    names: &std::collections::BTreeMap<String, String>,
739) -> Result<String, String> {
740    let mut doc: toml_edit::DocumentMut = text
741        .parse()
742        .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
743    let mut changed = false;
744    if let Some(entries) = doc
745        .get_mut("r2_buckets")
746        .and_then(|i| i.as_array_of_tables_mut())
747    {
748        for entry in entries.iter_mut() {
749            let Some(name) = entry
750                .get("binding")
751                .and_then(|b| b.as_str())
752                .and_then(|b| names.get(b))
753                .cloned()
754            else {
755                continue;
756            };
757            if let Some(item) = entry.get_mut("bucket_name")
758                && item.as_str() != Some(name.as_str())
759            {
760                set_string_preserving_decor(item, &name);
761                changed = true;
762            }
763        }
764    }
765    Ok(if changed {
766        doc.to_string()
767    } else {
768        text.to_string()
769    })
770}
771
772/// The bindings of a generated `wrangler.toml`'s `[[kv_namespaces]]` entries
773/// whose `id` is still that binding's deploy-time placeholder
774/// ([`kv_id_placeholder`]), in file order: what [`materialise_kv_namespace_ids`]
775/// would patch. Exposed so a caller can learn which ids it needs before doing
776/// the (possibly fallible) work of finding them, as `bynk dev --remote` does
777/// before it reads the ledger. Empty for a config with no KV, or one already
778/// materialised.
779pub fn kv_bindings_needing_ids(text: &str) -> Result<Vec<String>, String> {
780    let doc: toml_edit::DocumentMut = text
781        .parse()
782        .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
783    Ok(kv_entries(&doc)
784        .filter(|(binding, id)| *id == kv_id_placeholder(binding))
785        .map(|(binding, _)| binding.to_string())
786        .collect())
787}
788
789/// Materialise deploy-time KV namespace ids in a generated `wrangler.toml`'s
790/// text, structurally, by binding (S4b, #1967). Each `[[kv_namespaces]]` entry
791/// whose binding has an id in `ids`, and whose `id` is currently *exactly* that
792/// binding's placeholder ([`kv_id_placeholder`]), gets the id; the decor around
793/// the value (the `# set at deploy time` note) is kept.
794///
795/// Any other state — no `[[kv_namespaces]]` section, an entry already
796/// materialised, a binding `ids` doesn't name — is left alone, and when nothing
797/// changes `text` is returned unchanged (byte-for-byte: the document is never
798/// re-serialised in that case, so there's nothing for a caller to mistake for
799/// a real write). It is not a general "overwrite with the given id" operation:
800/// re-provisioning is a different feature (#1305's Decision B).
801pub fn materialise_kv_namespace_ids(
802    text: &str,
803    ids: &std::collections::BTreeMap<String, String>,
804) -> Result<String, String> {
805    let mut doc: toml_edit::DocumentMut = text
806        .parse()
807        .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
808    let mut changed = false;
809    if let Some(entries) = doc
810        .get_mut("kv_namespaces")
811        .and_then(|i| i.as_array_of_tables_mut())
812    {
813        for entry in entries.iter_mut() {
814            let Some(binding) = entry.get("binding").and_then(|b| b.as_str()) else {
815                continue;
816            };
817            let Some(id) = ids.get(binding) else {
818                continue;
819            };
820            let placeholder = kv_id_placeholder(binding);
821            if let Some(item) = entry.get_mut("id")
822                && item.as_str() == Some(placeholder.as_str())
823            {
824                set_string_preserving_decor(item, id);
825                changed = true;
826            }
827        }
828    }
829    Ok(if changed {
830        doc.to_string()
831    } else {
832        text.to_string()
833    })
834}
835
836/// P3 (#2342): the rate limiters whose `namespace_id` is still its
837/// placeholder, by `name`, in file order.
838pub fn ratelimits_needing_ids(text: &str) -> Result<Vec<String>, String> {
839    let doc: toml_edit::DocumentMut = text
840        .parse()
841        .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
842    let mut out = Vec::new();
843    if let Some(entries) = doc.get("ratelimits").and_then(|i| i.as_array_of_tables()) {
844        for entry in entries.iter() {
845            let Some(name) = entry.get("name").and_then(|b| b.as_str()) else {
846                continue;
847            };
848            if entry.get("namespace_id").and_then(|i| i.as_str())
849                == Some(ratelimit_namespace_placeholder(name).as_str())
850            {
851                out.push(name.to_string());
852            }
853        }
854    }
855    Ok(out)
856}
857
858/// P3 (#2342): write each rate limiter's allocated `namespace_id` over its
859/// placeholder, structurally, by `name`, leaving its `simple = { … }` and
860/// comment as they are. Unchanged text when nothing matches, as
861/// [`materialise_kv_namespace_ids`].
862pub fn materialise_ratelimit_namespace_ids(
863    text: &str,
864    ids: &std::collections::BTreeMap<String, String>,
865) -> Result<String, String> {
866    let mut doc: toml_edit::DocumentMut = text
867        .parse()
868        .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
869    let mut changed = false;
870    if let Some(entries) = doc
871        .get_mut("ratelimits")
872        .and_then(|i| i.as_array_of_tables_mut())
873    {
874        for entry in entries.iter_mut() {
875            let Some(name) = entry.get("name").and_then(|b| b.as_str()) else {
876                continue;
877            };
878            let Some(id) = ids.get(name) else {
879                continue;
880            };
881            let placeholder = ratelimit_namespace_placeholder(name);
882            if let Some(item) = entry.get_mut("namespace_id")
883                && item.as_str() == Some(placeholder.as_str())
884            {
885                set_string_preserving_decor(item, id);
886                changed = true;
887            }
888        }
889    }
890    Ok(if changed {
891        doc.to_string()
892    } else {
893        text.to_string()
894    })
895}
896
897/// Interop I5 (#1972): point Service Bindings at other Workers, structurally,
898/// by binding: each `[[services]]` entry whose binding `targets` names gets
899/// that `service`. `bynk dev` uses it to bind a foreign Worker to its local
900/// stand-in, which runs under a name of its own so that it never answers for
901/// the real Worker in wrangler's shared dev registry. Unchanged text when
902/// nothing matches, as [`materialise_kv_namespace_ids`].
903pub fn retarget_services(
904    text: &str,
905    targets: &std::collections::BTreeMap<String, String>,
906) -> Result<String, String> {
907    let mut doc: toml_edit::DocumentMut = text
908        .parse()
909        .map_err(|e| format!("invalid wrangler.toml: {e}"))?;
910    let mut changed = false;
911    if let Some(entries) = doc
912        .get_mut("services")
913        .and_then(|i| i.as_array_of_tables_mut())
914    {
915        for entry in entries.iter_mut() {
916            let Some(target) = entry
917                .get("binding")
918                .and_then(|b| b.as_str())
919                .and_then(|b| targets.get(b))
920                .cloned()
921            else {
922                continue;
923            };
924            if let Some(item) = entry.get_mut("service")
925                && item.as_str() != Some(target.as_str())
926            {
927                set_string_preserving_decor(item, &target);
928                changed = true;
929            }
930        }
931    }
932    Ok(if changed {
933        doc.to_string()
934    } else {
935        text.to_string()
936    })
937}
938
939/// Each `[[kv_namespaces]]` entry's `(binding, id)`, in file order; an entry
940/// missing either is skipped.
941fn kv_entries(doc: &toml_edit::DocumentMut) -> impl Iterator<Item = (&str, &str)> {
942    doc.get("kv_namespaces")
943        .and_then(|i| i.as_array_of_tables())
944        .into_iter()
945        .flatten()
946        .filter_map(|t| Some((t.get("binding")?.as_str()?, t.get("id")?.as_str()?)))
947}
948
949#[cfg(test)]
950mod patch_tests {
951    use super::*;
952
953    /// A full, realistic `wrangler.toml` — every stanza `emit_wrangler_toml`
954    /// can produce, in its real order, with its real banner and inline
955    /// comment (mirrors `bynkc/tests/fixtures/positive/
956    /// 372_kv_agent_queue_workers/expected/workers/ops-hub/wrangler.toml`
957    /// byte-for-byte). Review of #1305, finding 2: every test below asserts
958    /// **exact** patched text against this fixture, not just "the changed
959    /// field has the right value after a re-parse" — a re-parse is blind by
960    /// construction to whatever the printer discarded, which is exactly how
961    /// finding 1 (the whole-document alphabetical re-sort, dropped banner)
962    /// survived a green suite the first time around.
963    const REPRESENTATIVE: &str = "\
964# Generated by bynkc — do not edit by hand.
965name = \"ops-hub\"
966main = \"index.ts\"
967compatibility_date = \"2026-07-01\"
968compatibility_flags = [\"global_fetch_strictly_public\"]
969
970[[services]]
971binding = \"PAYMENT\"
972service = \"payment\"
973
974[[kv_namespaces]]
975binding = \"KV\"
976id = \"<KV_NAMESPACE_ID>\" # set at deploy time
977
978[[durable_objects.bindings]]
979name = \"JOB_LEDGER\"
980class_name = \"JobLedger\"
981
982[exports.JobLedger]
983type = \"durable-object\"
984storage = \"sqlite\"
985
986[[queues.consumers]]
987queue = \"job-intake\"
988max_batch_size = 10
989";
990
991    fn ids(pairs: &[(&str, &str)]) -> std::collections::BTreeMap<String, String> {
992        pairs
993            .iter()
994            .map(|(b, i)| (b.to_string(), i.to_string()))
995            .collect()
996    }
997
998    /// A Worker with the bare namespace and two derived instances (S4b,
999    /// #1967), as the emitter writes it (fixture `1966_derived_kv_workers`
1000    /// plus the bare `KV`).
1001    const INSTANCES: &str = "\
1002[[kv_namespaces]]
1003binding = \"KV\"
1004id = \"<KV_NAMESPACE_ID>\" # set at deploy time
1005
1006[[kv_namespaces]]
1007binding = \"KV_PRICE_CACHE\"
1008id = \"<KV_PRICE_CACHE_ID>\" # set at deploy time
1009
1010[[kv_namespaces]]
1011binding = \"KV_SESSIONS\"
1012id = \"<KV_SESSIONS_ID>\" # set at deploy time
1013";
1014
1015    /// Interop I5 (#1972): only the named bindings move; the rest of the
1016    /// file, comments included, is untouched, and a no-op is byte-identical.
1017    #[test]
1018    fn retarget_services_moves_only_the_named_bindings() {
1019        let text = "name = \"shop-billing\"\n\n[[services]]\nbinding = \"SHOP_FRONT\"\nservice = \"shop-front\"\n\n[[services]]\nbinding = \"SERVICE_BILLING\"\nservice = \"legacy-billing\" # foreign\n";
1020        let targets = std::collections::BTreeMap::from([(
1021            "SERVICE_BILLING".to_string(),
1022            "bynk-stand-in-shop-billing-legacy-billing".to_string(),
1023        )]);
1024        let out = retarget_services(text, &targets).unwrap();
1025        assert_eq!(
1026            out,
1027            text.replace(
1028                "service = \"legacy-billing\"",
1029                "service = \"bynk-stand-in-shop-billing-legacy-billing\""
1030            )
1031        );
1032        assert_eq!(retarget_services(&out, &targets).unwrap(), out);
1033        assert_eq!(
1034            retarget_services(text, &std::collections::BTreeMap::new()).unwrap(),
1035            text
1036        );
1037        assert!(retarget_services("not [ toml", &targets).is_err());
1038    }
1039
1040    #[test]
1041    fn materialise_kv_namespace_ids_changes_only_the_bare_id() {
1042        let expected = REPRESENTATIVE.replacen("<KV_NAMESPACE_ID>", "abc123", 1);
1043        assert_eq!(
1044            materialise_kv_namespace_ids(REPRESENTATIVE, &ids(&[("KV", "abc123")])).unwrap(),
1045            expected
1046        );
1047        // The `# set at deploy time` note survives — the old text-replace
1048        // behaviour left it in place too (it only replaced the placeholder
1049        // substring, not the whole line), and this slice isn't the one
1050        // deciding whether that note should still be there post-materialisation.
1051        assert!(expected.contains("id = \"abc123\" # set at deploy time"));
1052    }
1053
1054    /// S4b (#1967): each instance's placeholder gets its own id, and only its
1055    /// own: an id for one binding never lands in another's entry.
1056    #[test]
1057    fn materialise_kv_namespace_ids_patches_each_binding_apart() {
1058        let all = materialise_kv_namespace_ids(
1059            INSTANCES,
1060            &ids(&[
1061                ("KV", "bare"),
1062                ("KV_SESSIONS", "sess"),
1063                ("KV_PRICE_CACHE", "price"),
1064            ]),
1065        )
1066        .unwrap();
1067        assert_eq!(
1068            all,
1069            INSTANCES
1070                .replacen("<KV_NAMESPACE_ID>", "bare", 1)
1071                .replacen("<KV_PRICE_CACHE_ID>", "price", 1)
1072                .replacen("<KV_SESSIONS_ID>", "sess", 1)
1073        );
1074        let one =
1075            materialise_kv_namespace_ids(INSTANCES, &ids(&[("KV_SESSIONS", "sess")])).unwrap();
1076        assert_eq!(one, INSTANCES.replacen("<KV_SESSIONS_ID>", "sess", 1));
1077        assert_eq!(
1078            kv_bindings_needing_ids(&one).unwrap(),
1079            ["KV", "KV_PRICE_CACHE"]
1080        );
1081    }
1082
1083    /// A placeholder belongs to its binding: the bare placeholder under an
1084    /// instance's binding is not that instance's to patch.
1085    #[test]
1086    fn materialise_kv_namespace_ids_matches_each_bindings_own_placeholder() {
1087        let text = "[[kv_namespaces]]\nbinding = \"KV_SESSIONS\"\nid = \"<KV_NAMESPACE_ID>\"\n";
1088        assert_eq!(
1089            materialise_kv_namespace_ids(text, &ids(&[("KV_SESSIONS", "sess")])).unwrap(),
1090            text
1091        );
1092        assert!(kv_bindings_needing_ids(text).unwrap().is_empty());
1093    }
1094
1095    #[test]
1096    fn materialise_kv_namespace_ids_is_immune_to_reformatting() {
1097        let text = "[[kv_namespaces]]\nbinding = \"KV\"\nid = \"<KV_NAMESPACE_ID>\" # set at deploy time\n";
1098        let patched = materialise_kv_namespace_ids(text, &ids(&[("KV", "abc123")])).unwrap();
1099        assert_eq!(
1100            patched,
1101            "[[kv_namespaces]]\nbinding = \"KV\"\nid = \"abc123\" # set at deploy time\n"
1102        );
1103    }
1104
1105    #[test]
1106    fn materialise_kv_namespace_ids_is_a_no_op_without_a_kv_namespaces_section() {
1107        let text = "name = \"api\"\nmain = \"index.ts\"\n";
1108        let patched = materialise_kv_namespace_ids(text, &ids(&[("KV", "abc123")])).unwrap();
1109        assert_eq!(patched, text);
1110    }
1111
1112    #[test]
1113    fn materialise_kv_namespace_ids_is_a_no_op_once_already_materialised() {
1114        let text = "[[kv_namespaces]]\nbinding = \"KV\"\nid = \"already-real\"\n";
1115        let patched = materialise_kv_namespace_ids(text, &ids(&[("KV", "abc123")])).unwrap();
1116        assert_eq!(patched, text);
1117    }
1118
1119    #[test]
1120    fn kv_bindings_needing_ids_matches_materialise_kv_namespace_ids_own_gate() {
1121        assert_eq!(
1122            kv_bindings_needing_ids(INSTANCES).unwrap(),
1123            ["KV", "KV_PRICE_CACHE", "KV_SESSIONS"]
1124        );
1125        assert!(
1126            kv_bindings_needing_ids("name = \"api\"\n")
1127                .unwrap()
1128                .is_empty()
1129        );
1130        let already_real = "[[kv_namespaces]]\nbinding = \"KV\"\nid = \"already-real\"\n";
1131        assert!(kv_bindings_needing_ids(already_real).unwrap().is_empty());
1132    }
1133
1134    #[test]
1135    fn kv_instance_title_is_the_worker_then_the_capability_in_kebab_case() {
1136        assert_eq!(
1137            kv_instance_title("shop-cart", "KV_SESSIONS"),
1138            "shop-cart-sessions"
1139        );
1140        assert_eq!(
1141            kv_instance_title("shop-cart", "KV_PRICE_CACHE"),
1142            "shop-cart-price-cache"
1143        );
1144    }
1145
1146    #[test]
1147    fn kv_bindings_needing_ids_rejects_invalid_toml() {
1148        assert!(kv_bindings_needing_ids("not = valid = toml = at = all").is_err());
1149    }
1150
1151    #[test]
1152    fn materialise_kv_namespace_ids_rejects_invalid_toml() {
1153        assert!(
1154            materialise_kv_namespace_ids("not = valid = toml = at = all", &ids(&[("KV", "x")]))
1155                .is_err()
1156        );
1157    }
1158}
1159
1160#[cfg(test)]
1161mod compatibility_flags_tests {
1162    use super::*;
1163
1164    fn strings(names: &[&str]) -> Vec<String> {
1165        names.iter().map(|s| s.to_string()).collect()
1166    }
1167
1168    /// #1890: the default set alone, when the manifest adds nothing.
1169    #[test]
1170    fn the_default_is_global_fetch_strictly_public() {
1171        assert_eq!(
1172            DEFAULT_COMPATIBILITY_FLAGS,
1173            ["global_fetch_strictly_public"]
1174        );
1175        let (flags, duplicates) = compatibility_flags(&[]);
1176        assert_eq!(flags, ["global_fetch_strictly_public"]);
1177        assert!(duplicates.is_empty());
1178    }
1179
1180    /// #1890: the manifest's flags follow the default, in manifest order;
1181    /// names are not checked.
1182    #[test]
1183    fn extra_flags_follow_the_default_in_order() {
1184        let (flags, duplicates) =
1185            compatibility_flags(&strings(&["nodejs_compat", "a_future_flag"]));
1186        assert_eq!(
1187            flags,
1188            [
1189                "global_fetch_strictly_public",
1190                "nodejs_compat",
1191                "a_future_flag"
1192            ]
1193        );
1194        assert!(duplicates.is_empty());
1195    }
1196
1197    /// #1890: a repeat, of a default or of an earlier extra, is dropped and
1198    /// reported once per repeat.
1199    #[test]
1200    fn duplicates_are_dropped_and_reported() {
1201        let (flags, duplicates) = compatibility_flags(&strings(&[
1202            "nodejs_compat",
1203            "global_fetch_strictly_public",
1204            "nodejs_compat",
1205            "nodejs_compat",
1206        ]));
1207        assert_eq!(flags, ["global_fetch_strictly_public", "nodejs_compat"]);
1208        assert_eq!(
1209            duplicates,
1210            [
1211                "global_fetch_strictly_public",
1212                "nodejs_compat",
1213                "nodejs_compat"
1214            ]
1215        );
1216    }
1217}
1218
1219#[cfg(test)]
1220mod binding_tests {
1221    use super::*;
1222    use bynk_check::worker_bindings::duplicate_bindings;
1223
1224    fn names(bindings: &[WorkerBinding]) -> Vec<&str> {
1225        bindings.iter().map(|b| b.name.as_str()).collect()
1226    }
1227
1228    /// Emission order: Service Bindings by target, the KV namespace, then
1229    /// the Durable Objects by class name, the fan-out among them.
1230    #[test]
1231    fn worker_bindings_lists_every_kind_in_emission_order() {
1232        let bindings = worker_bindings(
1233            &[],
1234            &["shop.payment".to_string(), "audit".to_string()],
1235            true,
1236            &[],
1237            true,
1238        );
1239        assert_eq!(
1240            names(&bindings),
1241            ["AUDIT", "SHOP_PAYMENT", "KV", "___EVENTS_FANOUT"]
1242        );
1243        assert!(duplicate_bindings(&bindings).is_empty());
1244    }
1245
1246    /// #1950: the handler projection is drawn from the Worker's own list —
1247    /// each binding identical, name and source, a Service Binding and an
1248    /// agent's alike — and leaves out the KV namespace and the events fan-out.
1249    #[test]
1250    fn handler_bindings_are_a_subset_of_the_worker_bindings() {
1251        let worker = worker_bindings(
1252            &["OrderEntity".to_string()],
1253            &["shop.payment".to_string(), "audit".to_string()],
1254            true,
1255            &[],
1256            true,
1257        );
1258        let handler = handler_bindings(&["shop.payment".to_string()], &["OrderEntity".to_string()]);
1259        assert_eq!(names(&handler), ["SHOP_PAYMENT", "ORDER_ENTITY"]);
1260        for binding in &handler {
1261            assert!(worker.contains(binding), "{binding:?} not in {worker:?}");
1262        }
1263    }
1264
1265    /// A consumed context `kv` and the KV namespace are both `KV`: the
1266    /// later one is paired with the earlier.
1267    #[test]
1268    fn a_shared_name_pairs_the_later_binding_with_the_earlier() {
1269        let bindings = worker_bindings(&[], &["kv".to_string()], true, &[], false);
1270        let duplicates = duplicate_bindings(&bindings);
1271        assert_eq!(duplicates.len(), 1);
1272        let (earlier, later) = duplicates[0];
1273        assert_eq!(
1274            earlier.source,
1275            BindingSource::Service {
1276                context: "kv".to_string()
1277            }
1278        );
1279        assert_eq!(later.source, BindingSource::Kv);
1280    }
1281}