Skip to main content

brink_source_tree/
lib.rs

1//! The `SourceTree` seam (decision-log "Native source-loading seam: a
2//! `SourceTree` trait with a map-backed impl; the root is caller-supplied",
3//! 2026-07-22; issue #1278): a host-agnostic way to enumerate and read
4//! native `.brink` source files.
5//!
6//! Extracted from `brink-db` into this L0 leaf crate (decision-log
7//! 2026-07-23, issue #1323 ruling on #1325) so both `brink-db` (native
8//! discovery) and `brink-project-config` (config discovery, #1312) can
9//! depend on it without a
10//! `project-config -> brink-db -> brink-analyzer -> project-config` cycle.
11//! `brink-db` re-exports [`SourceTree`] so `brink_db::SourceTree` still
12//! resolves for existing consumers.
13//!
14//! `InMemory` is `brink-web`'s discovery seam directly; the host-only
15//! implementations (`RealFs`, `GitRev`) live in `brink-driver` and back
16//! `brink_driver::discover_native` (issue #1288) — a normal native compile
17//! and the `brink ide` git-baseline diff path, respectively.
18//!
19//! # The contract
20//!
21//! [`SourceTree::list`] enumerates every source key, **sorted
22//! deterministically by key** — never in filesystem/OS iteration order,
23//! which is unspecified and can vary between runs. Keys are root-relative
24//! (forward-slash-joined, matching how `.brink` module paths are derived
25//! downstream). [`SourceTree::read`] reads the source text for a key
26//! previously returned by `list` — **but callers may also probe candidate
27//! keys `list` never returned** (e.g. `find_config_in_tree`'s #1370
28//! ancestor-probing walk, which never calls `list` at all). A
29//! [`SourceTree::read`] implementation MUST surface a nonexistent key as
30//! [`io::ErrorKind::NotFound`], not some other error kind — callers that
31//! probe speculatively treat `NotFound` as "no candidate here, keep going"
32//! and treat every other error kind as fatal.
33//!
34//! ## Policy asymmetry: `list` may be key-kind-scoped, `read` never is
35//!
36//! `list`'s enumeration scope is entirely implementation-defined — nothing
37//! in this trait requires it to return only native `.brink` keys.
38//! `brink-driver`'s `RealFs`, for instance, scopes `list` to `.brink` only
39//! (the native discovery / `brink ide` shape; issue #1404 deleted a second,
40//! wider `.brink` + `.ink` scope once tracing showed every caller of that
41//! wider scope either filtered `list()`'s output back down to `.brink`
42//! itself or never called `list()` at all, so the extra `.ink` keys were
43//! never actually observable). `read`, however, has **no equivalent
44//! key-kind scoping on any implementation** — whether a key is native
45//! (`.brink`) or not plays no role in whether `read` will serve it,
46//! regardless of what that same implementation's `list` would ever
47//! enumerate. A `RealFs`-scoped tree's `read("brink.toml")` still succeeds
48//! if that file is on disk, even though its `list()` would never return
49//! that key.
50//!
51//! This is a claim about key-*kind* scoping specifically, not a claim that
52//! every implementation serves every key that physically exists: a
53//! `SourceTree` may still layer a *per-key* overlay unrelated to nativeness.
54//! `brink-cli`'s `EditOverlay`, for instance, reports `NotFound` for a key
55//! it has marked `removed` even though that file is still on disk — a
56//! moved/deleted-key overlay, not list-parity scoping keyed on whether the
57//! file is native. That axis is orthogonal to this section and remains
58//! legal.
59//!
60//! This asymmetry is intentional, not an oversight: it is exactly what lets
61//! `find_config_in_tree` probe for a manifestly non-native `brink.toml` key
62//! against *any* `SourceTree` — including one scoped to `.brink` alone —
63//! without needing a widened `list` or a second seam. The seam itself does
64//! not police "nativeness" on `read`; a consumer that needs that guarantee
65//! enforces it itself. `brink-driver`'s `discover_native` is the sharp edge
66//! of this: it inspects every key `list` returns and rejects the whole
67//! discovery (`DiscoverError::NonNativeKey`) if any of them is not `.brink`
68//! — but that check runs against `list`'s output only, is specific to that
69//! one consumer, and says nothing about what `read` will or won't serve.
70//! Do not assume a `SourceTree` implementation refuses to read non-native
71//! keys just because its `list` is native-scoped.
72//!
73//! The root itself is never discovered inside the seam (no implementation
74//! walks upward looking for a project marker) — it is always supplied by the
75//! caller, which resolves it however is appropriate for that host (a
76//! `brink.toml` walk-up for the CLI, a pushed project root for web/LSP). It
77//! is held by the implementation **at construction** (the #1323 layering
78//! ruling), not passed per call: `list` takes no `root` parameter, matching
79//! `read`, which never had one. Issue #1371 removed `list`'s `root`
80//! parameter for exactly this reason — before the fix, `RealFs` silently
81//! ignored a `root` argument to `list` while `GitRev` silently used it
82//! *instead of* its own constructor-held root, so the same call could
83//! resolve two different trees' worth of keys depending on which impl
84//! happened to be behind the `dyn SourceTree`. Dropping the parameter makes
85//! "root is constructor-held" the only contract there is to honor.
86
87pub mod walk;
88
89pub use walk::{Walk, WalkEntry};
90
91use std::collections::BTreeMap;
92use std::ffi::OsStr;
93use std::io;
94
95/// The directory-entry name that marks a git repository root — either an
96/// ordinary clone's `.git/` directory, or a linked worktree's `.git`
97/// *file* (a `gitdir:` pointer, e.g. how this repository's own
98/// `.claude/worktrees/*` are laid out). A single source of truth for that
99/// name (issue #1435): before this constant existed, [`IGNORED_DIR_NAMES`]
100/// below and `brink-project-config`'s `find_config` walk-up bound each
101/// hardcoded their own `".git"` literal, free to drift apart.
102pub const GIT_DIR_NAME: &str = ".git";
103
104/// Directory names a recursive filesystem walk should never descend into —
105/// build output and VCS/dependency metadata that is never a valid source
106/// location and can be enormous. Originally added to `brink-driver`'s
107/// `RealFs` walk alone (issue #1381: #1370 fixed *config discovery* to
108/// probe ancestors directly instead of enumerating, but the native compile
109/// walk — the other call path paying the same cost — still descended into
110/// these). Promoted here (issue #1402) so every host-side recursive walk —
111/// `brink-driver`'s `RealFs` and `brink-lsp`'s workspace scan alike — prunes
112/// the same directories instead of each re-deriving its own list. Matched
113/// by exact directory-entry name, not path suffix, so a source file
114/// legitimately named e.g. `target.brink` is unaffected.
115///
116/// Sharing the *list* was only half the problem: each walk still had to
117/// remember to consult it. [`Walk`] (issue #1433) applies this list by
118/// construction, and is where every recursive traversal enforces it now.
119pub const IGNORED_DIR_NAMES: &[&str] = &["target", GIT_DIR_NAME, "node_modules"];
120
121/// Whether `name` (a single directory-entry file name, not a path) is a
122/// conventionally-ignored directory a recursive walk must not descend into.
123/// See [`IGNORED_DIR_NAMES`].
124///
125/// # Call it directly only when there is no walk to hang it off
126///
127/// A recursive traversal must **not** call this itself — it uses [`Walk`],
128/// which applies the policy by construction, precisely because five separate
129/// issues fixed five hand-written walks that each forgot to (issue #1433).
130/// This predicate stays public for the cases that aren't walks at all and so
131/// have no descent to prune: `brink-lsp`'s `path_under_ignored_dir` tests
132/// every component of an already-complete path handed to it by the client's
133/// file watcher (#1415).
134///
135/// # Admission policy
136///
137/// This section is scoped to **`.ink` source admission**. This guard governs
138/// **directory walks** — code that discovers files by recursively
139/// enumerating a tree it wasn't already told the shape of (`brink-driver`'s
140/// `RealFs` walk; `brink-lsp`'s workspace-load walk; the *admission* half of
141/// `brink-lsp`'s file-watcher handler for `.ink` paths, which is handed
142/// individual paths but still decides whether each is new territory) — this
143/// half only actually prunes when `brink-lsp` has a non-empty
144/// `workspace_roots` to scope the check against; with none (single-file mode,
145/// or a watcher event racing `initialize`), `path_under_ignored_dir` declines
146/// to prune rather than guessing, per #1434. It does **not** govern **explicit
147/// path admission** — code that is handed one
148/// specific path by something outside the walk, with no discretion to skip
149/// it: a user opening a file directly in their editor
150/// (`textDocument/didOpen`), or an `INCLUDE` directive naming a path from
151/// within source that is itself already admitted (`brink-lsp`'s
152/// `chase_includes` / `load_file_from_disk`). `brink-lsp`'s
153/// `textDocument/didChange` and `textDocument/didSave` handlers are explicit
154/// path admission too — both insert via `ProjectDb::update_file`, a literal
155/// alias for `set_file`, so either can admit a path the db has never seen —
156/// though in practice they're always preceded by a `didOpen` for the same
157/// path first. Those call sites intentionally never call this guard — the
158/// user (via the editor) or the source author (via `INCLUDE`) has already
159/// made the decision to reference that exact file, and second-guessing it
160/// here would make e.g. `INCLUDE
161/// node_modules/shared/lib.ink` — a legitimate way to pull in vendored ink
162/// content — silently fail to load. Once such a file is admitted, it is
163/// tracked like any other: later watched-file CHANGED/DELETED events for it
164/// keep syncing, even though a *fresh* CREATED admission of the same
165/// still-untracked path would be pruned.
166///
167/// `brink-lsp`'s file-watcher handler also routes `brink.toml` changes
168/// separately from `.ink` admission, and applies this guard there under a
169/// stricter rule of its own: an ignored-dir `brink.toml` is never
170/// authoritative config, so that route skips unconditionally, with no
171/// already-tracked exemption — again, only when `workspace_roots` is
172/// non-empty; with no root to scope against, the same #1434 carve-out
173/// applies and the route does not prune at all. That rule is config-file
174/// routing, not `.ink` source admission, so it isn't part of the policy
175/// documented above.
176///
177/// Decided and written down once here (issue #1424) after #1415 found the
178/// split already held in practice — every admission path's behavior already
179/// agreed with it — but was never stated anywhere, leaving each site to
180/// (correctly, but silently and independently) either call this guard or
181/// omit it.
182#[must_use]
183pub fn is_ignored_dir(name: &OsStr) -> bool {
184    IGNORED_DIR_NAMES.iter().any(|ignored| name == *ignored)
185}
186
187/// A source of `.brink` files: enumerate what exists under a root (held by
188/// the implementation since construction — see the [module docs](self)),
189/// and read any key, whether or not enumeration returned it.
190///
191/// See the [module docs](self) for the full contract. Implementations must
192/// return `list()` results sorted by key, regardless of what order the
193/// underlying storage (filesystem, git tree, in-memory map) happens to
194/// iterate in.
195pub trait SourceTree {
196    /// Enumerate every source key under the implementation's own root,
197    /// sorted deterministically by key.
198    fn list(&self) -> io::Result<Vec<String>>;
199
200    /// Read the source text for `key`.
201    ///
202    /// `key` is usually one [`list`](Self::list) previously returned, but
203    /// callers may also probe speculative candidate keys `list` never
204    /// returned (see the [module docs](self) — e.g. an ancestor-directory
205    /// walk-up probing for a config file). Implementations MUST return
206    /// [`io::ErrorKind::NotFound`], and no other error kind, when `key` does
207    /// not exist — speculative callers rely on that kind to distinguish "not
208    /// here, keep probing" from a real I/O failure.
209    ///
210    /// `read` carries no key-kind scoping even when `list` does (see the
211    /// [module docs](self) "policy asymmetry" section) — whether a key is
212    /// native source or not plays no role in whether `read` serves it. A
213    /// per-key overlay unrelated to nativeness (e.g. a moved/deleted-key
214    /// guard) may still legally refuse a specific existing key.
215    fn read(&self, key: &str) -> io::Result<String>;
216}
217
218/// Map-backed [`SourceTree`]: the test and web seam.
219///
220/// Built from a `BTreeMap<key, source>`, so `list()`'s sortedness falls out
221/// of `BTreeMap`'s own ordering guarantee rather than an extra sort step —
222/// the map stays sorted by key no matter what order entries were inserted
223/// in.
224#[derive(Debug, Clone, Default)]
225pub struct InMemory {
226    files: BTreeMap<String, String>,
227}
228
229impl InMemory {
230    /// Build an in-memory `SourceTree` from a root-relative key → source map.
231    #[must_use]
232    pub fn new(files: BTreeMap<String, String>) -> Self {
233        Self { files }
234    }
235}
236
237impl SourceTree for InMemory {
238    fn list(&self) -> io::Result<Vec<String>> {
239        Ok(self.files.keys().cloned().collect())
240    }
241
242    fn read(&self, key: &str) -> io::Result<String> {
243        self.files
244            .get(key)
245            .cloned()
246            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("{key}: not found")))
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    /// Feeding keys in a hostile (reverse-sorted) insertion order must not
255    /// affect `list()`'s output — it always comes back key-sorted.
256    #[test]
257    fn in_memory_list_is_sorted_despite_hostile_reverse_insertion_order() {
258        let mut files = BTreeMap::new();
259        // Insert in reverse-sorted order.
260        for key in ["c/z.brink", "b/m.brink", "a/a.brink"] {
261            files.insert(key.to_string(), format!("-- {key} --"));
262        }
263        let tree = InMemory::new(files);
264
265        let keys = tree.list().expect("list succeeds");
266
267        assert_eq!(keys, vec!["a/a.brink", "b/m.brink", "c/z.brink"]);
268    }
269
270    /// Feeding keys in a hostile (shuffled, non-monotonic) insertion order
271    /// must also not affect `list()`'s output.
272    #[test]
273    fn in_memory_list_is_sorted_despite_hostile_shuffled_insertion_order() {
274        let mut files = BTreeMap::new();
275        for key in ["m/mid.brink", "a/first.brink", "z/last.brink", "b/b.brink"] {
276            files.insert(key.to_string(), format!("-- {key} --"));
277        }
278        let tree = InMemory::new(files);
279
280        let keys = tree.list().expect("list succeeds");
281
282        assert_eq!(
283            keys,
284            vec!["a/first.brink", "b/b.brink", "m/mid.brink", "z/last.brink"]
285        );
286    }
287
288    /// `read()` returns exactly the source text a key was constructed with.
289    #[test]
290    fn in_memory_read_round_trips() {
291        let mut files = BTreeMap::new();
292        files.insert(
293            "market/barter.brink".to_string(),
294            "flow barter() {}".to_string(),
295        );
296        files.insert("main.brink".to_string(), "flow main() {}".to_string());
297        let tree = InMemory::new(files);
298
299        assert_eq!(
300            tree.read("market/barter.brink").expect("key exists"),
301            "flow barter() {}"
302        );
303        assert_eq!(
304            tree.read("main.brink").expect("key exists"),
305            "flow main() {}"
306        );
307    }
308
309    /// Reading a key that was never inserted is a `NotFound` I/O error, not
310    /// a panic — `InMemory` is a real `SourceTree`, not a test-only stub
311    /// that can assume well-formed callers.
312    #[test]
313    fn in_memory_read_missing_key_is_not_found() {
314        let tree = InMemory::new(BTreeMap::new());
315
316        let err = tree.read("missing.brink").expect_err("key absent");
317
318        assert_eq!(err.kind(), io::ErrorKind::NotFound);
319    }
320
321    /// `list()` on an empty tree is `Ok(vec![])`, not an error.
322    #[test]
323    fn in_memory_list_empty_is_ok_empty() {
324        let tree = InMemory::new(BTreeMap::new());
325
326        assert_eq!(tree.list().expect("list succeeds"), Vec::<String>::new());
327    }
328
329    /// `is_ignored_dir` matches every name in [`IGNORED_DIR_NAMES`] exactly
330    /// (issue #1402: this is the shared helper both `brink-driver`'s
331    /// `RealFs` walk and `brink-lsp`'s workspace scan now call).
332    #[test]
333    fn is_ignored_dir_matches_every_listed_name() {
334        for name in IGNORED_DIR_NAMES {
335            assert!(is_ignored_dir(OsStr::new(name)), "{name} should be ignored");
336        }
337    }
338
339    /// A directory whose name merely starts with an ignored name (not an
340    /// exact match) is not pruned — this is a name-equality check, not a
341    /// prefix/suffix test, so e.g. `target.brink` (a legitimately named
342    /// source file) or `targets/` are unaffected.
343    #[test]
344    fn is_ignored_dir_does_not_match_by_prefix() {
345        assert!(!is_ignored_dir(OsStr::new("targets")));
346        assert!(!is_ignored_dir(OsStr::new("target.brink")));
347        assert!(!is_ignored_dir(OsStr::new("my-node_modules")));
348    }
349}