brink_driver/source_tree.rs
1//! Host-side [`SourceTree`](brink_db::SourceTree) implementations: the real
2//! filesystem and a git revision.
3//!
4//! Consumed by [`crate::discover_native::discover_native`] (issue #1288):
5//! `RealFs` backs a normal native compile (`brink-compiler`'s
6//! `prepare_driver`), `GitRev` backs the git-baseline diff path
7//! (`brink-cli`'s `load_git_baseline`, closing #1224) — see
8//! [`native_source_root`] for how a caller derives the `root` both
9//! constructors need from an entry path (decision-log "Native
10//! source-loading seam: a `SourceTree` trait with a map-backed impl; the
11//! root is caller-supplied", 2026-07-22; issue #1278).
12//!
13//! Both types are host-only (they touch the real filesystem and spawn a
14//! `git` subprocess), which is why they live here rather than in
15//! `brink-db`: `RealFs`/`GitRev` are never constructed on any
16//! wasm-reachable path — `brink-web`'s `compile`/`compile_fragment` build
17//! `brink_source_tree::InMemory` directly and feed it to
18//! `brink_environment::Project::load`, which never touches a
19//! `SourceTree` at all when driven from `Environment`'s inline content;
20//! `brink-compiler`'s `RealFs` branch is CLI-only. (`brink-driver` itself
21//! *is* linked into the wasm build transitively, via `brink-compiler` and
22//! `brink-environment` — it is `RealFs`/`GitRev` construction, not the
23//! crate link, that stays host-only.)
24
25use std::ffi::OsString;
26use std::fs;
27use std::io;
28use std::path::{Path, PathBuf};
29use std::process::Command;
30
31use brink_db::SourceTree;
32use brink_source_tree::Walk;
33
34/// `.brink` is the native surface's source extension (as opposed to ink's
35/// `.ink`) — see `crates/internal/brink-ir/src/hir/lower_native/mod.rs`.
36const NATIVE_EXTENSION: &str = "brink";
37
38/// Real-filesystem [`SourceTree`]: walks a root directory and enumerates
39/// `.brink` keys, keyed by root-relative path. Enumeration goes through the
40/// shared [`brink_source_tree::Walk`], so it never descends into
41/// [`brink_source_tree::IGNORED_DIR_NAMES`] (`target/`, `.git/`,
42/// `node_modules/` — issue #1381 hand-rolled that prune here; issue #1433
43/// moved the enforcement into the walk itself, where it can't be forgotten),
44/// so a stray build-output or dependency tree under `root` is never
45/// enumerated. `read` serves any key lazily off disk — it never eagerly
46/// reads the tree, so one malformed/unreadable file elsewhere under `root`
47/// cannot fail a `read` of an unrelated key (issue #1357).
48///
49/// `list` also reads `root`'s own `brink.toml` (if any) for `[project]
50/// unprune-dirs`, issue #1407's escape hatch for a project that legitimately
51/// keeps `.brink` sources under one of those pruned names — see
52/// [`unprune_dirs`] — and, when a pruned directory shallowly contains a
53/// `.brink` file that `unprune-dirs` didn't name, logs a `tracing::warn!`
54/// naming it (#1407's silent-skip diagnostic) rather than saying nothing.
55/// Neither behavior changes what a *clean* tree with no config or no pruned
56/// sources enumerates.
57///
58/// `list`'s `.brink`-only scope is fixed — there used to be a second,
59/// `.brink` + `.ink` scope reachable via a `RealFs::project` constructor
60/// (issue #1357's CLI producer mount), but every caller of that wider scope
61/// either filtered `list()`'s output back down to `.brink` itself
62/// (`brink-environment`'s `collect_sources`, for a native entry) or never
63/// called `list()` at all (the same function's ink-entry branch, which reads
64/// through the tree via `INCLUDE` BFS instead). The extra `.ink` keys were
65/// therefore never observable through any real call path, so issue #1404
66/// deleted the second scope and collapsed `RealFs::project`'s callers onto
67/// this single `.brink`-only constructor.
68///
69/// Both `list` and `read` resolve against the root this instance was
70/// constructed with — neither takes a `root` parameter (issue #1371:
71/// `SourceTree::list` used to take one, but `RealFs` always ignored it in
72/// favor of its own constructor-held root, while [`GitRev`]'s pre-#1371
73/// `list` used the passed-in `root` *instead of* its own constructor-held
74/// one — two impls silently disagreeing on which root governed the same
75/// call. Dropping the parameter everywhere makes "root is constructor-held"
76/// the only contract left to honor).
77#[derive(Debug, Clone)]
78pub struct RealFs {
79 root: PathBuf,
80}
81
82impl RealFs {
83 /// Construct a `RealFs` seam rooted at `root`, listing `.brink` keys
84 /// only. `read` resolves keys (as returned by `list`) relative to this
85 /// root.
86 #[must_use]
87 pub fn new(root: impl Into<PathBuf>) -> Self {
88 Self { root: root.into() }
89 }
90}
91
92impl SourceTree for RealFs {
93 fn list(&self) -> io::Result<Vec<String>> {
94 let mut keys = Vec::new();
95 let mut walk = Walk::new(&self.root)
96 .allow(unprune_dirs(&self.root))
97 .warn_on_pruned_sources([NATIVE_EXTENSION]);
98 for entry in walk.by_ref() {
99 let entry = entry?;
100 if !entry.is_file() || !is_native(entry.path()) {
101 continue;
102 }
103 let rel = entry
104 .path()
105 .strip_prefix(&self.root)
106 .map_err(|e| io::Error::other(e.to_string()))?;
107 keys.push(to_key(rel));
108 }
109 // `Walk` is pre-order and per-directory sorted, which is not the
110 // same as globally key-sorted (`a.brink` < `a/z.brink`, but the walk
111 // yields `a/`'s contents first) — and `list`'s contract is the
112 // latter, so sort the collected keys.
113 keys.sort();
114
115 // Issue #1407's diagnostic half: name every pruned directory that
116 // plausibly held a `.brink` source the author expected discovery to
117 // find, rather than leaving it silently invisible. `warn`, never an
118 // error — this is advisory (the prune itself is still correct
119 // behavior by default), and `list`'s contract is to enumerate keys,
120 // not to fail because a *different* directory looked suspicious.
121 for pruned in walk.pruned_with_sources() {
122 let name = pruned.file_name().unwrap_or_default().to_string_lossy();
123 tracing::warn!(
124 "discovery pruned {} — it contains .{NATIVE_EXTENSION} file(s) that were not \
125 loaded. If this is intentional source, add `unprune-dirs = [\"{name}\"]` under \
126 `[project]` in {CONFIG_FILE_NAME}.",
127 pruned.display(),
128 );
129 }
130
131 Ok(keys)
132 }
133
134 fn read(&self, key: &str) -> io::Result<String> {
135 fs::read_to_string(self.root.join(key))
136 }
137}
138
139/// The `brink.toml` key this looks for. Kept local rather than re-exporting
140/// `brink_project_config::CONFIG_FILE_NAME` under a new name — this module
141/// already spells the literal out in doc comments elsewhere, and importing
142/// the constant here keeps the diagnostic message and the actual read below
143/// from drifting apart.
144const CONFIG_FILE_NAME: &str = brink_project_config::CONFIG_FILE_NAME;
145
146/// Best-effort read of `root`'s own `brink.toml` for `[project]
147/// unprune-dirs` (issue #1407's escape hatch) — never `root`'s ancestors: by
148/// the time a `RealFs` is constructed, `root` already **is** the directory
149/// [`native_source_root`] resolved a discovered config to (or the entry's
150/// own directory, if none exists), so a direct `root`-relative read lands on
151/// the same file `brink_environment::Project::load`'s own ancestor
152/// walk-up will find moments later, without re-implementing that walk here.
153///
154/// Failures are swallowed here (no file, malformed TOML, an out-of-range
155/// `unprune-dirs` value): reporting them is not this function's job — the
156/// canonical parse in `Project::load`'s `resolve_options` runs over the very
157/// same file and raises the real `ConfigError`/warnings to the caller. If
158/// this best-effort read can't get a clean value, `list()` behaves exactly
159/// as if the file never set `unprune-dirs` at all — the standing prune
160/// policy still applies, it just isn't widened.
161fn unprune_dirs(root: &Path) -> Vec<OsString> {
162 let Ok(text) = fs::read_to_string(root.join(CONFIG_FILE_NAME)) else {
163 return Vec::new();
164 };
165 let Ok((config, _warnings)) = brink_project_config::parse_str_at(CONFIG_FILE_NAME, &text)
166 else {
167 return Vec::new();
168 };
169 config.unprune_dirs.into_iter().map(Into::into).collect()
170}
171
172/// Join a relative path's components with `/`, so keys are stable across
173/// platforms (Windows' `\` component separator would otherwise leak into
174/// module-path derivation downstream).
175fn to_key(rel: &Path) -> String {
176 rel.components()
177 .map(|c| c.as_os_str().to_string_lossy())
178 .collect::<Vec<_>>()
179 .join("/")
180}
181
182/// Whether `path` is a native `.brink` source file — an extension test only,
183/// matching `brink-db`'s internal `file_language` classification. This is
184/// the dispatch every discovery caller (`brink-compiler`'s `prepare_driver`,
185/// `brink-cli`'s `load_git_baseline`) uses to pick [`discover_native`] +
186/// [`RealFs`]/[`GitRev`] (native) over [`crate::Driver::discover`] (ink,
187/// `INCLUDE` BFS).
188///
189/// [`discover_native`]: crate::discover_native::discover_native
190#[must_use]
191pub fn is_native(path: &Path) -> bool {
192 path.extension().is_some_and(|ext| ext == NATIVE_EXTENSION)
193}
194
195/// Resolve a project's source root from an entry file's path: the directory
196/// containing the nearest `brink.toml` found by walking up from the entry
197/// (`brink-project-config`'s discovery), or — if none exists — the entry's
198/// own directory (decision-log 2026-07-22 "native module identity ...
199/// source root": the explicit, documented single-file-project mode, not a
200/// silent fallback).
201///
202/// Despite the name, this is no longer native-only: `prepare_driver`
203/// (`brink-compiler/src/driver.rs`) also calls it for an `.ink` entry, to
204/// register `ProjectDb::set_ink_root` (issue #1696) — the same root
205/// discovery a native compile already used, reused so `hir::
206/// root_content_scope_path`'s qualifier is a root-relative key rather than
207/// the entry's raw spelling.
208///
209/// A *relative* multi-component `entry_dir` like `chapters` has
210/// `Path::parent` return `Some("")` — not `None` — once the walk-up inside
211/// [`brink_project_config::find_config`] reaches it, so `find_config` can
212/// return a *bare* `brink.toml` (found relative to the process cwd, e.g.
213/// `PathBuf::from("brink.toml")`) whose own `.parent()` is that same empty
214/// path. Naively filtering that empty parent out and falling back to
215/// `entry_dir` (as this function used to) silently discards a config that
216/// *was* found — so `brink ide check -e chapters/story.ink`, run from a cwd
217/// containing `brink.toml`, missed the config and mis-rooted at `chapters`
218/// instead of the true root (review finding on #1403/PR #1412). An empty
219/// parent always means "found in the directory the walk started from" —
220/// i.e. the current directory — so it maps to `Path::new(".")` instead of
221/// being discarded.
222///
223/// A *relative* `entry_dir` still can't see past the process's cwd, though,
224/// even after that fix: `find_config`'s walk-up is `Path::parent`, which is
225/// purely lexical — for a relative path it bottoms out at `""` (cwd itself)
226/// and has no way to synthesize a `".."` to keep climbing, unlike an
227/// *absolute* `entry_dir`, whose `Path::parent` chain walks all the way to
228/// the filesystem root for free. So `brink compile story.ink`, run from a
229/// cwd whose `brink.toml` lives one directory *above* cwd (not in cwd
230/// itself), never even attempts that ancestor — `entry_dir` is `"."`,
231/// `find_config` checks `"./brink.toml"` and the bare `"brink.toml"` (both
232/// resolve to the same cwd-relative candidate) and then has nowhere lexical
233/// left to go, so it returns `None` and this function falls back to
234/// `entry_dir` itself, mis-rooting at cwd instead of the true project root
235/// (issue #1413) — even though the identical project laid out with an
236/// absolute or `chapters/`-nested entry resolves correctly. When the
237/// relative walk comes up empty, retry once from an absolutized
238/// `entry_dir` so a `brink.toml` above cwd is still found, exactly as it
239/// would be for an absolute-path entry. The retry is skipped whenever
240/// absolutizing `entry_dir` changes nothing (i.e. `entry_dir` was already
241/// absolute *and* already normalized — the first pass already walked to the
242/// filesystem root, so a byte-identical second walk would be wasted work)
243/// and never runs when the relative walk already found an
244/// answer — so the fast, already-correct relative result (including the
245/// `"."`-for-cwd spelling [`GitRev::repo_relative`](GitRev)'s shortcut
246/// depends on, per the #1403/PR #1412 trap) is untouched in the common
247/// case.
248///
249/// Neither pass climbs past a workspace/git boundary (#1425):
250/// [`brink_project_config::find_config`] itself now stops ascending once it
251/// passes a directory containing a `.git` entry, so this function can never
252/// resolve `root` to somewhere outside the repository the entry lives in —
253/// closing the gap the absolutized retry above opened (an absolute walk used
254/// to reach the filesystem root "for free," which meant a stray `brink.toml`
255/// anywhere above the repo — even in `$HOME` — could get picked up). A
256/// `brink.toml` sitting outside a repository entirely (as opposed to merely
257/// above `entry_dir` but still inside it) is now treated exactly like no
258/// `brink.toml` at all: this function falls back to `entry_dir`. And even a
259/// VCS-less tree, which has no `.git` boundary to stop at, no longer climbs
260/// "to the filesystem root for free" as the absolute-path reasoning above
261/// describes: `find_config`'s `MAX_ANCESTOR_DEPTH` cap (#1435) bounds *every*
262/// walk — relative-retry or absolute — regardless of whether a `.git` is
263/// ever found.
264#[must_use]
265pub fn native_source_root(entry: &Path) -> PathBuf {
266 native_source_root_inner(entry, false).0
267}
268
269/// Like [`native_source_root`], but additionally reports warnings when
270/// the bounded walk stepped over a `brink.toml`. Returns both the resolved
271/// root and any discovery warnings that should be reported to the user.
272#[must_use]
273pub fn native_source_root_with_warnings(
274 entry: &Path,
275) -> (PathBuf, Vec<brink_project_config::ConfigWarning>) {
276 native_source_root_inner(entry, true)
277}
278
279/// Shared implementation behind [`native_source_root`] and
280/// [`native_source_root_with_warnings`]. `want_warnings` gates whether the
281/// bounded past-the-boundary probe inside
282/// [`brink_project_config::find_config_with_warnings`] runs at all —
283/// mirroring that function's own `find_config`/`find_config_with_warnings`
284/// split (review finding on #1712: `native_source_root` used to always
285/// delegate to the warnings-producing path and just discard the result, so
286/// every no-warning caller paid for up to [`brink_project_config::
287/// MAX_ANCESTOR_DEPTH`] extra `is_file` calls per miss for nothing).
288fn native_source_root_inner(
289 entry: &Path,
290 want_warnings: bool,
291) -> (PathBuf, Vec<brink_project_config::ConfigWarning>) {
292 let entry_dir = entry
293 .parent()
294 .filter(|p| !p.as_os_str().is_empty())
295 .unwrap_or_else(|| Path::new("."));
296
297 let (root, mut warnings) = source_root_from_config(entry_dir, want_warnings);
298 if let Some(root) = root {
299 return (root, warnings);
300 }
301
302 let entry_dir_abs = std::path::absolute(entry_dir).unwrap_or_else(|_| entry_dir.to_path_buf());
303 if entry_dir_abs != entry_dir {
304 let (root, abs_warnings) = source_root_from_config(&entry_dir_abs, want_warnings);
305 warnings.extend(abs_warnings);
306 if let Some(root) = root {
307 return (root, warnings);
308 }
309 }
310
311 (entry_dir.to_path_buf(), warnings)
312}
313
314/// Walk up from `entry_dir` for a `brink.toml` via
315/// [`brink_project_config::find_config`] (`want_warnings = false`) or
316/// [`brink_project_config::find_config_with_warnings`] (`want_warnings =
317/// true`), returning the directory that governs it — `None` when no config
318/// is found anywhere above `entry_dir`. An empty parent (a bare
319/// `brink.toml` found exactly at `entry_dir`, e.g. the process cwd for a
320/// relative walk) maps to `Path::new(".")` rather than being discarded —
321/// see [`native_source_root`]'s doc for why.
322fn source_root_from_config(
323 entry_dir: &Path,
324 want_warnings: bool,
325) -> (Option<PathBuf>, Vec<brink_project_config::ConfigWarning>) {
326 let (config_path, warnings) = if want_warnings {
327 brink_project_config::find_config_with_warnings(entry_dir)
328 } else {
329 (brink_project_config::find_config(entry_dir), Vec::new())
330 };
331 let root = config_path.map(|path| {
332 let parent = path.parent().unwrap_or_else(|| Path::new(""));
333 if parent.as_os_str().is_empty() {
334 PathBuf::from(".")
335 } else {
336 parent.to_path_buf()
337 }
338 });
339 (root, warnings)
340}
341
342/// Convert `path` to the root-relative key [`RealFs`]/[`GitRev`] would key it
343/// under — the inverse of "join `root` with a key," used by discovery
344/// callers to look up the `FileId` a just-discovered entry landed on. Both
345/// `root` and `path` are lexically absolutized first (via
346/// [`std::path::absolute`], which resolves `.`/`..` without touching the
347/// filesystem) so the strip is exact regardless of how each was spelled
348/// relative to the process's cwd — e.g. `root = "."`, `path = "story/main.brink"`
349/// and `root = "story"`, `path = "./story/main.brink"` both key as
350/// `"story/main.brink"`.
351#[must_use]
352pub fn relative_key(root: &Path, path: &Path) -> String {
353 let root_abs = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf());
354 let path_abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
355 let rel = path_abs.strip_prefix(&root_abs).unwrap_or(&path_abs);
356 to_key(rel)
357}
358
359/// Git-revision [`SourceTree`]: reads keys/contents from a git revision via
360/// `git show <rev>:<path>` — the fix path for #1224's baseline-diff bug
361/// (`brink ide effects-diff --rev` reading nothing because the old
362/// closure-only seam couldn't enumerate).
363///
364/// `git` runs with `repo_dir` as its working directory. `root` (a path
365/// relative to `repo_dir`, `.` for the whole repo) is stored at
366/// construction for the same reason `RealFs` stores its root: neither `list`
367/// nor `read` takes a `root` parameter (issue #1371), so both must already
368/// know how to turn a root-relative key back into a repo-relative git
369/// pathspec from `self.root` alone.
370#[derive(Debug, Clone)]
371pub struct GitRev {
372 repo_dir: PathBuf,
373 rev: String,
374 root: PathBuf,
375}
376
377impl GitRev {
378 /// Construct a `GitRev` seam that reads `root` (relative to `repo_dir`)
379 /// at revision `rev`.
380 #[must_use]
381 pub fn new(
382 repo_dir: impl Into<PathBuf>,
383 rev: impl Into<String>,
384 root: impl Into<PathBuf>,
385 ) -> Self {
386 Self {
387 repo_dir: repo_dir.into(),
388 rev: rev.into(),
389 root: root.into(),
390 }
391 }
392
393 /// The repo-relative pathspec for `key` (root-relative), i.e. `root`
394 /// joined with `key` and normalized to `/`-separated components.
395 fn repo_relative(&self, key: &str) -> String {
396 if self.root == Path::new(".") {
397 key.to_string()
398 } else {
399 format!("{}/{key}", to_key(&self.root))
400 }
401 }
402}
403
404impl SourceTree for GitRev {
405 fn list(&self) -> io::Result<Vec<String>> {
406 let pathspec = to_key(&self.root);
407 let output = Command::new("git")
408 .current_dir(&self.repo_dir)
409 .args([
410 "ls-tree",
411 "-r",
412 "--name-only",
413 "--full-name",
414 &self.rev,
415 "--",
416 ])
417 .arg(&pathspec)
418 .output()?;
419 if !output.status.success() {
420 return Err(io::Error::other(format!(
421 "git ls-tree {} -- {pathspec} failed: {}",
422 self.rev,
423 String::from_utf8_lossy(&output.stderr)
424 )));
425 }
426 let text = String::from_utf8(output.stdout)
427 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
428
429 let prefix = if pathspec == "." {
430 String::new()
431 } else {
432 format!("{pathspec}/")
433 };
434 let mut keys: Vec<String> = text
435 .lines()
436 .filter(|line| line.ends_with(&format!(".{NATIVE_EXTENSION}")))
437 .map(|line| line.strip_prefix(&prefix).unwrap_or(line).to_string())
438 .collect();
439 keys.sort();
440 Ok(keys)
441 }
442
443 fn read(&self, key: &str) -> io::Result<String> {
444 let spec = format!("{}:{}", self.rev, self.repo_relative(key));
445 let output = Command::new("git")
446 .current_dir(&self.repo_dir)
447 .args(["show", &spec])
448 .output()?;
449 if output.status.success() {
450 String::from_utf8(output.stdout)
451 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
452 } else {
453 Err(io::Error::new(
454 io::ErrorKind::NotFound,
455 format!("{key} not in {}", self.rev),
456 ))
457 }
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use std::process::Command as StdCommand;
465 use std::sync::atomic::{AtomicU64, Ordering};
466 use std::time::{SystemTime, UNIX_EPOCH};
467
468 /// A fresh, empty temp directory under the OS temp dir, unique per call
469 /// (pid + a monotonic counter + a nanosecond timestamp) so parallel test
470 /// runs never collide. No external crate needed for this — the tests
471 /// clean up after themselves.
472 fn temp_dir(label: &str) -> PathBuf {
473 static COUNTER: AtomicU64 = AtomicU64::new(0);
474 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
475 let nanos = SystemTime::now()
476 .duration_since(UNIX_EPOCH)
477 .map(|d| d.as_nanos())
478 .unwrap_or_default();
479 let dir = std::env::temp_dir().join(format!(
480 "brink-source-tree-test-{label}-{}-{n}-{nanos}",
481 std::process::id()
482 ));
483 fs::create_dir_all(&dir).expect("create temp dir");
484 dir
485 }
486
487 /// `RealFs::list` over a tempdir enumerates only `.brink` files, and
488 /// returns them in sorted order even though they are created on disk in
489 /// a hostile (non-sorted) order.
490 #[test]
491 fn real_fs_list_enumerates_only_brink_files_in_sorted_order() {
492 let root = temp_dir("realfs-list");
493
494 // Created in a hostile (non-sorted) order, interleaved with
495 // non-`.brink` files and a nested directory.
496 fs::write(root.join("z.brink"), "-- z --").expect("write z.brink");
497 fs::write(root.join("z.ink"), "not brink").expect("write z.ink");
498 fs::write(root.join("README.md"), "not brink").expect("write README.md");
499 fs::create_dir_all(root.join("nested")).expect("mkdir nested");
500 fs::write(root.join("nested/a.brink"), "-- nested/a --").expect("write nested/a.brink");
501 fs::write(root.join("a.brink"), "-- a --").expect("write a.brink");
502
503 let tree = RealFs::new(&root);
504 let keys = tree.list().expect("list succeeds");
505
506 assert_eq!(keys, vec!["a.brink", "nested/a.brink", "z.brink"]);
507
508 fs::remove_dir_all(&root).expect("cleanup temp dir");
509 }
510
511 /// `RealFs::read` round-trips exactly the bytes written to disk, using a
512 /// key as returned by `list`.
513 #[test]
514 fn real_fs_read_round_trips() {
515 let root = temp_dir("realfs-read");
516 fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
517
518 let tree = RealFs::new(&root);
519 let keys = tree.list().expect("list succeeds");
520 assert_eq!(keys, vec!["main.brink"]);
521
522 let source = tree.read(&keys[0]).expect("read succeeds");
523 assert_eq!(source, "flow main() {}");
524
525 fs::remove_dir_all(&root).expect("cleanup temp dir");
526 }
527
528 /// An empty root directory lists as empty, not an error.
529 #[test]
530 fn real_fs_list_empty_dir_is_ok_empty() {
531 let root = temp_dir("realfs-empty");
532
533 let tree = RealFs::new(&root);
534 let keys = tree.list().expect("list succeeds");
535
536 assert_eq!(keys, Vec::<String>::new());
537
538 fs::remove_dir_all(&root).expect("cleanup temp dir");
539 }
540
541 // ── ignored-directory pruning (issue #1381) ─────────────────────────
542
543 /// `RealFs::list` never descends into a `target/` subtree — a `.brink`
544 /// file inside it is never enumerated, matching `.git/` and
545 /// `node_modules/` in scope. Proves the walk *prunes* the directory
546 /// (rather than merely filtering matched keys after the fact): the
547 /// fixture also plants a sibling `.brink` file directly under `target/`
548 /// to guarantee a bug that stopped pruning at the top level, but still
549 /// walked in, would be caught.
550 #[test]
551 fn real_fs_list_skips_ignored_dirs() {
552 let root = temp_dir("realfs-ignored-dirs");
553
554 fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
555 fs::create_dir_all(root.join("target/debug")).expect("mkdir target/debug");
556 fs::write(root.join("target/stray.brink"), "-- stray --").expect("write target/stray");
557 fs::write(root.join("target/debug/build.brink"), "-- build --")
558 .expect("write target/debug/build");
559 fs::create_dir_all(root.join(".git/objects")).expect("mkdir .git/objects");
560 fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write .git/HEAD");
561 fs::write(root.join(".git/objects/pack.brink"), "-- pack --")
562 .expect("write .git/objects/pack.brink");
563 fs::create_dir_all(root.join("node_modules/some-pkg")).expect("mkdir node_modules");
564 fs::write(root.join("node_modules/some-pkg/index.brink"), "-- pkg --")
565 .expect("write node_modules/some-pkg/index.brink");
566
567 let tree = RealFs::new(&root);
568 let keys = tree.list().expect("list succeeds");
569
570 assert_eq!(
571 keys,
572 vec!["main.brink"],
573 "target/, .git/, and node_modules/ must be pruned entirely"
574 );
575
576 fs::remove_dir_all(&root).expect("cleanup temp dir");
577 }
578
579 // ── unprune-dirs escape hatch (issue #1407) ─────────────────────────
580
581 /// A `brink.toml` with `[project] unprune-dirs = ["node_modules"]` sat
582 /// beside `root` admits a `.brink` file inside `node_modules/` that
583 /// would otherwise be pruned entirely — while `target/`, not named by
584 /// `unprune-dirs`, stays pruned.
585 #[test]
586 fn real_fs_list_unprune_dirs_admits_a_named_ignored_dir_only() {
587 let root = temp_dir("realfs-unprune-dirs");
588
589 fs::write(
590 root.join("brink.toml"),
591 "[project]\nunprune-dirs = [\"node_modules\"]\n",
592 )
593 .expect("write brink.toml");
594 fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
595 fs::create_dir_all(root.join("node_modules/vendor-ink")).expect("mkdir node_modules");
596 fs::write(
597 root.join("node_modules/vendor-ink/lib.brink"),
598 "flow lib() {}",
599 )
600 .expect("write node_modules/vendor-ink/lib.brink");
601 fs::create_dir_all(root.join("target")).expect("mkdir target");
602 fs::write(root.join("target/stray.brink"), "-- stray --")
603 .expect("write target/stray.brink");
604
605 let tree = RealFs::new(&root);
606 let keys = tree.list().expect("list succeeds");
607
608 assert_eq!(
609 keys,
610 vec!["main.brink", "node_modules/vendor-ink/lib.brink"],
611 "unprune-dirs must admit node_modules/ specifically, target/ must stay pruned"
612 );
613
614 fs::remove_dir_all(&root).expect("cleanup temp dir");
615 }
616
617 /// With no `brink.toml` at all, `RealFs::list` behaves exactly as before
618 /// #1407 — the escape-hatch config read is best-effort and must not
619 /// change behavior (or fail `list`) when there is nothing to read.
620 #[test]
621 fn real_fs_list_with_no_brink_toml_still_prunes_normally() {
622 let root = temp_dir("realfs-unprune-dirs-no-config");
623
624 fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
625 fs::create_dir_all(root.join("node_modules")).expect("mkdir node_modules");
626 fs::write(root.join("node_modules/stray.brink"), "-- stray --")
627 .expect("write node_modules/stray.brink");
628
629 let tree = RealFs::new(&root);
630 let keys = tree.list().expect("list succeeds");
631
632 assert_eq!(keys, vec!["main.brink"]);
633
634 fs::remove_dir_all(&root).expect("cleanup temp dir");
635 }
636
637 /// A malformed `brink.toml` (invalid `dialect` value) must not fail
638 /// `RealFs::list` — the best-effort escape-hatch read swallows the parse
639 /// error and behaves as if `unprune-dirs` were unset; the *real*
640 /// `ConfigError` for this file is still raised later, by
641 /// `brink_environment::Project::load`'s canonical parse, not silently
642 /// dropped by this crate.
643 #[test]
644 fn real_fs_list_tolerates_a_malformed_brink_toml() {
645 let root = temp_dir("realfs-unprune-dirs-malformed-config");
646
647 fs::write(
648 root.join("brink.toml"),
649 "[project]\ndialect = \"sideways\"\n",
650 )
651 .expect("write malformed brink.toml");
652 fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
653 fs::create_dir_all(root.join("node_modules")).expect("mkdir node_modules");
654 fs::write(root.join("node_modules/stray.brink"), "-- stray --")
655 .expect("write node_modules/stray.brink");
656
657 let tree = RealFs::new(&root);
658 let keys = tree
659 .list()
660 .expect("list must succeed despite a malformed brink.toml");
661
662 assert_eq!(
663 keys,
664 vec!["main.brink"],
665 "no unprune-dirs could be read from the malformed file, so node_modules/ \
666 stays pruned, same as no config at all"
667 );
668
669 fs::remove_dir_all(&root).expect("cleanup temp dir");
670 }
671
672 /// `RealFs::list` excludes `.ink` and `brink.toml` keys even when they
673 /// sit alongside `.brink` files — `discover_native`/`EditOverlay` must
674 /// never see them, and `read` still serves them (see below).
675 #[test]
676 fn real_fs_native_list_still_excludes_ink_and_config() {
677 let root = temp_dir("realfs-native-scope");
678
679 fs::write(root.join("a.brink"), "-- a --").expect("write a.brink");
680 fs::write(root.join("main.ink"), "-> END\n").expect("write main.ink");
681 fs::write(root.join("brink.toml"), "[project]\n").expect("write brink.toml");
682
683 let tree = RealFs::new(&root);
684 let keys = tree.list().expect("list succeeds");
685
686 assert_eq!(keys, vec!["a.brink"]);
687
688 // `read` carries no equivalent scoping (brink-source-tree's "policy
689 // asymmetry" doc section): a `.brink`-scoped `list()` still leaves
690 // `read` willing to serve the non-native keys sitting right next to
691 // it, because `find_config_in_tree`'s ancestor probe depends on
692 // exactly that.
693 assert_eq!(
694 tree.read("brink.toml").expect("read is not list-scoped"),
695 "[project]\n"
696 );
697 assert_eq!(
698 tree.read("main.ink").expect("read is not list-scoped"),
699 "-> END\n"
700 );
701
702 fs::remove_dir_all(&root).expect("cleanup temp dir");
703 }
704
705 /// `RealFs::list` takes no `root` parameter at all (issue #1371) and
706 /// always walks the root it was constructed with — the CLI producer
707 /// mount (`brink_environment::Project::load`) calls `list` with no
708 /// arguments at all now, the #1312 "tree is rooted at `.`" convention
709 /// realized as "there is nothing else to pass," and must still see the
710 /// real keys.
711 #[test]
712 fn real_fs_list_uses_only_its_constructor_root() {
713 let root = temp_dir("realfs-list-constructor-root");
714 fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
715
716 let tree = RealFs::new(&root);
717 let keys = tree.list().expect("list succeeds");
718
719 assert_eq!(keys, vec!["main.brink"]);
720
721 fs::remove_dir_all(&root).expect("cleanup temp dir");
722 }
723
724 /// `RealFs::read` is lazy per-key: `list` never reads file contents, so
725 /// an unrelated file elsewhere under root that is not valid UTF-8 does
726 /// not fail `list`, and does not fail `read` of a *different*,
727 /// well-formed key (issue #1357's core fix — a whole-tree eager drain
728 /// would have failed both).
729 #[test]
730 fn real_fs_read_is_lazy_so_an_unrelated_malformed_file_does_not_fail_other_reads() {
731 let root = temp_dir("realfs-lazy-read");
732 fs::write(root.join("good.brink"), "flow main() {}").expect("write good.brink");
733 // Invalid UTF-8 bytes with a `.brink` extension — would fail
734 // `fs::read_to_string` if ever read.
735 fs::write(root.join("bad.brink"), [0xFF, 0xFE, 0xFD]).expect("write bad.brink");
736
737 let tree = RealFs::new(&root);
738 let keys = tree.list().expect("list succeeds without reading contents");
739 assert_eq!(keys, vec!["bad.brink", "good.brink"]);
740
741 let source = tree
742 .read("good.brink")
743 .expect("reading an unrelated, well-formed key must not be affected");
744 assert_eq!(source, "flow main() {}");
745
746 fs::remove_dir_all(&root).expect("cleanup temp dir");
747 }
748
749 /// `RealFs::read` resolves a key that escapes the root (a leading `..`
750 /// segment) by joining it onto the root and reading through to disk —
751 /// the read-through behavior an ink `INCLUDE` above the resolved project
752 /// root needs (issue #1356's regression, preserved by #1357's
753 /// `DrainedRoot` replacement).
754 #[test]
755 fn real_fs_read_resolves_a_key_that_escapes_the_root() {
756 let wrapper = temp_dir("realfs-escape-root");
757 let root = wrapper.join("proj");
758 fs::create_dir_all(&root).expect("mkdir proj");
759 fs::write(wrapper.join("shared.ink"), "Shared content.\n").expect("write shared.ink");
760
761 let tree = RealFs::new(&root);
762 let source = tree
763 .read("../shared.ink")
764 .expect("read resolves an above-root key relative to the constructed root");
765
766 assert_eq!(source, "Shared content.\n");
767
768 fs::remove_dir_all(&wrapper).expect("cleanup temp dir");
769 }
770
771 /// Issue #1387 (2/3): `find_config_in_tree`'s #1370 direct probe (no
772 /// `list()` walk — see its doc comment) works by calling `RealFs::read`
773 /// on each `{ancestor}/brink.toml` candidate and treating anything but
774 /// `NotFound` as "found". `find_config_in_tree_reports_found_when_the_
775 /// candidate_read_errors_non_not_found` (brink-project-config) pins that
776 /// contract against a hand-written mock `SourceTree`; this pins the same
777 /// contract against a *real* `RealFs`, with an actual symlink: a
778 /// `brink.toml` that is a symlink to a real file elsewhere must resolve
779 /// exactly as a plain file would (`fs::read_to_string`, which `RealFs::
780 /// read` wraps, follows symlinks) — both `RealFs::read` and the probe
781 /// that depends on it must see the target's real content, not treat the
782 /// symlink as absent or unreadable.
783 #[cfg(unix)]
784 #[test]
785 fn real_fs_read_and_find_config_in_tree_follow_a_symlinked_brink_toml() {
786 use std::os::unix::fs::symlink;
787
788 let root = temp_dir("realfs-symlink-config");
789 fs::write(
790 root.join("real-brink.toml"),
791 "[project]\ndialect = \"brink\"\n",
792 )
793 .expect("write real-brink.toml");
794 symlink(root.join("real-brink.toml"), root.join("brink.toml"))
795 .expect("symlink brink.toml -> real-brink.toml");
796 fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
797
798 let tree = RealFs::new(&root);
799 assert_eq!(
800 tree.read("brink.toml").expect("read follows the symlink"),
801 "[project]\ndialect = \"brink\"\n"
802 );
803
804 let found = brink_project_config::discover_from_entry_in_tree(&tree, "main.brink")
805 .expect("probe read succeeds")
806 .expect("the symlinked brink.toml is discovered");
807 assert_eq!(found, "brink.toml");
808
809 fs::remove_dir_all(&root).expect("cleanup temp dir");
810 }
811
812 /// Issue #1387 (2/3), the other edge case: a `brink.toml` that is
813 /// actually a *directory* on disk (a plausible authoring mistake — an
814 /// empty `mkdir brink.toml` instead of a file, or a half-finished
815 /// project scaffold). `RealFs::read` must surface this as an `Err`
816 /// (`fs::read_to_string` on a directory never panics — it errors), and
817 /// `find_config_in_tree`'s probe must still treat that error as "found"
818 /// per its documented contract ("any other error kind ... means a
819 /// `brink.toml` *exists* at this candidate but this probe couldn't read
820 /// it — treated as found"): the caller's own subsequent `read` of the
821 /// returned key is what turns this into a path-attributed load error
822 /// (`brink-environment`'s `LoadError::ConfigRead`), not a silent
823 /// "no config" fallback to defaults.
824 #[test]
825 fn real_fs_read_and_find_config_in_tree_report_a_directory_shaped_brink_toml_as_found() {
826 let root = temp_dir("realfs-dir-config");
827 fs::create_dir_all(root.join("brink.toml")).expect("mkdir brink.toml (directory)");
828 fs::write(root.join("main.brink"), "flow main() {}").expect("write main.brink");
829
830 let tree = RealFs::new(&root);
831 tree.read("brink.toml")
832 .expect_err("reading a directory as a config file must error, not panic");
833
834 let found = brink_project_config::discover_from_entry_in_tree(&tree, "main.brink")
835 .expect("the directory's read error is not propagated as an Err")
836 .expect("a directory-shaped brink.toml is still reported as found");
837 assert_eq!(found, "brink.toml");
838
839 fs::remove_dir_all(&root).expect("cleanup temp dir");
840 }
841
842 /// Build a throwaway git repo with one commit containing `files`
843 /// (relative path -> content), and return its directory plus the
844 /// resulting commit sha.
845 fn git_repo_with_commit(label: &str, files: &[(&str, &str)]) -> (PathBuf, String) {
846 let dir = temp_dir(label);
847 let git = |args: &[&str]| {
848 let output = StdCommand::new("git")
849 .current_dir(&dir)
850 .args(args)
851 .output()
852 .expect("spawn git");
853 assert!(
854 output.status.success(),
855 "git {args:?} failed: {}",
856 String::from_utf8_lossy(&output.stderr)
857 );
858 };
859 git(&["init", "--quiet"]);
860 git(&["config", "user.email", "sourcetree-test@example.invalid"]);
861 git(&["config", "user.name", "SourceTree Test"]);
862 for (path, content) in files {
863 let full = dir.join(path);
864 if let Some(parent) = full.parent() {
865 fs::create_dir_all(parent).expect("mkdir parent");
866 }
867 fs::write(&full, content).expect("write fixture file");
868 }
869 git(&["add", "."]);
870 git(&["commit", "--quiet", "-m", "sourcetree test fixture"]);
871
872 let output = StdCommand::new("git")
873 .current_dir(&dir)
874 .args(["rev-parse", "HEAD"])
875 .output()
876 .expect("spawn git rev-parse");
877 assert!(output.status.success(), "git rev-parse HEAD failed");
878 let sha = String::from_utf8(output.stdout)
879 .expect("HEAD sha is utf8")
880 .trim()
881 .to_string();
882 (dir, sha)
883 }
884
885 /// `GitRev::list`/`read` over a real (throwaway) git repo: enumerates
886 /// only `.brink` blobs in sorted order, and `read` round-trips their
887 /// committed content.
888 #[test]
889 fn git_rev_list_and_read_round_trip_a_real_commit() {
890 let (repo_dir, sha) = git_repo_with_commit(
891 "gitrev",
892 &[
893 ("z.brink", "-- z --"),
894 ("a.brink", "-- a --"),
895 ("nested/b.brink", "-- nested/b --"),
896 ("README.md", "not brink"),
897 ],
898 );
899
900 let tree = GitRev::new(&repo_dir, sha.clone(), ".");
901 let keys = tree.list().expect("list succeeds");
902
903 assert_eq!(keys, vec!["a.brink", "nested/b.brink", "z.brink"]);
904 assert_eq!(tree.read("a.brink").expect("read succeeds"), "-- a --");
905 assert_eq!(
906 tree.read("nested/b.brink").expect("read succeeds"),
907 "-- nested/b --"
908 );
909
910 fs::remove_dir_all(&repo_dir).expect("cleanup temp dir");
911 }
912
913 /// A key that does not exist at the given revision reads as a
914 /// `NotFound` I/O error.
915 #[test]
916 fn git_rev_read_missing_key_is_not_found() {
917 let (repo_dir, sha) = git_repo_with_commit("gitrev-missing", &[("a.brink", "-- a --")]);
918
919 let tree = GitRev::new(&repo_dir, sha, ".");
920 let err = tree.read("missing.brink").expect_err("key absent at rev");
921
922 assert_eq!(err.kind(), io::ErrorKind::NotFound);
923
924 fs::remove_dir_all(&repo_dir).expect("cleanup temp dir");
925 }
926
927 /// Regression for #1371: `GitRev::list` used to take a `root: &Path`
928 /// trait parameter and scope its `git ls-tree` pathspec off *that*
929 /// argument, ignoring its own constructor-held `root` entirely — the
930 /// opposite bug from `RealFs` (which ignored the argument and always
931 /// used its constructor root). A tree constructed with a subdirectory
932 /// root must list only that subdirectory's `.brink` files, with no
933 /// `root` argument available to override it.
934 #[test]
935 fn git_rev_list_uses_only_its_constructor_root_not_a_call_site_argument() {
936 let (repo_dir, sha) = git_repo_with_commit(
937 "gitrev-constructor-root",
938 &[("sub/a.brink", "-- sub/a --"), ("top.brink", "-- top --")],
939 );
940
941 let tree = GitRev::new(&repo_dir, sha, "sub");
942 let keys = tree.list().expect("list succeeds");
943
944 assert_eq!(
945 keys,
946 vec!["a.brink"],
947 "must scope to the constructor-held root (sub/), never see top.brink"
948 );
949 assert_eq!(tree.read("a.brink").expect("read succeeds"), "-- sub/a --");
950
951 fs::remove_dir_all(&repo_dir).expect("cleanup temp dir");
952 }
953
954 // ── is_native ────────────────────────────────────────────────────
955
956 #[test]
957 fn is_native_matches_brink_extension_only() {
958 assert!(is_native(Path::new("foo.brink")));
959 assert!(is_native(Path::new("nested/foo.brink")));
960 assert!(!is_native(Path::new("foo.ink")));
961 assert!(!is_native(Path::new("foo")));
962 }
963
964 // ── native_source_root ──────────────────────────────────────────────
965
966 /// A `brink.toml` above the entry's directory (walked up to) makes its
967 /// *parent* directory the source root — not the entry's own directory.
968 #[test]
969 fn native_source_root_walks_up_to_brink_toml() {
970 let dir = temp_dir("root-walkup");
971 fs::create_dir_all(dir.join("sub")).expect("mkdir sub");
972 fs::write(dir.join("brink.toml"), "[project]\n").expect("write brink.toml");
973
974 let entry = dir.join("sub").join("main.brink");
975 let root = native_source_root(&entry);
976
977 assert_eq!(
978 root, dir,
979 "root must be brink.toml's directory, not entry's"
980 );
981
982 fs::remove_dir_all(&dir).expect("cleanup temp dir");
983 }
984
985 /// No `brink.toml` anywhere above the entry: root falls back to the
986 /// entry's own directory — the documented single-file-project mode.
987 #[test]
988 fn native_source_root_falls_back_to_entry_dir_without_brink_toml() {
989 let dir = temp_dir("root-fallback");
990
991 let entry = dir.join("main.brink");
992 let root = native_source_root(&entry);
993
994 assert_eq!(root, dir);
995
996 fs::remove_dir_all(&dir).expect("cleanup temp dir");
997 }
998
999 // ── relative_key ─────────────────────────────────────────────────
1000
1001 #[test]
1002 fn relative_key_strips_root_prefix() {
1003 let dir = temp_dir("relative-key");
1004 let path = dir.join("story").join("main.brink");
1005
1006 assert_eq!(relative_key(&dir, &path), "story/main.brink");
1007
1008 fs::remove_dir_all(&dir).expect("cleanup temp dir");
1009 }
1010
1011 /// `root = "."` and an already-cwd-relative `path` key identically to
1012 /// the plain path — the common case for a CLI invocation with no
1013 /// `brink.toml` above the entry.
1014 #[test]
1015 fn relative_key_root_dot_keys_a_relative_path_as_is() {
1016 assert_eq!(
1017 relative_key(Path::new("."), Path::new("main.brink")),
1018 "main.brink"
1019 );
1020 }
1021
1022 // ── native_source_root discovery warnings (issue #1610) ──────────
1023
1024 /// `native_source_root_with_warnings` discovers warnings about stepped-over
1025 /// `brink.toml` files and reports them, not silently dropping them (rule 9:
1026 /// silent drops are bugs until proven otherwise). This test proves the
1027 /// warnings reach the caller (rule 20e: assert what the consumer receives,
1028 /// not an internal enum an intermediate layer happens to hold).
1029 #[test]
1030 fn native_source_root_with_warnings_reports_discovery_warnings() {
1031 // Keep the whole fixture inside this test's own temp dir — never
1032 // write into the shared `$TMPDIR` itself (review finding on #1712:
1033 // writing `brink.toml` directly under `std::env::temp_dir()` races
1034 // every other fixture in this file, and any of them, rooted there,
1035 // would spuriously discover it as an ancestor config).
1036 let base = temp_dir("native-source-root-warnings");
1037
1038 // Create a structure:
1039 // base/
1040 // brink.toml (above the repository boundary)
1041 // proj/
1042 // .git/ (marks a repository boundary; walk stops here)
1043 // sub/
1044 // deep/
1045 // entry.brink (entry point)
1046 fs::write(base.join("brink.toml"), "[project]\n").expect("write above brink.toml");
1047 let root = base.join("proj");
1048 fs::create_dir_all(root.join(".git")).expect("mkdir .git");
1049 fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").expect("write .git/HEAD");
1050 fs::create_dir_all(root.join("sub/deep")).expect("mkdir sub/deep");
1051 fs::write(root.join("sub/deep/entry.brink"), "flow main() {}\n")
1052 .expect("write entry.brink");
1053
1054 let entry = root.join("sub/deep/entry.brink");
1055 let (resolved_root, warnings) = native_source_root_with_warnings(&entry);
1056
1057 // The resolved root must fall back to the entry directory because no
1058 // brink.toml was found within the .git boundary.
1059 assert_eq!(resolved_root, root.join("sub/deep"));
1060
1061 // `find_config_with_warnings` reported the stepped-over file as a
1062 // [`ConfigWarning`], and it reached the caller (rule 20e). Assert the
1063 // warning text actually names the stepped-over path, not merely that
1064 // *some* warning arrived (review finding on #1712).
1065 assert_eq!(warnings.len(), 1, "expected exactly one discovery warning");
1066 let warning_text = warnings[0].to_string();
1067 let stepped_over = base.join("brink.toml");
1068 assert!(
1069 warning_text.contains(&stepped_over.display().to_string()),
1070 "warning must name the stepped-over brink.toml path {}: got {warning_text}",
1071 stepped_over.display(),
1072 );
1073
1074 fs::remove_dir_all(&base).expect("cleanup temp dir");
1075 }
1076}