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