Skip to main content

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}