1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
use std::path::{Path, PathBuf};
use crate::paths::ProjectPaths;
/// Distinguishes a commons from a context (and from a test) in the project
/// graph. Tests are a third kind in v0.7.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UnitKind {
Commons,
Context,
Test,
/// v0.16: a `test integration` multi-Worker integration test.
Integration,
/// v0.17: an `adapter` — the host boundary (capability contract + binding).
Adapter,
}
impl UnitKind {
pub fn display(self) -> &'static str {
match self {
UnitKind::Commons => "commons",
UnitKind::Context => "context",
UnitKind::Test => "test",
UnitKind::Integration => "integration test",
UnitKind::Adapter => "adapter",
}
}
}
/// Where a project's `.bynk` files live.
#[derive(Clone)]
pub enum Roots {
/// A single tree walked as one root (in-memory builds and legacy
/// single-file/single-tree inputs).
Single(PathBuf),
/// v0.113 (DECISION S): a project rooted at `project_root`, with a flat
/// `include`/`exclude` layout (`ProjectPaths`). Test-ness is structural (a
/// `suite` declaration), so there is no source/test role split — the tree is
/// walked for `.bynk` files and each declaration is partitioned by kind.
Split {
project_root: PathBuf,
paths: ProjectPaths,
},
}
impl Roots {
/// Every `include` tree this project's roots resolve to, each paired with
/// its project-root-relative `include` prefix — joined onto that tree's
/// (root-relative) `source_path` to build each file's `identity_path`
/// (empty for a single-root project). A file's identity path is relative
/// to the root that contains it.
///
/// R3.9 (#1113): `[paths] include` is no longer capped at a
/// hardcoded primary/secondary pair — every entry becomes its own tree,
/// walked and discovered like any other. `pub` so `bynk-driver` walks
/// exactly the trees `compile_project` would, rather than a
/// hand-duplicated copy that can silently drift from this one.
///
/// Two `include` entries that resolve to the same absolute root (a typo
/// like `["src", "src"]`, or two entries a symlink/`..` makes equal)
/// collapse to that root's *first* occurrence — the old `Roots::resolve`
/// this replaces skipped a secondary tree equal to the primary the same
/// way (`split_mode = src_root != tests_root`); without this a duplicate
/// entry would be walked and parsed twice, producing spurious
/// duplicate-name diagnostics. An empty `include` list falls back to one
/// tree at `project_root` itself, matching `Roots::resolve`'s old
/// `unwrap_or_default()` primary — every caller that reaches `trees()` is
/// meant to go through `ProjectPaths::conventional`/
/// `try_read_project_paths`, which never produce an empty list, but
/// `ProjectPaths`'s fields are `pub` and this keeps a caller that
/// constructs one directly from indexing an empty `Vec`.
pub fn trees(&self) -> Vec<(PathBuf, PathBuf)> {
match self {
Roots::Single(root) => vec![(root.clone(), PathBuf::new())],
Roots::Split {
project_root,
paths,
} => {
if paths.include.is_empty() {
return vec![(project_root.clone(), PathBuf::new())];
}
let mut out: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(paths.include.len());
for p in &paths.include {
let root = project_root.join(p);
if out.iter().any(|(r, _)| *r == root) {
continue;
}
// `.` normalises to an empty prefix — a join identity.
// `ProjectPaths::conventional` pushes `"."` for the flat
// layout (`.bynk` at the project root, no `src/`), and
// `Path::new(".").join("x.bynk")` is `./x.bynk`, not
// `x.bynk` (`Components` keeps the leading `CurDir`), which
// would leak a `./` into every snapshot key and reported
// path.
let prefix = if p.as_path() == Path::new(".") {
PathBuf::new()
} else {
p.clone()
};
out.push((root, prefix));
}
out
}
}
}
/// Where `bynk.schema.lock` lives — distinct from [`Self::trees`]'s
/// per-`include` roots, since a `Split` layout's roots are subdirectories
/// of the project root, but the registry belongs beside `bynk.toml`, one
/// level up, the same place `bynk.deploy.lock` lives.
///
/// #1085 review: used only to give `schema_registry::parse`'s corruption
/// diagnostic a real location again — #1078 made `bynk-emit` disk-free
/// for this file, which cost the absolute path the old message carried.
/// Never used for I/O; `bynk-driver`'s own `schema_lock::lock_path` is
/// the one that actually names the file for its own error messages.
pub fn project_root(&self) -> &Path {
match self {
Roots::Single(root) => root,
Roots::Split { project_root, .. } => project_root,
}
}
/// Absolute subtrees to skip during discovery: the author's `exclude` list
/// plus the tool's own build-output and dependency caches (`out`,
/// `node_modules`), so a project whose `include` is the root does not sweep
/// up generated or vendored files.
pub fn excludes(&self) -> Vec<PathBuf> {
match self {
Roots::Single(_) => Vec::new(),
Roots::Split {
project_root,
paths,
} => {
let mut ex: Vec<PathBuf> =
paths.exclude.iter().map(|p| project_root.join(p)).collect();
for cache in ["out", "node_modules"] {
ex.push(project_root.join(cache));
}
ex
}
}
}
}
/// `bynk-emit`'s `CompileOptions::schema_registry` value — whether a build
/// reconciles `bynk.schema.lock`, and if so, its current content.
///
/// A plain `Option<String>` was rejected for this: "off" and "on, fresh
/// project" would both spell `None`, and a future edit that lost the disk
/// read (a real bug, not a hypothetical — see #1078's history) would still
/// type-check as "on, fresh project" instead of failing to compile. The two
/// states are kept structurally distinct instead.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum SchemaLock {
/// Reconciliation is off for this build — `bynk-emit` never touches
/// `unit_tables`' event shapes against any registry, and emits each
/// event's `schemaVersion` straight from `EventDecl::schema_version()`.
#[default]
Off,
/// On. `existing` is `bynk.schema.lock`'s current content, pre-read by
/// the caller (never by `bynk-emit`). `None` means **verified absent** —
/// the caller checked and there is no lock file yet, so this is the
/// project's first-ever reconciliation and every event baselines
/// silently. It must not mean "content unavailable for some other
/// reason" (a permission error, a read that was skipped) — that would
/// silently re-baseline a real registry's history, exactly the
/// corruption `schema_registry::parse` exists to refuse when the content
/// *is* present but unparseable.
On { existing: Option<String> },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_root_is_one_empty_prefixed_tree() {
let roots = Roots::Single(PathBuf::from("/proj"));
assert_eq!(
roots.trees(),
vec![(PathBuf::from("/proj"), PathBuf::new())]
);
assert!(roots.excludes().is_empty());
}
#[test]
fn split_conventional_two_roots_prefix_by_their_own_name() {
let roots = Roots::Split {
project_root: PathBuf::from("/proj"),
paths: ProjectPaths {
include: vec![PathBuf::from("src"), PathBuf::from("tests")],
exclude: Vec::new(),
},
};
assert_eq!(
roots.trees(),
vec![
(PathBuf::from("/proj/src"), PathBuf::from("src")),
(PathBuf::from("/proj/tests"), PathBuf::from("tests")),
]
);
}
#[test]
fn split_flat_dot_include_normalises_to_an_empty_prefix() {
let roots = Roots::Split {
project_root: PathBuf::from("/proj"),
paths: ProjectPaths {
include: vec![PathBuf::from(".")],
exclude: Vec::new(),
},
};
assert_eq!(
roots.trees(),
vec![(PathBuf::from("/proj"), PathBuf::new())]
);
}
/// R3.9 (#1113): three or more `include` roots are no longer silently
/// dropped past the second — every entry becomes its own walked tree.
#[test]
fn split_three_or_more_include_roots_are_all_resolved() {
let roots = Roots::Split {
project_root: PathBuf::from("/proj"),
paths: ProjectPaths {
include: vec![
PathBuf::from("src"),
PathBuf::from("tests"),
PathBuf::from("examples"),
],
exclude: Vec::new(),
},
};
assert_eq!(
roots.trees(),
vec![
(PathBuf::from("/proj/src"), PathBuf::from("src")),
(PathBuf::from("/proj/tests"), PathBuf::from("tests")),
(PathBuf::from("/proj/examples"), PathBuf::from("examples")),
]
);
}
/// A single-entry `include` collapses to one walked tree — no invented
/// second tree mirroring the first (the pre-R3.9 `Roots::resolve` shape
/// this replaces always did that structurally; `trees()` does it because
/// there is genuinely only one entry to iterate).
#[test]
fn split_single_include_entry_is_one_tree() {
let roots = Roots::Split {
project_root: PathBuf::from("/p"),
paths: ProjectPaths {
include: vec![PathBuf::from("src")],
exclude: Vec::new(),
},
};
assert_eq!(
roots.trees(),
vec![(PathBuf::from("/p/src"), PathBuf::from("src"))]
);
}
/// A duplicate `include` entry (a typo like `["src", "src"]`) collapses to
/// its first occurrence instead of being walked twice — the pre-R3.9
/// `Roots::resolve`'s `split_mode = src_root != tests_root` equality guard,
/// preserved here so `phase_discovery`/`phase_parse` don't produce two
/// `ParsedFile`s per file.
#[test]
fn split_duplicate_include_roots_collapse_to_one_tree() {
let roots = Roots::Split {
project_root: PathBuf::from("/proj"),
paths: ProjectPaths {
include: vec![PathBuf::from("src"), PathBuf::from("src")],
exclude: Vec::new(),
},
};
assert_eq!(
roots.trees(),
vec![(PathBuf::from("/proj/src"), PathBuf::from("src"))]
);
}
/// An empty `include` list (reachable only by constructing `ProjectPaths`
/// directly, bypassing `ProjectPaths::conventional`/
/// `try_read_project_paths`) falls back to one tree at the project root —
/// matching the old `Roots::resolve`'s `unwrap_or_default()` primary —
/// instead of an empty `Vec` that leaves callers indexing `trees[0]` to
/// panic.
#[test]
fn split_empty_include_falls_back_to_project_root() {
let roots = Roots::Split {
project_root: PathBuf::from("/proj"),
paths: ProjectPaths {
include: Vec::new(),
exclude: Vec::new(),
},
};
assert_eq!(
roots.trees(),
vec![(PathBuf::from("/proj"), PathBuf::new())]
);
}
#[test]
fn excludes_always_add_the_tool_caches() {
let roots = Roots::Split {
project_root: PathBuf::from("/proj"),
paths: ProjectPaths {
include: vec![PathBuf::from("src")],
exclude: vec![PathBuf::from("vendor")],
},
};
let ex = roots.excludes();
assert!(ex.contains(&PathBuf::from("/proj/vendor")));
assert!(ex.contains(&PathBuf::from("/proj/out")));
assert!(ex.contains(&PathBuf::from("/proj/node_modules")));
}
}