Skip to main content

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}