brink_project_config/lib.rs
1//! `brink.toml` — the project settings file for dialect + type policy
2//! (#1005).
3//!
4//! `dialect` and `types` are mount-time-only inputs to `AnalysisOptions`
5//! (docs/t1b-surface-spec.md §1, docs/typed-mode-spec.md §1): never embedded
6//! in `.inkb`, never delivered to the runtime. Before this crate, every
7//! surface that compiles the same project — the CLI, `brink ide`, the wasm
8//! editor session — picked its own default (a CLI flag here, a hardcoded
9//! `setLanguageDialect` call there), so two mounts compiling the same
10//! project could silently disagree about which syntax/typing surface it's
11//! written in.
12//!
13//! This crate is the one place that:
14//!
15//! - **discovers** the config file — walking up from the entry `.ink` file's
16//! directory to the nearest ancestor containing [`CONFIG_FILE_NAME`],
17//! bounded at a workspace/git boundary (#1425) so the walk can never
18//! escape the project and pick up an unrelated `brink.toml` far above it,
19//! and **also** bounded by a fixed ancestor-depth cap
20//! ([`MAX_ANCESTOR_DEPTH`]) so a VCS-less project — no `.git` boundary to
21//! stop at — still can't climb all the way to the filesystem root (#1435)
22//! ([`discover_from_entry`], [`find_config`]). A `brink.toml` the bounded
23//! walk steps over is never silently dropped: [`find_config_with_warnings`]
24//! reports it back as a [`ConfigWarning`] instead;
25//! - **parses** it, tolerating unknown keys as warnings rather than errors
26//! (forward compat — an older `brink` binary shouldn't choke on a
27//! `brink.toml` written for a newer schema) ([`parse_str`],
28//! [`parse_str_at`] — the latter threads the discovered path into every
29//! [`ConfigError`] it raises, #1384);
30//! - defines the two policy enums the file can set ([`Dialect`],
31//! [`TypePolicy`]); applying them to an `AnalysisOptions` lives in
32//! `brink-analyzer` (`AnalysisOptions::apply_project_config`), honoring
33//! the precedence rule
34//! every mount must follow: **an explicit API call / CLI flag always wins
35//! over the file.** The file supplies the *default*; code wins
36//!
37//! A missing `brink.toml` is not an error anywhere in this crate — it means
38//! "use `AnalysisOptions::default()` (or whatever the caller already had)",
39//! byte-identical to pre-#1005 behavior.
40//!
41//! ## Schema
42//!
43//! ```toml
44//! [project]
45//! dialect = "brink" # "brink" | "strict-ink" (default: strict-ink)
46//! types = "strict" # "gradual" | "strict" (default: dialect-keyed —
47//! # brink → strict, strict-ink → gradual; issue
48//! # #1127, ruled 2026-07-19)
49//!
50//! [lints]
51//! deny-warnings = true # promote every Warning-severity diagnostic to
52//! # Error (the `-D warnings` equivalent; issue #1160)
53//! E014 = "deny" # per-code severity override:
54//! # "allow" | "warn" | "deny" | "info" | "hint"
55//! # ("info"/"hint" down-level to the advisory tiers
56//! # below Warning, issue #1162)
57//! ```
58//!
59//! ```toml
60//! [project]
61//! unprune-dirs = ["node_modules"] # directory names discovery must NOT
62//! # prune, on top of the standing
63//! # `target`/`.git`/`node_modules` policy
64//! # (issue #1407's escape hatch — see
65//! # `brink_source_tree::Walk::allow`). A
66//! # name that isn't one of those three is
67//! # a no-op (there was nothing to
68//! # un-prune) and warns.
69//! ```
70//!
71//! ```toml
72//! [project]
73//! conventions = "conventions.brink" # docs/prose-dialect-spec.md §3.4: a
74//! # built-in preset name ("screenplay")
75//! # or a project-relative path to a
76//! # `.brink` conventions module. Names
77//! # the ONE file a pattern-claiming
78//! # `@[convention(claims = "…", order =
79//! # N)]` handler may be declared in
80//! # (issue #1844's confinement rule,
81//! # `E169` elsewhere) — unset means no
82//! # conventions module is configured, so
83//! # nothing is enforced yet.
84//! #
85//! # `elements` is a DEPRECATED alias for
86//! # this key (issue #2180: the key
87//! # predates the split of `@[element]`
88//! # from `@[convention]` and now names a
89//! # module of the latter, not the
90//! # former). Setting `elements` still
91//! # works but warns; setting both keys
92//! # prefers `conventions` and warns
93//! # about the conflict. The alias will
94//! # be removed in a future release —
95//! # migrate to `conventions`.
96//! ```
97//!
98//! ```toml
99//! [project]
100//! entry = "story.ink" # the project's entry file, project-relative
101//! # (issue #2331, ruled 2026-08-07 "[project] entry
102//! # beats mountStudio's entryFile"). When both this
103//! # key and a host's own entry-file argument
104//! # (`mountStudio`'s `entryFile`, `ProjectSession`'s
105//! # constructor option) are present, THIS KEY WINS —
106//! # the host argument is only the fallback for a
107//! # configless project. Unset means "no opinion": the
108//! # host argument decides alone, unchanged from
109//! # pre-#2331 behavior.
110//! ```
111//!
112//! (`E014` — a plainly `Warning`-by-default code — is used here rather than
113//! `E063`: `E063`'s own *base* severity is `types`-policy-dependent (`Error`
114//! under `types = strict`, see `brink_analyzer::effective_severity`'s doc
115//! comment), so it makes a confusing flagship example — under `types =
116//! strict` a `[lints]` entry for it is never even consulted.)
117//!
118//! Every key is optional; an empty or absent `[project]`/`[lints]` table is
119//! valid and contributes nothing (`ProjectConfig::default()`).
120//!
121//! `[lints]` is shaped like Rust's own `[lints]` table (issue #1160) but is
122//! **not** a drop-in semantic match: each key other than the reserved
123//! `deny-warnings` is taken as a diagnostic code (`"E014"`) mapped to a
124//! [`LintLevel`], and `Deny`/`Warn` behave as their Rust namesakes suggest —
125//! but `Allow` does not *remove* the diagnostic the way Rust's `allow`
126//! does. `LintLevel::Allow` only buys immunity from `deny-warnings`; the
127//! diagnostic still resolves to `Severity::Warning` and is still reported
128//! (`brink_analyzer::effective_severity`'s doc comment, step 3). An author
129//! who wants a code gone entirely wants `brink_ir::suppressions`
130//! (`//brink-disable`), a different, per-site mechanism — not `[lints]`.
131//!
132//! This crate does not know the closed set of real `DiagnosticCode`s
133//! (keeping it dependency-free, #1234), so it accepts any key here without
134//! validation — resolving a key against the real code set, and deciding
135//! which codes are actually overridable (a `Warning`-base-severity code
136//! only — see `effective_severity`'s hard-error exemption), is
137//! `AnalysisOptions::apply_project_config`'s job in `brink-analyzer` (which
138//! owns `DiagnosticCode`): an unknown or non-overridable key is never
139//! merged into the resolved policy, and is surfaced back to the caller as a
140//! [`ConfigWarning`]-shaped string through that function's return value —
141//! the same "warn, never silently drop" channel this crate's own unknown-key
142//! warnings use.
143
144use std::collections::BTreeMap;
145use std::fmt;
146use std::io;
147use std::path::{Path, PathBuf};
148
149use brink_source_tree::{IGNORED_DIR_NAMES, SourceTree};
150
151/// Compiler dialect: gates T1b brink-extension syntax. Default `StrictInk` —
152/// divergence from the oracle-anchored ink subset is a visible, one-time,
153/// per-project choice (docs/t1b-surface-spec.md §1).
154///
155/// Defined here rather than in `brink-analyzer` because it is a
156/// **project-policy** type: the analyzer consumes it, this crate parses it,
157/// and owning it here is what keeps this crate free of workspace
158/// dependencies (#1234). `brink-analyzer` re-exports it, so
159/// `brink_analyzer::Dialect` remains the canonical path for consumers.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
161pub enum Dialect {
162 #[default]
163 StrictInk,
164 Brink,
165}
166
167/// `types` project policy (docs/typed-mode-spec.md §1). `Gradual` is the
168/// pre-flip behavior — `Unknown` unifies with anything, annotations are
169/// optional seasoning, and the strict checks do not run. `Strict` requires
170/// `dialect = brink`.
171///
172/// The *default* is dialect-keyed since the 2026-07-19 "Typing posture
173/// ruled" decision (issue #1127) — see `brink_analyzer::resolve_type_policy`.
174/// The derived `Default` (`Gradual`) exists only so pre-resolution containers
175/// can derive theirs; policy defaulting must never read it directly.
176///
177/// Defined here for the same reason as [`Dialect`], and re-exported by
178/// `brink-analyzer`.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
180pub enum TypePolicy {
181 #[default]
182 Gradual,
183 Strict,
184}
185
186/// A `[lints]` table entry's severity (issue #1160) — mirrors Rust's own
187/// `[lints]` levels. `Warn` is every diagnostic code's implicit level when
188/// `[lints]` doesn't mention it, so it doubles as this type's `Default`.
189///
190/// Defined here for the same reason as [`Dialect`]/[`TypePolicy`]: a
191/// project-policy type this crate parses but doesn't interpret, kept
192/// dependency-free (#1234) and re-exported by `brink-analyzer`, which owns
193/// applying it against the real `DiagnosticCode` set.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
195pub enum LintLevel {
196 /// Never escalate this code past `Warning`, even under `deny-warnings`.
197 Allow,
198 /// The code's ordinary behavior: `Warning`, promoted to `Error` by
199 /// `deny-warnings` like any other unconfigured warning.
200 #[default]
201 Warn,
202 /// Always `Error`, regardless of `deny-warnings`.
203 Deny,
204 /// Down-level to `Severity::Info` (issue #1162) — an advisory tier below
205 /// `Warning`, immune to `deny-warnings` like `Allow` (escalating an
206 /// author's deliberate downgrade back up would defeat the point of it).
207 Info,
208 /// Down-level to `Severity::Hint` (issue #1162) — the quietest tier,
209 /// immune to `deny-warnings` for the same reason as `Info`. The IDE-
210 /// convention use case this exists for (e.g. unused-symbol dimming) is
211 /// exactly the case where even an `Info` squiggle is too loud.
212 Hint,
213}
214use thiserror::Error;
215use toml::Value;
216
217/// The config filename every mount discovers, beside the root `.ink` entry
218/// file (or in an ancestor directory — see [`find_config`]).
219pub const CONFIG_FILE_NAME: &str = "brink.toml";
220
221/// The `[project]`/`[lints]` tables' recognized keys, parsed out of
222/// `brink.toml`. `dialect`/`types` are `None` when the file doesn't set
223/// them — callers fall back to `AnalysisOptions::default()` (or an explicit
224/// override), never to a default invented by this crate. `lints`/
225/// `deny_warnings` follow the same "unset means untouched" rule: an empty
226/// `lints` map and a `None` `deny_warnings` both mean "`[lints]` didn't say,
227/// leave whatever the caller already had."
228///
229/// No longer `Copy` (issue #1160): `lints` is a `BTreeMap`, which isn't
230/// `Copy`. Every construction site now needs `.clone()` where it used to
231/// rely on an implicit copy.
232#[derive(Debug, Clone, Default, PartialEq, Eq)]
233pub struct ProjectConfig {
234 /// `[project] dialect`, if set.
235 pub dialect: Option<Dialect>,
236 /// `[project] types`, if set.
237 pub types: Option<TypePolicy>,
238 /// `[lints]` per-code severity overrides, keyed by the raw code string
239 /// as written in the file (e.g. `"E063"`) — this crate doesn't validate
240 /// codes against the real `DiagnosticCode` set (#1234 dependency-free
241 /// constraint); resolving unknown/non-overridable codes is
242 /// `brink-analyzer`'s job. Sorted (`BTreeMap`) for deterministic
243 /// iteration.
244 pub lints: BTreeMap<String, LintLevel>,
245 /// `[lints] deny-warnings`, if set.
246 pub deny_warnings: Option<bool>,
247 /// `[project] unprune-dirs`, if set: directory names discovery must not
248 /// prune, layered on top of the standing
249 /// [`brink_source_tree::IGNORED_DIR_NAMES`] policy (issue #1407's escape
250 /// hatch). Empty (the default) means "the standing policy applies with
251 /// no override" — same "unset means untouched" convention as `lints`.
252 /// Raw strings as written in the file; a name outside
253 /// [`brink_source_tree::IGNORED_DIR_NAMES`] parses fine (this crate
254 /// stays dependency-free of anything beyond `brink_source_tree`, and
255 /// there is nothing wrong in principle with naming a directory that
256 /// isn't pruned in the first place) but is a no-op, so [`parse_str_at`]
257 /// warns about it rather than silently accepting a likely typo (e.g.
258 /// `"node-modules"` instead of `"node_modules"`).
259 pub unprune_dirs: Vec<String>,
260 /// `[project] conventions`, if set (docs/prose-dialect-spec.md §3.4's
261 /// pointer mechanism): either a built-in preset name (`"screenplay"`)
262 /// or a project-relative path to a `.brink` conventions module
263 /// (`"conventions.brink"`, `"scenes/conventions.brink"`). This crate
264 /// only carries the raw string — it doesn't know the closed preset-name
265 /// set or validate the path exists, for the same dependency-free
266 /// reason `lints` doesn't validate codes (#1234); resolving it (and, if
267 /// it names a project path, checking that pattern-claiming handlers
268 /// only live in that one file, issue #1844's confinement rule) is
269 /// `brink-analyzer`/`brink-db`'s job.
270 ///
271 /// Renamed from `elements` by issue #2180 (the key predates the split
272 /// of `@[element]` from `@[convention]`, docs/decision-log.md's
273 /// 2026-08-03 ruling, and now names a module of the latter, not the
274 /// former). [`parse_str_at`] still accepts the old `[project] elements`
275 /// spelling as a deprecated alias — see its own doc comment for the
276 /// precedence/warning rules — but every in-memory representation past
277 /// parsing uses only this field; there is no separate `elements` field
278 /// to keep in sync.
279 pub conventions: Option<String>,
280 /// `[project] entry`, if set (issue #2331, ruled 2026-08-07 "`[project]
281 /// entry` beats `mountStudio`'s `entryFile`"): a project-relative path
282 /// naming the project's entry file (e.g. `"story.ink"`,
283 /// `"chapters/main.brink"`). Same shape as [`Self::conventions`] — this
284 /// crate only carries the raw string, without checking the path exists
285 /// or resolving it against a real project tree (kept dependency-free,
286 /// #1234); that is each mount's own job (e.g. `ProjectSession` in
287 /// `packages/ink-editor/src/project-session.ts`, which knows the
288 /// project's actual file set).
289 ///
290 /// The ruling: when both this key and a host's own entry-file argument
291 /// are present, this key WINS — the host argument is only the fallback
292 /// for a configless project (one with no `brink.toml`, or a
293 /// `brink.toml` that doesn't set `entry`). Unlike `dialect`/`types`,
294 /// there is no "explicit API call always wins" precedence tier here:
295 /// the host argument was never an explicit *override* API in the first
296 /// place, just a constructor-time default that had nowhere better to
297 /// come from before this field existed.
298 pub entry: Option<String>,
299}
300
301impl ProjectConfig {
302 /// True if the file set nothing at all (an all-default/empty
303 /// `[project]`/`[lints]` table, or neither table present).
304 #[must_use]
305 pub fn is_empty(&self) -> bool {
306 self.dialect.is_none()
307 && self.types.is_none()
308 && self.lints.is_empty()
309 && self.deny_warnings.is_none()
310 && self.unprune_dirs.is_empty()
311 && self.conventions.is_none()
312 && self.entry.is_none()
313 }
314}
315
316/// A recognized-but-not-understood corner of `brink.toml`: an unknown
317/// top-level key, or an unknown key inside `[project]`. Never fatal —
318/// forward compat (#1005): an older `brink` binary reading a `brink.toml`
319/// written for a newer schema warns instead of refusing to compile.
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub struct ConfigWarning(pub String);
322
323impl fmt::Display for ConfigWarning {
324 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325 write!(f, "{}", self.0)
326 }
327}
328
329/// A `brink.toml` that couldn't be read or parsed. Unlike [`ConfigWarning`],
330/// these are genuine failures: malformed TOML syntax, or a *recognized* key
331/// holding a value outside its enum (`dialect = "sideways"`) — never an
332/// unrecognized key, which is always a warning.
333///
334/// Every variant carries `path` — the file this error came from (#1384: the
335/// path/span threading [`parse_str`]'s doc comment describes below). Before
336/// #1384 only [`ConfigError::Io`] carried one; a caller with a discovered
337/// path in scope (every one of them, in practice — see [`parse_str_at`]) had
338/// to re-derive and hand-format the "which file" prefix itself for every
339/// other variant, a duplicated, easy-to-forget convention that is exactly
340/// how #1369 happened in the first place (`LoadError::Config` lost its path
341/// for a release when that hand-formatting was dropped). Structural fields
342/// mean a new caller gets it for free.
343#[derive(Debug, Error)]
344pub enum ConfigError {
345 /// The file exists but couldn't be read (permissions, race, …).
346 #[error("failed to read {path}: {source}")]
347 Io {
348 path: PathBuf,
349 #[source]
350 source: std::io::Error,
351 },
352 /// Malformed TOML syntax. `source` (`toml::de::Error`) carries its own
353 /// byte span into the document — see [`ConfigError::span`] — and its
354 /// `Display` already renders a `line X, column Y` location plus a
355 /// caret-annotated snippet on its own, independent of `path` (`toml`'s
356 /// own error type does this regardless of whether a path is threaded
357 /// in). What `path` adds here is the file-name attribution this variant
358 /// lacked before #1384; the line/column were always there.
359 #[error("invalid TOML syntax in {path}: {source}")]
360 Toml {
361 path: String,
362 #[source]
363 source: toml::de::Error,
364 },
365 /// The document's root, or a table where one is expected, isn't a table.
366 #[error("`{key}` must be a table, found {found} (in {path})")]
367 NotATable {
368 path: String,
369 key: String,
370 found: &'static str,
371 },
372 /// A recognized key's value has the wrong TOML type (e.g. `dialect = 1`).
373 #[error("`{key}` must be a string, found {found} (in {path})")]
374 WrongType {
375 path: String,
376 key: String,
377 found: &'static str,
378 },
379 /// A recognized key's value is a string, but not one of its allowed
380 /// variants (e.g. `dialect = "sideways"`). No span: this fires *after*
381 /// the document parsed successfully — a syntactically valid string in an
382 /// out-of-range value — so the `toml` crate never attaches a byte range
383 /// to it the way it does for [`ConfigError::Toml`]; `path` is the most
384 /// precise location available (#1384).
385 #[error("`{key}` must be one of {expected:?}, found {found:?} (in {path})")]
386 InvalidValue {
387 path: String,
388 key: String,
389 expected: &'static [&'static str],
390 found: String,
391 },
392}
393
394impl ConfigError {
395 /// The file this error came from, for every variant (#1384).
396 #[must_use]
397 pub fn path(&self) -> &str {
398 match self {
399 ConfigError::Io { path, .. } => path.to_str().unwrap_or_default(),
400 ConfigError::Toml { path, .. }
401 | ConfigError::NotATable { path, .. }
402 | ConfigError::WrongType { path, .. }
403 | ConfigError::InvalidValue { path, .. } => path,
404 }
405 }
406
407 /// The byte range into the parsed document where this error occurred,
408 /// when the underlying TOML parser reported one (#1384) — only ever
409 /// `Some` for [`ConfigError::Toml`] (malformed syntax): every other
410 /// variant is raised *after* the document parsed successfully (a
411 /// recognized key holding an out-of-range value, or the wrong shape), so
412 /// there is no narrower-than-"the whole file" location the `toml` crate
413 /// ever attached to it. Centralizes the match `brink-lsp` previously
414 /// re-derived itself (`toml_span_to_lsp_range`) so a new caller doesn't
415 /// have to.
416 #[must_use]
417 pub fn span(&self) -> Option<std::ops::Range<usize>> {
418 match self {
419 ConfigError::Toml { source, .. } => source.span(),
420 _ => None,
421 }
422 }
423}
424
425/// A successfully discovered + parsed `brink.toml`.
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct LoadedConfig {
428 /// The path the config was read from (for diagnostics/logging).
429 pub path: PathBuf,
430 /// The parsed `[project]` table.
431 pub config: ProjectConfig,
432 /// Unknown-key warnings (never errors — see [`ConfigWarning`]).
433 pub warnings: Vec<ConfigWarning>,
434}
435
436/// Parse `brink.toml` source text (already read, by whatever means the
437/// caller has — a native `std::fs::read_to_string`, a wasm embedder's own
438/// host filesystem API, …). This is the sandbox-agnostic half of the crate:
439/// no filesystem access, so it's also what the wasm editor mount uses
440/// (the browser sandbox has no `walk up the directory tree` of its own).
441///
442/// Unknown top-level keys and unknown `[project]` keys become
443/// [`ConfigWarning`]s. Only malformed TOML syntax or a recognized key with
444/// an invalid value is a [`ConfigError`].
445///
446/// Every [`ConfigError`] this can raise still needs *some* `path` (#1384);
447/// this is [`parse_str_at`] with [`CONFIG_FILE_NAME`] as a fallback label,
448/// for the one caller that genuinely has no location of its own — an
449/// embedder pushing raw `brink.toml` text it read through its own host API,
450/// with no discovered key to give (`EditorSession::apply_project_config` in
451/// `brink-web`). A caller that *did* discover the file (walked up to find
452/// it, has a `SourceTree` key or filesystem path in hand) should call
453/// [`parse_str_at`] directly with that path instead.
454pub fn parse_str(text: &str) -> Result<(ProjectConfig, Vec<ConfigWarning>), ConfigError> {
455 parse_str_at(CONFIG_FILE_NAME, text)
456}
457
458/// [`parse_str`], attaching `path` to every [`ConfigError`] it raises
459/// (#1384) — the discovered file's `SourceTree` key or filesystem path,
460/// rendered into each variant's own `Display`. `ConfigError::Toml`'s message
461/// already named the line/column on its own, via the wrapped
462/// `toml::de::Error`'s own `Display` (see [`ConfigError::span`]) —
463/// independent of `path`; what threading `path` in adds is the file-name
464/// attribution.
465///
466/// Every discovery-based caller in the workspace has a path in scope at this
467/// point and should call this rather than [`parse_str`]:
468/// [`load_from_entry`], `brink-environment::resolve_options`, `brink ide`'s
469/// `resolve_analysis_options`, brink-web's `discover_project_config`, and
470/// the LSP's `resolve_language_options`.
471pub fn parse_str_at(
472 path: impl Into<String>,
473 text: &str,
474) -> Result<(ProjectConfig, Vec<ConfigWarning>), ConfigError> {
475 let path = path.into();
476 let doc: Value = toml::from_str(text).map_err(|source| ConfigError::Toml {
477 path: path.clone(),
478 source,
479 })?;
480 let root = match doc {
481 Value::Table(t) => t,
482 other => {
483 return Err(ConfigError::NotATable {
484 path,
485 key: "<root>".to_owned(),
486 found: value_type_name(&other),
487 });
488 }
489 };
490
491 let mut config = ProjectConfig::default();
492 let mut warnings = Vec::new();
493
494 for (key, value) in &root {
495 if key == "project" {
496 let project = match value {
497 Value::Table(t) => t,
498 other => {
499 return Err(ConfigError::NotATable {
500 path,
501 key: "project".to_owned(),
502 found: value_type_name(other),
503 });
504 }
505 };
506 parse_project_table(&path, project, &mut config, &mut warnings)?;
507 } else if key == "lints" {
508 let lints = match value {
509 Value::Table(t) => t,
510 other => {
511 return Err(ConfigError::NotATable {
512 path,
513 key: "lints".to_owned(),
514 found: value_type_name(other),
515 });
516 }
517 };
518 for (lkey, lvalue) in lints {
519 if lkey == "deny-warnings" {
520 config.deny_warnings = Some(parse_deny_warnings(&path, lkey, lvalue)?);
521 } else {
522 config
523 .lints
524 .insert(lkey.clone(), parse_lint_level(&path, lkey, lvalue)?);
525 }
526 }
527 } else {
528 warnings.push(ConfigWarning(format!(
529 "unknown top-level key `{key}` in {CONFIG_FILE_NAME} (ignored)"
530 )));
531 }
532 }
533
534 Ok((config, warnings))
535}
536
537/// Parse the `[project]` table's keys into `config`/`warnings` — the body
538/// [`parse_str_at`] used to inline directly before it grew too long
539/// (clippy's `too_many_lines`) once `conventions`/`elements` reconciliation
540/// (issue #2180) was added.
541fn parse_project_table(
542 path: &str,
543 project: &toml::map::Map<String, Value>,
544 config: &mut ProjectConfig,
545 warnings: &mut Vec<ConfigWarning>,
546) -> Result<(), ConfigError> {
547 // `conventions` (issue #2180) and its deprecated `elements` alias are
548 // collected separately, rather than writing straight into
549 // `config.conventions` inside the match arm below, and reconciled only
550 // after the whole `[project]` table has been walked. `toml::Table`'s
551 // iteration order is not "as written in the file" in general, so
552 // resolving "both keys set" precedence arm-by-arm as each key is
553 // visited would make the outcome depend on iteration order —
554 // collecting both first and resolving once afterward keeps it
555 // deterministic regardless of which key the file happens to list
556 // first.
557 let mut conventions_value: Option<String> = None;
558 let mut elements_value: Option<String> = None;
559 for (pkey, pvalue) in project {
560 match pkey.as_str() {
561 "dialect" => config.dialect = Some(parse_dialect(path, pkey, pvalue)?),
562 "types" => config.types = Some(parse_types(path, pkey, pvalue)?),
563 "unprune-dirs" => {
564 let dirs = parse_string_list(path, pkey, pvalue)?;
565 for dir in &dirs {
566 if !IGNORED_DIR_NAMES.contains(&dir.as_str()) {
567 warnings.push(ConfigWarning(format!(
568 "`project.unprune-dirs` entry `{dir}` in {CONFIG_FILE_NAME} is not \
569 one of {IGNORED_DIR_NAMES:?} — it was never pruned, so this has no \
570 effect (check for a typo)"
571 )));
572 }
573 }
574 config.unprune_dirs = dirs;
575 }
576 "conventions" => {
577 let s = parse_path_like_string(path, pkey, pvalue)?;
578 if s.is_empty() {
579 warnings.push(ConfigWarning(format!(
580 "`project.conventions` in {CONFIG_FILE_NAME} is an empty string \
581 (ignored) — expected a built-in preset name (e.g. \"screenplay\") or a \
582 path to a conventions module (e.g. \"conventions.brink\")"
583 )));
584 } else {
585 conventions_value = Some(s);
586 }
587 }
588 "elements" => {
589 let s = parse_path_like_string(path, pkey, pvalue)?;
590 if s.is_empty() {
591 warnings.push(ConfigWarning(format!(
592 "`project.elements` in {CONFIG_FILE_NAME} is an empty string (ignored) \
593 — expected a built-in preset name (e.g. \"screenplay\") or a path to a \
594 conventions module (e.g. \"conventions.brink\")"
595 )));
596 } else {
597 elements_value = Some(s);
598 }
599 }
600 "entry" => {
601 let s = parse_path_like_string(path, pkey, pvalue)?;
602 if s.is_empty() {
603 warnings.push(ConfigWarning(format!(
604 "`project.entry` in {CONFIG_FILE_NAME} is an empty string (ignored) — \
605 expected a project-relative path to the entry file (e.g. \
606 \"story.ink\")"
607 )));
608 } else {
609 config.entry = Some(s);
610 }
611 }
612 _ => warnings.push(ConfigWarning(format!(
613 "unknown key `project.{pkey}` in {CONFIG_FILE_NAME} (ignored)"
614 ))),
615 }
616 }
617 config.conventions = resolve_conventions_key(conventions_value, elements_value, warnings);
618 Ok(())
619}
620
621fn parse_dialect(path: &str, key: &str, value: &Value) -> Result<Dialect, ConfigError> {
622 let s = value.as_str().ok_or_else(|| ConfigError::WrongType {
623 path: path.to_owned(),
624 key: format!("project.{key}"),
625 found: value_type_name(value),
626 })?;
627 match s {
628 "brink" => Ok(Dialect::Brink),
629 "strict-ink" => Ok(Dialect::StrictInk),
630 other => Err(ConfigError::InvalidValue {
631 path: path.to_owned(),
632 key: format!("project.{key}"),
633 expected: &["brink", "strict-ink"],
634 found: other.to_owned(),
635 }),
636 }
637}
638
639/// Reconcile `[project] conventions` against its deprecated `elements`
640/// alias (issue #2180) into the one value [`ProjectConfig::conventions`]
641/// carries, pushing whatever [`ConfigWarning`]s the reconciliation itself
642/// warrants onto `warnings`.
643///
644/// `elements` is `conventions`'s deprecated predecessor (renamed post the
645/// `@[element]`/`@[convention]` split, docs/decision-log.md's 2026-08-03
646/// ruling) — accepted for a deprecation window rather than hard-broken,
647/// since it's a silent-misconfiguration risk otherwise (an existing
648/// project's `brink.toml` would stop configuring its conventions module
649/// with no error at all, just quietly-disabled `E169` enforcement).
650/// `conventions` always wins when both are set.
651fn resolve_conventions_key(
652 conventions_value: Option<String>,
653 elements_value: Option<String>,
654 warnings: &mut Vec<ConfigWarning>,
655) -> Option<String> {
656 match (conventions_value, elements_value) {
657 (Some(c), Some(_)) => {
658 warnings.push(ConfigWarning(format!(
659 "`project.elements` and `project.conventions` are both set in \
660 {CONFIG_FILE_NAME} — `project.elements` is deprecated (renamed to \
661 `project.conventions`, issue #2180) and was ignored in favor of \
662 `project.conventions`"
663 )));
664 Some(c)
665 }
666 (Some(c), None) => Some(c),
667 (None, Some(e)) => {
668 warnings.push(ConfigWarning(format!(
669 "`project.elements` in {CONFIG_FILE_NAME} is deprecated — rename to \
670 `project.conventions` (issue #2180: the key now names a module of \
671 `@[convention]` declarations, not `@[element]` ones)"
672 )));
673 Some(e)
674 }
675 (None, None) => None,
676 }
677}
678
679/// Parse a `[project]` key whose value is a bare project-relative path (or,
680/// for `conventions`/`elements`, a built-in preset name): `conventions`
681/// (§3.4's pointer mechanism), its deprecated `elements` alias (issue
682/// #2180 — the raw string shape is identical for either key), and `entry`
683/// (issue #2331) all share this exact validation. Accepts any non-empty
684/// string, since this crate doesn't know the closed set of built-in preset
685/// names and can't check a project path exists (kept dependency-free,
686/// #1234) — each caller in [`parse_project_table`] flags an empty string as
687/// a warning itself; this only enforces the TOML shape (a string, full
688/// stop). Checking a bare (preset-shaped) `conventions`/`elements` value
689/// against the real closed preset-name set is
690/// `brink-analyzer::AnalysisOptions::apply_project_config`'s job (issue
691/// #1874), the same "this crate stays dependency-free; the crate that owns
692/// the closed set validates" split `[lints]`'s `validate_lint_code` uses;
693/// `entry` has no preset-name form to check in the first place —
694/// resolving whether it names a real project file is `ProjectSession`'s job
695/// (`packages/ink-editor/src/project-session.ts`).
696fn parse_path_like_string(path: &str, key: &str, value: &Value) -> Result<String, ConfigError> {
697 value
698 .as_str()
699 .map(str::to_owned)
700 .ok_or_else(|| ConfigError::WrongType {
701 path: path.to_owned(),
702 key: format!("project.{key}"),
703 found: value_type_name(value),
704 })
705}
706
707fn parse_types(path: &str, key: &str, value: &Value) -> Result<TypePolicy, ConfigError> {
708 let s = value.as_str().ok_or_else(|| ConfigError::WrongType {
709 path: path.to_owned(),
710 key: format!("project.{key}"),
711 found: value_type_name(value),
712 })?;
713 match s {
714 "gradual" => Ok(TypePolicy::Gradual),
715 "strict" => Ok(TypePolicy::Strict),
716 other => Err(ConfigError::InvalidValue {
717 path: path.to_owned(),
718 key: format!("project.{key}"),
719 expected: &["gradual", "strict"],
720 found: other.to_owned(),
721 }),
722 }
723}
724
725fn parse_deny_warnings(path: &str, key: &str, value: &Value) -> Result<bool, ConfigError> {
726 value.as_bool().ok_or_else(|| ConfigError::WrongType {
727 path: path.to_owned(),
728 key: format!("lints.{key}"),
729 found: value_type_name(value),
730 })
731}
732
733fn parse_lint_level(path: &str, key: &str, value: &Value) -> Result<LintLevel, ConfigError> {
734 let s = value.as_str().ok_or_else(|| ConfigError::WrongType {
735 path: path.to_owned(),
736 key: format!("lints.{key}"),
737 found: value_type_name(value),
738 })?;
739 match s {
740 "allow" => Ok(LintLevel::Allow),
741 "warn" => Ok(LintLevel::Warn),
742 "deny" => Ok(LintLevel::Deny),
743 "info" => Ok(LintLevel::Info),
744 "hint" => Ok(LintLevel::Hint),
745 other => Err(ConfigError::InvalidValue {
746 path: path.to_owned(),
747 key: format!("lints.{key}"),
748 expected: &["allow", "warn", "deny", "info", "hint"],
749 found: other.to_owned(),
750 }),
751 }
752}
753
754/// Parse a TOML array-of-strings value (e.g. `[project] unprune-dirs`).
755/// Every element must itself be a string — a non-string element (`[1, 2]`,
756/// `[true]`) is [`ConfigError::WrongType`], matching the treatment every
757/// other recognized-but-wrong-shaped value gets.
758fn parse_string_list(path: &str, key: &str, value: &Value) -> Result<Vec<String>, ConfigError> {
759 let arr = value.as_array().ok_or_else(|| ConfigError::WrongType {
760 path: path.to_owned(),
761 key: format!("project.{key}"),
762 found: value_type_name(value),
763 })?;
764 arr.iter()
765 .map(|item| {
766 item.as_str()
767 .map(str::to_owned)
768 .ok_or_else(|| ConfigError::WrongType {
769 path: path.to_owned(),
770 key: format!("project.{key}"),
771 found: value_type_name(item),
772 })
773 })
774 .collect()
775}
776
777fn value_type_name(value: &Value) -> &'static str {
778 match value {
779 Value::String(_) => "string",
780 Value::Integer(_) => "integer",
781 Value::Float(_) => "float",
782 Value::Boolean(_) => "boolean",
783 Value::Datetime(_) => "datetime",
784 Value::Array(_) => "array",
785 Value::Table(_) => "table",
786 }
787}
788
789/// Maximum number of ancestor directories [`find_config`]'s walk will climb
790/// above `start_dir`, whether or not a `.git` boundary is ever found (#1435).
791///
792/// #1425 bounded the walk at a workspace/git boundary, but that boundary
793/// only exists for a project under version control — a VCS-less tree has no
794/// `.git` anywhere above it, so the walk still climbed all the way to the
795/// filesystem root, exactly the unbounded-ancestor-walk shape this
796/// codebase's "guard against unbounded growth" rule exists to catch. This
797/// cap closes that gap unconditionally: it applies to *every* walk, not just
798/// the VCS-less case, so the bound is one rule instead of two.
799///
800/// A fixed constant, not an environment- or filesystem-derived limit:
801/// config discovery is a deterministic-compilation input (#1306), so how far
802/// the walk climbs must never vary by machine, `$HOME` depth, or anything
803/// else runtime-observable — only by `start_dir` itself. 32 is generously
804/// above any real project layout in this workspace (the deepest nested
805/// fixture is a handful of levels) while still being nowhere near "walk to
806/// the filesystem root."
807pub const MAX_ANCESTOR_DEPTH: usize = 32;
808
809/// Walk up from `start_dir` (inclusive) through every ancestor directory,
810/// returning the path to the first [`CONFIG_FILE_NAME`] found. This is the
811/// "walk up from the entry file to the nearest `brink.toml`" discovery rule
812/// (#1005) — a project's entry `.ink` file doesn't have to sit directly
813/// beside the config for every mount to find the same one.
814///
815/// A thin wrapper over [`find_config_with_warnings`] that discards its
816/// [`ConfigWarning`]s — for callers with no warning channel of their own to
817/// report them through. A caller that *does* have one (the LSP's
818/// `tracing::warn!`, [`load_from_entry`]'s returned `Vec<ConfigWarning>` via
819/// [`discover_from_entry_with_warnings`]) should call
820/// [`find_config_with_warnings`] directly instead, per house rule 9 (silent
821/// drops are always bugs until proven otherwise).
822#[must_use]
823pub fn find_config(start_dir: &Path) -> Option<PathBuf> {
824 find_config_inner(start_dir, false).0
825}
826
827/// [`find_config`], additionally reporting when the bounded walk stepped
828/// over a `brink.toml` an author might reasonably have expected to be
829/// discovered (#1435) — never used as the result, only as a
830/// [`ConfigWarning`] so the caller can tell them it was ignored instead of
831/// silently proceeding as if no config existed anywhere.
832///
833/// **Bounded two ways**, either of which stops the search phase:
834///
835/// - **Workspace/git boundary (#1425).** Before checking a directory's
836/// parent, this stops if the directory itself contains a `.git` entry —
837/// the marker of a repository root, whether it's an ordinary repository
838/// (`.git/` is a directory) or a linked worktree (`.git` is a *file*
839/// holding a `gitdir:` pointer, e.g. `.claude/worktrees/*` in this very
840/// repo — checked with [`Path::exists`], not `is_dir`, so both shapes
841/// count; the marker name itself is [`brink_source_tree::GIT_DIR_NAME`],
842/// the same constant [`brink_source_tree::IGNORED_DIR_NAMES`] uses, so the
843/// two never drift apart, #1435).
844/// - **Ancestor depth cap ([`MAX_ANCESTOR_DEPTH`], #1435).** Applies
845/// regardless of any `.git` boundary — the VCS-less case #1425 didn't
846/// cover.
847///
848/// `start_dir` and every ancestor up to and including whichever boundary is
849/// hit first are still probed for `brink.toml` — only climbing *past* it is
850/// refused.
851///
852/// If neither bound stops the walk before it runs out of ancestors
853/// naturally (reaches the filesystem root with nothing found), the search is
854/// exhaustive and there is nothing above to warn about. If a bound *does*
855/// stop it short, a second, equally bounded probe continues past that point
856/// — read-only, purely to check whether a `brink.toml` exists somewhere
857/// above (walk-up call sites in this workspace: [`find_config`],
858/// `brink-lsp`'s `resolve_language_options`, `brink-driver`'s
859/// `native_source_root`) — and if one does, [`ConfigError`]-free but
860/// warning-worthy: the returned path is still `None` (it was never a
861/// candidate the bound allowed), but a [`ConfigWarning`] names it so the
862/// caller can tell the author their file was ignored.
863#[must_use]
864pub fn find_config_with_warnings(start_dir: &Path) -> (Option<PathBuf>, Vec<ConfigWarning>) {
865 find_config_inner(start_dir, true)
866}
867
868/// Shared implementation behind [`find_config`] and
869/// [`find_config_with_warnings`]. `want_warnings` gates the second, bounded
870/// probe past the stop point: [`find_config`] has nowhere to put a
871/// [`ConfigWarning`] it would only immediately discard, so it passes `false`
872/// and this function skips the probe's filesystem stats entirely instead of
873/// running them and throwing the result away — up to [`MAX_ANCESTOR_DEPTH`]
874/// (32) extra `is_file` calls per miss, climbing *past* the very
875/// git/depth boundary the bound exists to stay inside, was wasted work every
876/// discarding caller paid for unconditionally (review finding on #1435).
877fn find_config_inner(
878 start_dir: &Path,
879 want_warnings: bool,
880) -> (Option<PathBuf>, Vec<ConfigWarning>) {
881 let mut dir = Some(start_dir);
882 let mut depth = 0usize;
883 // Where (and why) the primary search stopped short of the filesystem
884 // root, if it did — `None` means it ran out of ancestors naturally.
885 let mut stopped_at: Option<(PathBuf, &'static str)> = None;
886
887 while let Some(d) = dir {
888 let candidate = d.join(CONFIG_FILE_NAME);
889 if candidate.is_file() {
890 return (Some(candidate), Vec::new());
891 }
892 if d.join(brink_source_tree::GIT_DIR_NAME).exists() {
893 // Workspace/git boundary: this directory is the repository
894 // root (or a linked worktree's root) and had no `brink.toml`
895 // of its own — do not climb past it.
896 stopped_at = Some((d.to_path_buf(), "workspace/git boundary"));
897 break;
898 }
899 if depth >= MAX_ANCESTOR_DEPTH {
900 // Ancestor depth cap: no `.git` boundary was found within
901 // MAX_ANCESTOR_DEPTH climbs — do not climb further.
902 stopped_at = Some((d.to_path_buf(), "ancestor depth limit"));
903 break;
904 }
905 depth += 1;
906 dir = d.parent();
907 }
908
909 let Some((stopped_at, reason)) = stopped_at else {
910 // The walk exhausted every real ancestor without hitting either
911 // bound — there is nothing further up to have missed.
912 return (None, Vec::new());
913 };
914
915 if !want_warnings {
916 // No warning channel to report through — skip the probe rather than
917 // running it and discarding the result (#1435 review finding).
918 return (None, Vec::new());
919 }
920
921 // Bounded peek past the stop point, purely to detect a stray config an
922 // author might expect to be picked up — its existence is reported as a
923 // warning, but it is never returned as a result. Bounded by the same
924 // cap so this detection pass cannot itself become an unbounded climb.
925 let mut probe = stopped_at.parent();
926 let mut probe_depth = 0usize;
927 while let Some(p) = probe {
928 let candidate = p.join(CONFIG_FILE_NAME);
929 if candidate.is_file() {
930 return (
931 None,
932 vec![ConfigWarning(format!(
933 "{} exists above the {reason} at {} and was ignored",
934 candidate.display(),
935 stopped_at.display(),
936 ))],
937 );
938 }
939 probe_depth += 1;
940 if probe_depth >= MAX_ANCESTOR_DEPTH {
941 break;
942 }
943 probe = p.parent();
944 }
945
946 (None, Vec::new())
947}
948
949/// [`find_config`], starting from an entry `.ink` file's directory rather
950/// than a directory directly. The common case: `brink compile story.ink`
951/// discovers `brink.toml` starting from `story.ink`'s parent.
952#[must_use]
953pub fn discover_from_entry(entry_file: &Path) -> Option<PathBuf> {
954 let start = entry_file.parent().unwrap_or_else(|| Path::new("."));
955 find_config(start)
956}
957
958/// [`discover_from_entry`], surfacing [`find_config_with_warnings`]'s
959/// [`ConfigWarning`]s instead of discarding them. [`load_from_entry`] uses
960/// this rather than [`discover_from_entry`] so a config skipped by the
961/// bounded walk is never silently dropped (#1435, house rule 9).
962#[must_use]
963pub fn discover_from_entry_with_warnings(
964 entry_file: &Path,
965) -> (Option<PathBuf>, Vec<ConfigWarning>) {
966 let start = entry_file.parent().unwrap_or_else(|| Path::new("."));
967 find_config_with_warnings(start)
968}
969
970/// [`find_config`], but discovering over a [`SourceTree`] rather than the
971/// real filesystem (#1312) — mount-agnostic: the same walk-up rule serves
972/// the CLI's `RealFs` mount, a wasm sandbox's `InMemory` mount, a git
973/// baseline's `GitRev` mount, or any future host, with no per-mount
974/// discovery code duplicated outside the seam.
975///
976/// `start_key` is a root-relative directory key (`""` for the tree root
977/// itself), in the same forward-slash-joined form [`SourceTree::list`]
978/// returns. Walks `start_key` and every ancestor, closest first, probing
979/// whether `{ancestor}/brink.toml` (bare `brink.toml` at the tree root)
980/// exists via a direct [`SourceTree::read`] of each candidate key — the
981/// tree-relative analog of [`find_config`]'s `Path::is_file` check at each
982/// `Path::parent`.
983///
984/// This is an O(depth) probe, **not** a tree enumeration: unlike an earlier
985/// version of this function, it never calls [`SourceTree::list`] (issue
986/// #1370 — a full recursive tree walk, including `target/`/`.git`/
987/// `node_modules`, just to test a handful of ancestor candidates was the
988/// same waste #1357 removed from the CLI drain, relocated here). A `read`
989/// that fails with [`io::ErrorKind::NotFound`] means "no `brink.toml` at
990/// this candidate, keep walking up"; any other error kind (permission
991/// denied, invalid encoding, ...) means a `brink.toml` *exists* at this
992/// candidate but this probe couldn't read it — treated as "found" (returns
993/// `Some(candidate)`) rather than propagated, so the caller's own
994/// [`SourceTree::read`] of the returned key is what actually surfaces the
995/// failure, with the path correctly attributed (see `brink-environment`'s
996/// `LoadError::ConfigRead`, #1369). Propagating this probe's own read error
997/// instead would report the same failure without a path — issue #1370's
998/// fix regressed exactly that for a moment before this doc/behavior was
999/// tightened; `tree`'s own [`SourceTree::read`] already resolves keys
1000/// against whatever root the tree was constructed with, so this function
1001/// needs no enumeration to know where to look.
1002///
1003/// Takes no `root` parameter: every current [`SourceTree`] implementation
1004/// resolves `read` keys against its own constructor-held root (issue #1371),
1005/// so there is nothing for a caller to supply here. An earlier version of
1006/// this function accepted (and ignored) a `root: &Path` for shape-symmetry
1007/// with [`SourceTree::list`]'s old signature; issue #1395 dropped it once
1008/// #1371 made the equivalent parameter dead on `list` too, closing the gap
1009/// left when this function's own dead parameter wasn't swept up at the same
1010/// time.
1011///
1012/// Returns the matching key, not file content — callers read it via
1013/// [`SourceTree::read`] (mirroring how [`find_config`] returns a path the
1014/// caller reads via `std::fs`, not file content).
1015///
1016/// Already bounded at the tree's own root, so it needed no change for #1425
1017/// or #1435 (unlike [`find_config`]'s `.git`-directory and
1018/// [`MAX_ANCESTOR_DEPTH`] bounds): a key's ancestors are string-derived
1019/// (`rsplit_once('/')`), bottoming out at the empty root key with nothing
1020/// further to strip — there is no lexical equivalent of `find_config`'s
1021/// `Path::parent` climb here for a depth cap to even apply to. It can only
1022/// ever "escape" the project if the `tree` itself is rooted somewhere too
1023/// wide (a caller concern, not this function's).
1024pub fn find_config_in_tree(tree: &dyn SourceTree, start_key: &str) -> io::Result<Option<String>> {
1025 let mut dir = start_key.trim_matches('/');
1026 loop {
1027 let candidate = if dir.is_empty() {
1028 CONFIG_FILE_NAME.to_owned()
1029 } else {
1030 format!("{dir}/{CONFIG_FILE_NAME}")
1031 };
1032 match tree.read(&candidate) {
1033 Err(err) if err.kind() == io::ErrorKind::NotFound => {}
1034 // Found: either the read actually succeeded, or it failed with
1035 // some other error kind — which, under the `SourceTree::read`
1036 // contract, implies the candidate exists but this probe read
1037 // just couldn't consume it. Report it as found either way; the
1038 // caller's own read of the same key is what turns a probe-read
1039 // failure into a path-attributed error (`LoadError::ConfigRead`)
1040 // instead of a bare, pathless one.
1041 Ok(_) | Err(_) => return Ok(Some(candidate)),
1042 }
1043 if dir.is_empty() {
1044 return Ok(None);
1045 }
1046 dir = match dir.rsplit_once('/') {
1047 Some((parent, _)) => parent,
1048 None => "",
1049 };
1050 }
1051}
1052
1053/// [`find_config_in_tree`], starting from an entry `.brink`/`.ink` file's
1054/// root-relative key rather than a directory key directly — the
1055/// [`SourceTree`] analog of [`discover_from_entry`].
1056pub fn discover_from_entry_in_tree(
1057 tree: &dyn SourceTree,
1058 entry_key: &str,
1059) -> io::Result<Option<String>> {
1060 let start = match entry_key.trim_matches('/').rsplit_once('/') {
1061 Some((parent, _)) => parent,
1062 None => "",
1063 };
1064 find_config_in_tree(tree, start)
1065}
1066
1067/// Discover (via [`discover_from_entry_with_warnings`]) and parse (via
1068/// [`parse_str`]) the `brink.toml` governing `entry_file`'s project, if one
1069/// exists.
1070///
1071/// Returns `Ok((None, warnings))` — never an error — when no `brink.toml` is
1072/// found within the bounded walk (see [`find_config_with_warnings`]):
1073/// `warnings` is empty in the ordinary "genuinely no config anywhere" case
1074/// (current behavior exactly, no regression), and carries a
1075/// [`ConfigWarning`] in the `#1435` case — a `brink.toml` existed above the
1076/// walk's workspace/git or ancestor-depth bound and was skipped. Discovery
1077/// warnings are returned alongside the result rather than folded into
1078/// [`LoadedConfig::warnings`] because there is no [`LoadedConfig`] to hold
1079/// them when nothing was loaded; when a config *is* found, this vec is
1080/// always empty and [`LoadedConfig::warnings`] (the file's own parse-time
1081/// warnings) is the vec to read instead.
1082pub fn load_from_entry(
1083 entry_file: &Path,
1084) -> Result<(Option<LoadedConfig>, Vec<ConfigWarning>), ConfigError> {
1085 let (path, discovery_warnings) = discover_from_entry_with_warnings(entry_file);
1086 let Some(path) = path else {
1087 return Ok((None, discovery_warnings));
1088 };
1089 let text = std::fs::read_to_string(&path).map_err(|source| ConfigError::Io {
1090 path: path.clone(),
1091 source,
1092 })?;
1093 let (config, warnings) = parse_str_at(path.display().to_string(), &text)?;
1094 Ok((
1095 Some(LoadedConfig {
1096 path,
1097 config,
1098 warnings,
1099 }),
1100 Vec::new(),
1101 ))
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106 use super::*;
1107
1108 // ── parse_str ────────────────────────────────────────────────────
1109
1110 #[test]
1111 fn empty_document_is_empty_config_no_warnings() {
1112 let (config, warnings) = parse_str("").unwrap();
1113 assert_eq!(config, ProjectConfig::default());
1114 assert!(config.is_empty());
1115 assert!(warnings.is_empty());
1116 }
1117
1118 #[test]
1119 fn parses_dialect_and_types() {
1120 let (config, warnings) = parse_str(
1121 r#"
1122 [project]
1123 dialect = "brink"
1124 types = "strict"
1125 "#,
1126 )
1127 .unwrap();
1128 assert_eq!(config.dialect, Some(Dialect::Brink));
1129 assert_eq!(config.types, Some(TypePolicy::Strict));
1130 assert!(warnings.is_empty());
1131 }
1132
1133 #[test]
1134 fn parses_strict_ink_and_gradual() {
1135 let (config, warnings) = parse_str(
1136 r#"
1137 [project]
1138 dialect = "strict-ink"
1139 types = "gradual"
1140 "#,
1141 )
1142 .unwrap();
1143 assert_eq!(config.dialect, Some(Dialect::StrictInk));
1144 assert_eq!(config.types, Some(TypePolicy::Gradual));
1145 assert!(warnings.is_empty());
1146 }
1147
1148 #[test]
1149 fn partial_project_table_leaves_other_field_none() {
1150 let (config, _) = parse_str("[project]\ndialect = \"brink\"\n").unwrap();
1151 assert_eq!(config.dialect, Some(Dialect::Brink));
1152 assert_eq!(config.types, None);
1153 }
1154
1155 #[test]
1156 fn unknown_top_level_key_warns_not_errors() {
1157 let (config, warnings) = parse_str("future_section = 1\n").unwrap();
1158 assert!(config.is_empty());
1159 assert_eq!(warnings.len(), 1);
1160 assert!(warnings[0].0.contains("future_section"));
1161 }
1162
1163 #[test]
1164 fn unknown_project_key_warns_not_errors() {
1165 let (config, warnings) =
1166 parse_str("[project]\ndialect = \"brink\"\nfuture_key = \"x\"\n").unwrap();
1167 assert_eq!(config.dialect, Some(Dialect::Brink));
1168 assert_eq!(warnings.len(), 1);
1169 assert!(warnings[0].0.contains("project.future_key"));
1170 }
1171
1172 // ── unprune-dirs (issue #1407) ──────────────────────────────────────
1173
1174 #[test]
1175 fn parses_unprune_dirs() {
1176 let (config, warnings) = parse_str(
1177 r#"
1178 [project]
1179 unprune-dirs = ["node_modules", "target"]
1180 "#,
1181 )
1182 .unwrap();
1183 assert_eq!(
1184 config.unprune_dirs,
1185 vec!["node_modules".to_string(), "target".to_string()]
1186 );
1187 assert!(!config.is_empty());
1188 assert!(
1189 warnings.is_empty(),
1190 "both names are real IGNORED_DIR_NAMES entries, no warning expected: {warnings:?}"
1191 );
1192 }
1193
1194 /// An `unprune-dirs` entry that isn't one of the three actually-pruned
1195 /// names is a no-op (there was nothing to un-prune) — likely a typo, so
1196 /// it warns rather than silently doing nothing (house-rule "validate
1197 /// user-supplied config keys").
1198 #[test]
1199 fn unprune_dirs_entry_outside_ignored_dir_names_warns() {
1200 let (config, warnings) = parse_str(
1201 r#"
1202 [project]
1203 unprune-dirs = ["node-modules"]
1204 "#,
1205 )
1206 .unwrap();
1207 assert_eq!(config.unprune_dirs, vec!["node-modules".to_string()]);
1208 assert_eq!(warnings.len(), 1);
1209 assert!(warnings[0].0.contains("node-modules"));
1210 assert!(warnings[0].0.contains("unprune-dirs"));
1211 }
1212
1213 #[test]
1214 fn unprune_dirs_wrong_element_type_is_an_error() {
1215 let err = parse_str("[project]\nunprune-dirs = [1, 2]\n").unwrap_err();
1216 assert!(matches!(err, ConfigError::WrongType { .. }));
1217 }
1218
1219 #[test]
1220 fn unprune_dirs_not_an_array_is_an_error() {
1221 let err = parse_str("[project]\nunprune-dirs = \"node_modules\"\n").unwrap_err();
1222 assert!(matches!(err, ConfigError::WrongType { .. }));
1223 }
1224
1225 #[test]
1226 fn empty_unprune_dirs_is_not_a_warning_and_leaves_config_empty_by_itself() {
1227 let (config, warnings) = parse_str("[project]\nunprune-dirs = []\n").unwrap();
1228 assert!(config.unprune_dirs.is_empty());
1229 assert!(warnings.is_empty());
1230 // An explicit empty array still counts as "set" for `is_empty()`'s
1231 // purposes only if non-empty — an empty list is indistinguishable
1232 // from unset here, matching `lints`' own empty-map convention.
1233 assert!(config.is_empty());
1234 }
1235
1236 #[test]
1237 fn invalid_dialect_value_is_an_error() {
1238 let err = parse_str("[project]\ndialect = \"sideways\"\n").unwrap_err();
1239 assert!(matches!(err, ConfigError::InvalidValue { .. }));
1240 }
1241
1242 // ── conventions (issue #1844, renamed from `elements` by #2180) ──────
1243
1244 #[test]
1245 fn parses_conventions_as_a_path() {
1246 let (config, warnings) = parse_str(
1247 r#"
1248 [project]
1249 conventions = "conventions.brink"
1250 "#,
1251 )
1252 .unwrap();
1253 assert_eq!(config.conventions.as_deref(), Some("conventions.brink"));
1254 assert!(!config.is_empty());
1255 assert!(warnings.is_empty(), "{warnings:?}");
1256 }
1257
1258 #[test]
1259 fn parses_conventions_as_a_preset_name() {
1260 let (config, _warnings) = parse_str("[project]\nconventions = \"screenplay\"\n").unwrap();
1261 assert_eq!(config.conventions.as_deref(), Some("screenplay"));
1262 }
1263
1264 #[test]
1265 fn empty_conventions_string_warns_and_is_not_set() {
1266 let (config, warnings) = parse_str("[project]\nconventions = \"\"\n").unwrap();
1267 assert_eq!(config.conventions, None);
1268 assert!(config.is_empty());
1269 assert_eq!(warnings.len(), 1);
1270 assert!(warnings[0].0.contains("conventions"));
1271 }
1272
1273 #[test]
1274 fn conventions_wrong_type_is_an_error() {
1275 let err = parse_str("[project]\nconventions = 1\n").unwrap_err();
1276 assert!(matches!(err, ConfigError::WrongType { .. }));
1277 }
1278
1279 #[test]
1280 fn unset_conventions_leaves_config_empty_by_itself() {
1281 let (config, _warnings) = parse_str("[project]\ndialect = \"brink\"\n").unwrap();
1282 assert_eq!(config.conventions, None);
1283 }
1284
1285 // ── entry (issue #2331, ruled 2026-08-07) ────────────────────────────
1286
1287 #[test]
1288 fn parses_entry_as_a_project_relative_path() {
1289 let (config, warnings) = parse_str(
1290 r#"
1291 [project]
1292 entry = "story.ink"
1293 "#,
1294 )
1295 .unwrap();
1296 assert_eq!(config.entry.as_deref(), Some("story.ink"));
1297 assert!(!config.is_empty());
1298 assert!(warnings.is_empty(), "{warnings:?}");
1299 }
1300
1301 #[test]
1302 fn parses_entry_nested_under_a_directory() {
1303 let (config, _warnings) =
1304 parse_str("[project]\nentry = \"chapters/main.brink\"\n").unwrap();
1305 assert_eq!(config.entry.as_deref(), Some("chapters/main.brink"));
1306 }
1307
1308 #[test]
1309 fn empty_entry_string_warns_and_is_not_set() {
1310 let (config, warnings) = parse_str("[project]\nentry = \"\"\n").unwrap();
1311 assert_eq!(config.entry, None);
1312 assert!(config.is_empty());
1313 assert_eq!(warnings.len(), 1);
1314 assert!(warnings[0].0.contains("entry"));
1315 }
1316
1317 #[test]
1318 fn entry_wrong_type_is_an_error() {
1319 let err = parse_str("[project]\nentry = 1\n").unwrap_err();
1320 assert!(matches!(err, ConfigError::WrongType { .. }));
1321 }
1322
1323 #[test]
1324 fn unset_entry_leaves_config_empty_by_itself() {
1325 let (config, _warnings) = parse_str("[project]\ndialect = \"brink\"\n").unwrap();
1326 assert_eq!(config.entry, None);
1327 }
1328
1329 #[test]
1330 fn entry_and_conventions_coexist_independently() {
1331 let (config, warnings) =
1332 parse_str("[project]\nentry = \"story.ink\"\nconventions = \"conventions.brink\"\n")
1333 .unwrap();
1334 assert_eq!(config.entry.as_deref(), Some("story.ink"));
1335 assert_eq!(config.conventions.as_deref(), Some("conventions.brink"));
1336 assert!(warnings.is_empty(), "{warnings:?}");
1337 }
1338
1339 // ── `elements` deprecated alias (issue #2180) ────────────────────────
1340
1341 /// The old key still works — a hard break would silently un-configure
1342 /// every existing project's conventions module (and its `E169`
1343 /// enforcement) the moment it upgrades, with no error at all.
1344 #[test]
1345 fn elements_alias_still_sets_conventions_but_warns() {
1346 let (config, warnings) = parse_str("[project]\nelements = \"conventions.brink\"\n")
1347 .expect("deprecated `elements` key must still parse, not hard-error");
1348 assert_eq!(config.conventions.as_deref(), Some("conventions.brink"));
1349 assert_eq!(warnings.len(), 1, "{warnings:?}");
1350 assert!(warnings[0].0.contains("project.elements"));
1351 assert!(warnings[0].0.contains("deprecated"));
1352 assert!(warnings[0].0.contains("project.conventions"));
1353 }
1354
1355 #[test]
1356 fn empty_elements_alias_string_warns_and_is_not_set() {
1357 let (config, warnings) = parse_str("[project]\nelements = \"\"\n").unwrap();
1358 assert_eq!(config.conventions, None);
1359 assert!(config.is_empty());
1360 // Only the empty-string warning fires — an empty value never
1361 // reaches `elements_value`, so there is nothing to also warn as a
1362 // deprecated-but-set alias.
1363 assert_eq!(warnings.len(), 1, "{warnings:?}");
1364 assert!(warnings[0].0.contains("elements"));
1365 }
1366
1367 #[test]
1368 fn elements_alias_wrong_type_is_an_error() {
1369 let err = parse_str("[project]\nelements = 1\n").unwrap_err();
1370 assert!(matches!(err, ConfigError::WrongType { .. }));
1371 }
1372
1373 /// `conventions` always wins when both keys are set — and the conflict
1374 /// itself is warned about, so an author isn't left guessing which value
1375 /// took effect.
1376 #[test]
1377 fn both_conventions_and_elements_set_prefers_conventions_and_warns() {
1378 let (config, warnings) = parse_str(
1379 r#"
1380 [project]
1381 conventions = "new.brink"
1382 elements = "old.brink"
1383 "#,
1384 )
1385 .unwrap();
1386 assert_eq!(config.conventions.as_deref(), Some("new.brink"));
1387 assert_eq!(warnings.len(), 1, "{warnings:?}");
1388 assert!(warnings[0].0.contains("project.elements"));
1389 assert!(warnings[0].0.contains("project.conventions"));
1390 assert!(warnings[0].0.contains("both set"));
1391 }
1392
1393 #[test]
1394 fn invalid_types_value_is_an_error() {
1395 let err = parse_str("[project]\ntypes = \"loose\"\n").unwrap_err();
1396 assert!(matches!(err, ConfigError::InvalidValue { .. }));
1397 }
1398
1399 #[test]
1400 fn wrong_type_value_is_an_error() {
1401 let err = parse_str("[project]\ndialect = 1\n").unwrap_err();
1402 assert!(matches!(err, ConfigError::WrongType { .. }));
1403 }
1404
1405 #[test]
1406 fn malformed_toml_is_an_error() {
1407 let err = parse_str("this is not [ toml").unwrap_err();
1408 assert!(matches!(err, ConfigError::Toml { .. }));
1409 }
1410
1411 #[test]
1412 fn non_table_root_is_an_error() {
1413 let err = parse_str("\"just a string\"").unwrap_err();
1414 assert!(matches!(
1415 err,
1416 ConfigError::NotATable { .. } | ConfigError::Toml { .. }
1417 ));
1418 }
1419
1420 // ── path/span threading (#1384) ─────────────────────────────────────
1421
1422 /// Every [`ConfigError`] channel names the file it came from — the CLI
1423 /// message, the LSP diagnostic, and now (#1384) the error's own
1424 /// `Display`, structurally rather than by convention at each call site.
1425 #[test]
1426 fn parse_str_at_names_its_path_on_invalid_value() {
1427 let err =
1428 parse_str_at("chapters/brink.toml", "[project]\ndialect = \"sideways\"\n").unwrap_err();
1429 assert_eq!(err.path(), "chapters/brink.toml");
1430 assert!(
1431 err.to_string().contains("chapters/brink.toml"),
1432 "message must name the file, got: {err}"
1433 );
1434 assert!(
1435 matches!(err, ConfigError::InvalidValue { .. }),
1436 "expected InvalidValue, got: {err:?}"
1437 );
1438 }
1439
1440 #[test]
1441 fn parse_str_at_names_its_path_on_malformed_toml() {
1442 let err = parse_str_at("chapters/brink.toml", "this is not [ toml").unwrap_err();
1443 assert_eq!(err.path(), "chapters/brink.toml");
1444 assert!(
1445 err.to_string().contains("chapters/brink.toml"),
1446 "message must name the file, got: {err}"
1447 );
1448 assert!(
1449 matches!(err, ConfigError::Toml { .. }),
1450 "expected Toml, got: {err:?}"
1451 );
1452 }
1453
1454 #[test]
1455 fn parse_str_at_names_its_path_on_wrong_type() {
1456 let err = parse_str_at("chapters/brink.toml", "[project]\ndialect = 1\n").unwrap_err();
1457 assert_eq!(err.path(), "chapters/brink.toml");
1458 assert!(err.to_string().contains("chapters/brink.toml"));
1459 assert!(
1460 matches!(err, ConfigError::WrongType { .. }),
1461 "expected WrongType, got: {err:?}"
1462 );
1463 }
1464
1465 #[test]
1466 fn parse_str_at_names_its_path_on_not_a_table() {
1467 // `project = 1` parses fine as TOML (root table with an integer
1468 // value), so this exercises `NotATable`, not `Toml` — a bare string
1469 // like `"just a string"` is invalid TOML *syntax* and would hit the
1470 // `Toml` arm instead, duplicating the malformed-syntax test above and
1471 // leaving `NotATable`'s `path` field uncovered.
1472 let err = parse_str_at("chapters/brink.toml", "project = 1\n").unwrap_err();
1473 assert_eq!(err.path(), "chapters/brink.toml");
1474 assert!(err.to_string().contains("chapters/brink.toml"));
1475 assert!(
1476 matches!(err, ConfigError::NotATable { .. }),
1477 "expected NotATable, got: {err:?}"
1478 );
1479 }
1480
1481 /// `parse_str` (the pathless entry point) still falls back to the bare
1482 /// [`CONFIG_FILE_NAME`] rather than an empty/absent path — a caller with
1483 /// no discovered location still gets a named, non-empty `path()`.
1484 #[test]
1485 fn parse_str_falls_back_to_config_file_name_as_path() {
1486 let err = parse_str("[project]\ndialect = \"sideways\"\n").unwrap_err();
1487 assert_eq!(err.path(), CONFIG_FILE_NAME);
1488 }
1489
1490 /// Malformed TOML *syntax* carries a byte span from the underlying
1491 /// `toml` crate — a malformed value's line, not just its file, is
1492 /// locatable (#1384's "a malformed value cannot be located precisely"
1493 /// gap, for the syntax-error half of it). The span must point at the
1494 /// actual offending text, not just be present.
1495 #[test]
1496 fn toml_syntax_error_carries_a_span_pointing_at_the_bad_text() {
1497 let text = "[project]\ndialect = \"brink\" oops\n";
1498 let err = parse_str_at("brink.toml", text).unwrap_err();
1499 let span = err.span().expect("malformed TOML syntax must carry a span");
1500 assert!(span.start > 0, "span must not point at the file start");
1501 // The reported range must fall on the malformed second line, not the
1502 // first (well-formed) line.
1503 let first_line_end = text.find('\n').unwrap();
1504 assert!(
1505 span.start > first_line_end,
1506 "span {span:?} must point past the first line (ends at {first_line_end})"
1507 );
1508 }
1509
1510 /// `InvalidValue` fires *after* the document parses successfully (a
1511 /// syntactically fine string that just isn't a recognized variant), so
1512 /// there is no narrower-than-file location available — `span()` must be
1513 /// `None`, not a stale or zeroed range that looks meaningful but isn't.
1514 #[test]
1515 fn invalid_value_error_has_no_span() {
1516 let err = parse_str_at("brink.toml", "[project]\ndialect = \"sideways\"\n").unwrap_err();
1517 assert_eq!(err.span(), None);
1518 }
1519
1520 // ── [lints] ──────────────────────────────────────────────────────
1521
1522 #[test]
1523 fn parses_per_code_lint_levels() {
1524 let (config, warnings) = parse_str(
1525 r#"
1526 [lints]
1527 E063 = "deny"
1528 E014 = "allow"
1529 E022 = "warn"
1530 "#,
1531 )
1532 .unwrap();
1533 assert_eq!(config.lints.get("E063"), Some(&LintLevel::Deny));
1534 assert_eq!(config.lints.get("E014"), Some(&LintLevel::Allow));
1535 assert_eq!(config.lints.get("E022"), Some(&LintLevel::Warn));
1536 assert!(warnings.is_empty());
1537 }
1538
1539 /// #1162: `[lints]` must be able to down-level a code to either advisory
1540 /// tier below `Warning`, not just `allow`/`warn`/`deny`.
1541 #[test]
1542 fn parses_info_and_hint_lint_levels() {
1543 let (config, warnings) = parse_str(
1544 r#"
1545 [lints]
1546 E014 = "info"
1547 E022 = "hint"
1548 "#,
1549 )
1550 .unwrap();
1551 assert_eq!(config.lints.get("E014"), Some(&LintLevel::Info));
1552 assert_eq!(config.lints.get("E022"), Some(&LintLevel::Hint));
1553 assert!(warnings.is_empty());
1554 }
1555
1556 #[test]
1557 fn parses_deny_warnings_flag() {
1558 let (config, _) = parse_str("[lints]\ndeny-warnings = true\n").unwrap();
1559 assert_eq!(config.deny_warnings, Some(true));
1560 }
1561
1562 #[test]
1563 fn deny_warnings_and_codes_coexist() {
1564 let (config, _) = parse_str(
1565 r#"
1566 [lints]
1567 deny-warnings = true
1568 E063 = "allow"
1569 "#,
1570 )
1571 .unwrap();
1572 assert_eq!(config.deny_warnings, Some(true));
1573 assert_eq!(config.lints.get("E063"), Some(&LintLevel::Allow));
1574 }
1575
1576 #[test]
1577 fn absent_lints_table_is_empty_config() {
1578 let (config, _) = parse_str("[project]\ndialect = \"brink\"\n").unwrap();
1579 assert!(config.lints.is_empty());
1580 assert_eq!(config.deny_warnings, None);
1581 }
1582
1583 #[test]
1584 fn invalid_lint_level_value_is_an_error() {
1585 let err = parse_str("[lints]\nE063 = \"sideways\"\n").unwrap_err();
1586 assert!(matches!(err, ConfigError::InvalidValue { .. }));
1587 }
1588
1589 #[test]
1590 fn wrong_type_deny_warnings_is_an_error() {
1591 let err = parse_str("[lints]\ndeny-warnings = \"yes\"\n").unwrap_err();
1592 assert!(matches!(err, ConfigError::WrongType { .. }));
1593 }
1594
1595 #[test]
1596 fn wrong_type_lint_level_is_an_error() {
1597 let err = parse_str("[lints]\nE063 = 1\n").unwrap_err();
1598 assert!(matches!(err, ConfigError::WrongType { .. }));
1599 }
1600
1601 #[test]
1602 fn non_table_lints_is_an_error() {
1603 let err = parse_str("lints = 1\n").unwrap_err();
1604 assert!(matches!(err, ConfigError::NotATable { .. }));
1605 }
1606
1607 // ── discovery ─────────────────────────────────────────────────────
1608
1609 fn unique_tmp_dir(tag: &str) -> PathBuf {
1610 let mut dir = std::env::temp_dir();
1611 dir.push(format!(
1612 "brink-project-config-test-{tag}-{}-{:?}",
1613 std::process::id(),
1614 std::time::SystemTime::now()
1615 .duration_since(std::time::UNIX_EPOCH)
1616 .unwrap_or_default()
1617 .as_nanos()
1618 ));
1619 dir
1620 }
1621
1622 #[test]
1623 fn find_config_walks_up_from_start_dir() {
1624 let root = unique_tmp_dir("walk-up");
1625 let nested = root.join("a").join("b");
1626 std::fs::create_dir_all(&nested).unwrap();
1627 std::fs::write(
1628 root.join(CONFIG_FILE_NAME),
1629 "[project]\ndialect = \"brink\"\n",
1630 )
1631 .unwrap();
1632
1633 let found = find_config(&nested).expect("should find brink.toml in an ancestor");
1634 assert_eq!(found, root.join(CONFIG_FILE_NAME));
1635
1636 std::fs::remove_dir_all(&root).unwrap();
1637 }
1638
1639 #[test]
1640 fn find_config_returns_none_when_absent() {
1641 let root = unique_tmp_dir("absent");
1642 std::fs::create_dir_all(&root).unwrap();
1643 assert_eq!(find_config(&root), None);
1644 std::fs::remove_dir_all(&root).unwrap();
1645 }
1646
1647 // ── workspace/git boundary (#1425) ──────────────────────────────────
1648
1649 /// The walk must not climb past a directory containing a `.git`
1650 /// subdirectory — an unrelated `brink.toml` sitting further up (outside
1651 /// the repository) must never be picked up.
1652 #[test]
1653 fn find_config_stops_at_git_dir_boundary() {
1654 let root = unique_tmp_dir("git-boundary-dir");
1655 let repo = root.join("repo");
1656 let nested = repo.join("a").join("b");
1657 std::fs::create_dir_all(&nested).unwrap();
1658 std::fs::create_dir_all(repo.join(".git")).unwrap();
1659 // Stray config *above* the repository root — must never be found.
1660 std::fs::write(
1661 root.join(CONFIG_FILE_NAME),
1662 "[project]\ndialect = \"brink\"\n",
1663 )
1664 .unwrap();
1665
1666 assert_eq!(
1667 find_config(&nested),
1668 None,
1669 "must not climb past the .git-marked repository root to a stray ancestor config"
1670 );
1671
1672 std::fs::remove_dir_all(&root).unwrap();
1673 }
1674
1675 /// The boundary check also fires when `.git` is a *file* rather than a
1676 /// directory — the shape a linked git worktree uses (a `gitdir:` pointer
1677 /// file, exactly how this repository's own `.claude/worktrees/*` are
1678 /// laid out), not just an ordinary clone's `.git/` directory.
1679 #[test]
1680 fn find_config_stops_at_git_file_boundary_worktree_shape() {
1681 let root = unique_tmp_dir("git-boundary-file");
1682 let repo = root.join("repo");
1683 let nested = repo.join("a").join("b");
1684 std::fs::create_dir_all(&nested).unwrap();
1685 std::fs::write(repo.join(".git"), "gitdir: /elsewhere/.git/worktrees/x\n").unwrap();
1686 std::fs::write(
1687 root.join(CONFIG_FILE_NAME),
1688 "[project]\ndialect = \"brink\"\n",
1689 )
1690 .unwrap();
1691
1692 assert_eq!(
1693 find_config(&nested),
1694 None,
1695 "a `.git` worktree-pointer *file* must bound the walk exactly like a `.git` dir"
1696 );
1697
1698 std::fs::remove_dir_all(&root).unwrap();
1699 }
1700
1701 /// The boundary directory itself (the one holding `.git`) is still
1702 /// checked for `brink.toml` before the walk refuses to climb further —
1703 /// bounding the walk must not also blind it to a config at the boundary.
1704 #[test]
1705 fn find_config_still_finds_config_at_the_git_boundary_dir_itself() {
1706 let root = unique_tmp_dir("git-boundary-config-at-root");
1707 let repo = root.join("repo");
1708 let nested = repo.join("a").join("b");
1709 std::fs::create_dir_all(&nested).unwrap();
1710 std::fs::create_dir_all(repo.join(".git")).unwrap();
1711 std::fs::write(
1712 repo.join(CONFIG_FILE_NAME),
1713 "[project]\ndialect = \"brink\"\n",
1714 )
1715 .unwrap();
1716
1717 let found = find_config(&nested).expect("brink.toml at the repo root must still be found");
1718 assert_eq!(found, repo.join(CONFIG_FILE_NAME));
1719
1720 std::fs::remove_dir_all(&root).unwrap();
1721 }
1722
1723 /// A project with no `.git` anywhere above it (no VCS at all) is
1724 /// unaffected by the bound as long as the config is within
1725 /// [`MAX_ANCESTOR_DEPTH`] — a shallow nesting (well inside the cap)
1726 /// behaves exactly as before #1425/#1435.
1727 #[test]
1728 fn find_config_without_any_git_boundary_still_finds_config_within_depth_cap() {
1729 let root = unique_tmp_dir("no-git-anywhere");
1730 let nested = root.join("a").join("b").join("c");
1731 std::fs::create_dir_all(&nested).unwrap();
1732 std::fs::write(
1733 root.join(CONFIG_FILE_NAME),
1734 "[project]\ndialect = \"brink\"\n",
1735 )
1736 .unwrap();
1737
1738 let found =
1739 find_config(&nested).expect("should still find brink.toml with no .git anywhere");
1740 assert_eq!(found, root.join(CONFIG_FILE_NAME));
1741
1742 std::fs::remove_dir_all(&root).unwrap();
1743 }
1744
1745 // ── ancestor depth cap, VCS-less trees (#1435) ──────────────────────
1746
1747 /// Builds `root/d0/d1/.../d{depth-1}`, creating every intermediate
1748 /// directory, and returns the deepest one.
1749 fn nested_chain(root: &Path, depth: usize) -> PathBuf {
1750 let mut dir = root.to_path_buf();
1751 for i in 0..depth {
1752 dir = dir.join(format!("d{i}"));
1753 }
1754 std::fs::create_dir_all(&dir).unwrap();
1755 dir
1756 }
1757
1758 /// The defect #1435 exists to fix: a VCS-less tree (no `.git` anywhere)
1759 /// nested deeper than [`MAX_ANCESTOR_DEPTH`] must not have its
1760 /// `brink.toml` discovered — before this fix, `find_config`'s
1761 /// `Path::parent`-only walk had no stop condition at all here and would
1762 /// have found it regardless of depth.
1763 #[test]
1764 fn find_config_bounds_vcs_less_walk_at_max_ancestor_depth() {
1765 let root = unique_tmp_dir("vcs-less-too-deep");
1766 let deepest = nested_chain(&root, MAX_ANCESTOR_DEPTH + 10);
1767 std::fs::write(
1768 root.join(CONFIG_FILE_NAME),
1769 "[project]\ndialect = \"brink\"\n",
1770 )
1771 .unwrap();
1772
1773 assert_eq!(
1774 find_config(&deepest),
1775 None,
1776 "a VCS-less walk must not climb past MAX_ANCESTOR_DEPTH ancestors, even with no \
1777 .git boundary to stop it otherwise"
1778 );
1779
1780 std::fs::remove_dir_all(&root).unwrap();
1781 }
1782
1783 /// A VCS-less tree nested exactly at the cap (not beyond it) still finds
1784 /// its `brink.toml` — the cap must not be off-by-one in the stricter
1785 /// direction.
1786 #[test]
1787 fn find_config_finds_config_exactly_at_max_ancestor_depth() {
1788 let root = unique_tmp_dir("vcs-less-at-cap");
1789 let deepest = nested_chain(&root, MAX_ANCESTOR_DEPTH);
1790 std::fs::write(
1791 root.join(CONFIG_FILE_NAME),
1792 "[project]\ndialect = \"brink\"\n",
1793 )
1794 .unwrap();
1795
1796 let found = find_config(&deepest)
1797 .expect("a brink.toml exactly MAX_ANCESTOR_DEPTH ancestors up must still be found");
1798 assert_eq!(found, root.join(CONFIG_FILE_NAME));
1799
1800 std::fs::remove_dir_all(&root).unwrap();
1801 }
1802
1803 // ── silent-drop warnings (#1435) ─────────────────────────────────────
1804
1805 /// A `brink.toml` sitting above the workspace/git boundary is not just
1806 /// silently ignored — [`find_config_with_warnings`] reports it via a
1807 /// [`ConfigWarning`] naming both the skipped file and the boundary.
1808 #[test]
1809 fn find_config_with_warnings_reports_config_skipped_above_git_boundary() {
1810 let root = unique_tmp_dir("warn-git-boundary");
1811 let repo = root.join("repo");
1812 let nested = repo.join("a").join("b");
1813 std::fs::create_dir_all(&nested).unwrap();
1814 std::fs::create_dir_all(repo.join(".git")).unwrap();
1815 let stray = root.join(CONFIG_FILE_NAME);
1816 std::fs::write(&stray, "[project]\ndialect = \"brink\"\n").unwrap();
1817
1818 let (found, warnings) = find_config_with_warnings(&nested);
1819 assert_eq!(found, None, "the stray config must still never be returned");
1820 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
1821 assert!(
1822 warnings[0].0.contains(&stray.display().to_string()),
1823 "warning must name the skipped file, got: {}",
1824 warnings[0]
1825 );
1826
1827 std::fs::remove_dir_all(&root).unwrap();
1828 }
1829
1830 /// The VCS-less analog: a `brink.toml` sitting beyond
1831 /// [`MAX_ANCESTOR_DEPTH`] in a tree with no `.git` anywhere is reported
1832 /// the same way.
1833 #[test]
1834 fn find_config_with_warnings_reports_config_skipped_beyond_depth_cap() {
1835 let root = unique_tmp_dir("warn-depth-cap");
1836 let deepest = nested_chain(&root, MAX_ANCESTOR_DEPTH + 10);
1837 let stray = root.join(CONFIG_FILE_NAME);
1838 std::fs::write(&stray, "[project]\ndialect = \"brink\"\n").unwrap();
1839
1840 let (found, warnings) = find_config_with_warnings(&deepest);
1841 assert_eq!(found, None, "the stray config must still never be returned");
1842 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
1843 assert!(
1844 warnings[0].0.contains(&stray.display().to_string()),
1845 "warning must name the skipped file, got: {}",
1846 warnings[0]
1847 );
1848
1849 std::fs::remove_dir_all(&root).unwrap();
1850 }
1851
1852 /// No warning when there is genuinely nothing above either — a bound
1853 /// firing is not itself warning-worthy, only a bound that actually
1854 /// skipped a real config.
1855 #[test]
1856 fn find_config_with_warnings_is_silent_when_nothing_skipped() {
1857 let root = unique_tmp_dir("warn-nothing-to-skip");
1858 let deepest = nested_chain(&root, MAX_ANCESTOR_DEPTH + 10);
1859 // No brink.toml anywhere in this tree at all.
1860
1861 let (found, warnings) = find_config_with_warnings(&deepest);
1862 assert_eq!(found, None);
1863 assert!(warnings.is_empty(), "got: {warnings:?}");
1864
1865 std::fs::remove_dir_all(&root).unwrap();
1866 }
1867
1868 /// `find_config` (the discarding wrapper) must behave identically to
1869 /// `find_config_with_warnings(...).0` for a stray config skipped at the
1870 /// git boundary — the shared `find_config_inner(..., want_warnings:
1871 /// false)` path skips the second probe entirely (review finding on
1872 /// #1435: the probe cost was paid and thrown away), but the result must
1873 /// still be `None`, never the stray path.
1874 #[test]
1875 fn find_config_skips_the_warning_probe_but_still_returns_none_at_git_boundary() {
1876 let root = unique_tmp_dir("no-warn-probe-git-boundary");
1877 let repo = root.join("repo");
1878 let nested = repo.join("a").join("b");
1879 std::fs::create_dir_all(&nested).unwrap();
1880 std::fs::create_dir_all(repo.join(".git")).unwrap();
1881 std::fs::write(
1882 root.join(CONFIG_FILE_NAME),
1883 "[project]\ndialect = \"brink\"\n",
1884 )
1885 .unwrap();
1886
1887 assert_eq!(find_config(&nested), None);
1888
1889 std::fs::remove_dir_all(&root).unwrap();
1890 }
1891
1892 /// The depth-cap analog of the above.
1893 #[test]
1894 fn find_config_skips_the_warning_probe_but_still_returns_none_beyond_depth_cap() {
1895 let root = unique_tmp_dir("no-warn-probe-depth-cap");
1896 let deepest = nested_chain(&root, MAX_ANCESTOR_DEPTH + 10);
1897 std::fs::write(
1898 root.join(CONFIG_FILE_NAME),
1899 "[project]\ndialect = \"brink\"\n",
1900 )
1901 .unwrap();
1902
1903 assert_eq!(find_config(&deepest), None);
1904
1905 std::fs::remove_dir_all(&root).unwrap();
1906 }
1907
1908 #[test]
1909 fn discover_from_entry_starts_at_entry_parent() {
1910 let root = unique_tmp_dir("entry-parent");
1911 std::fs::create_dir_all(&root).unwrap();
1912 std::fs::write(
1913 root.join(CONFIG_FILE_NAME),
1914 "[project]\ntypes = \"strict\"\n",
1915 )
1916 .unwrap();
1917 let entry = root.join("story.ink");
1918 std::fs::write(&entry, "content").unwrap();
1919
1920 let found = discover_from_entry(&entry).expect("should find brink.toml beside entry");
1921 assert_eq!(found, root.join(CONFIG_FILE_NAME));
1922
1923 std::fs::remove_dir_all(&root).unwrap();
1924 }
1925
1926 #[test]
1927 fn load_from_entry_none_when_no_config() {
1928 let root = unique_tmp_dir("load-none");
1929 std::fs::create_dir_all(&root).unwrap();
1930 let entry = root.join("story.ink");
1931 std::fs::write(&entry, "content").unwrap();
1932
1933 let (loaded, warnings) = load_from_entry(&entry).unwrap();
1934 assert!(loaded.is_none());
1935 assert!(warnings.is_empty(), "got: {warnings:?}");
1936
1937 std::fs::remove_dir_all(&root).unwrap();
1938 }
1939
1940 /// [`load_from_entry`]'s discovery-warning half of #1435: a config
1941 /// skipped by the bounded walk is surfaced through this function's own
1942 /// return value, not swallowed by its `Ok(None)` "nothing found" case.
1943 #[test]
1944 fn load_from_entry_surfaces_discovery_warning_when_config_skipped() {
1945 let root = unique_tmp_dir("load-skipped-warning");
1946 let repo = root.join("repo");
1947 std::fs::create_dir_all(&repo).unwrap();
1948 std::fs::create_dir_all(repo.join(".git")).unwrap();
1949 let stray = root.join(CONFIG_FILE_NAME);
1950 std::fs::write(&stray, "[project]\ndialect = \"brink\"\n").unwrap();
1951 let entry = repo.join("story.ink");
1952 std::fs::write(&entry, "content").unwrap();
1953
1954 let (loaded, warnings) = load_from_entry(&entry).unwrap();
1955 assert!(
1956 loaded.is_none(),
1957 "the out-of-repo config must never be loaded"
1958 );
1959 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
1960 assert!(
1961 warnings[0].0.contains(&stray.display().to_string()),
1962 "warning must name the skipped file, got: {}",
1963 warnings[0]
1964 );
1965
1966 std::fs::remove_dir_all(&root).unwrap();
1967 }
1968
1969 #[test]
1970 fn find_config_in_tree_walks_up_from_start_key() {
1971 use brink_source_tree::InMemory;
1972 use std::collections::BTreeMap;
1973
1974 let mut files = BTreeMap::new();
1975 files.insert(
1976 CONFIG_FILE_NAME.to_owned(),
1977 "[project]\ndialect = \"brink\"\n".to_owned(),
1978 );
1979 files.insert("a/b/story.ink".to_owned(), "content".to_owned());
1980 let tree = InMemory::new(files);
1981
1982 let found = find_config_in_tree(&tree, "a/b")
1983 .expect("list succeeds")
1984 .expect("should find brink.toml in an ancestor key");
1985 assert_eq!(found, CONFIG_FILE_NAME);
1986 }
1987
1988 #[test]
1989 fn find_config_in_tree_returns_none_when_absent() {
1990 use brink_source_tree::InMemory;
1991 use std::collections::BTreeMap;
1992
1993 let mut files = BTreeMap::new();
1994 files.insert("a/b/story.ink".to_owned(), "content".to_owned());
1995 let tree = InMemory::new(files);
1996
1997 let found = find_config_in_tree(&tree, "a/b").expect("list succeeds");
1998 assert_eq!(found, None);
1999 }
2000
2001 /// A `SourceTree` whose `list` errors out — proves `find_config_in_tree`
2002 /// resolves purely via direct `read` probes of the O(depth) ancestor
2003 /// candidates and never falls back to enumerating the tree (issue
2004 /// #1370): if it ever called `list`, that error would propagate and the
2005 /// test's `.expect(..)` calls below would fail. Seeded with a huge,
2006 /// irrelevant key set (standing in for `target/`/`.git`/`node_modules`
2007 /// clutter a real tree walk would have to traverse) that a `list`-based
2008 /// implementation would have to comb through but a `read`-probing one
2009 /// never touches.
2010 struct ErrorsOnList {
2011 files: BTreeMap<String, String>,
2012 }
2013
2014 impl SourceTree for ErrorsOnList {
2015 fn list(&self) -> io::Result<Vec<String>> {
2016 Err(io::Error::other(
2017 "find_config_in_tree must not enumerate the tree via SourceTree::list (issue #1370)",
2018 ))
2019 }
2020
2021 fn read(&self, key: &str) -> io::Result<String> {
2022 self.files
2023 .get(key)
2024 .cloned()
2025 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("{key}: not found")))
2026 }
2027 }
2028
2029 #[test]
2030 fn find_config_in_tree_probes_directly_without_enumerating_the_tree() {
2031 let mut files = BTreeMap::new();
2032 files.insert(
2033 CONFIG_FILE_NAME.to_owned(),
2034 "[project]\ndialect = \"brink\"\n".to_owned(),
2035 );
2036 for i in 0..10_000 {
2037 files.insert(format!("target/build-artifact-{i}.o"), "ignored".to_owned());
2038 }
2039 let tree = ErrorsOnList { files };
2040
2041 let found = find_config_in_tree(&tree, "a/b/c/d")
2042 .expect("direct probing succeeds without ever calling list")
2043 .expect("should find brink.toml at the tree root");
2044 assert_eq!(found, CONFIG_FILE_NAME);
2045 }
2046
2047 #[test]
2048 fn find_config_in_tree_probes_directly_returns_none_without_enumerating_the_tree() {
2049 let mut files = BTreeMap::new();
2050 for i in 0..10_000 {
2051 files.insert(format!(".git/objects/{i}"), "ignored".to_owned());
2052 }
2053 let tree = ErrorsOnList { files };
2054
2055 let found = find_config_in_tree(&tree, "a/b/c/d")
2056 .expect("direct probing succeeds without ever calling list");
2057 assert_eq!(found, None);
2058 }
2059
2060 /// A `SourceTree` whose `brink.toml` candidate exists but errors on
2061 /// `read` with a non-`NotFound` kind (e.g. invalid encoding, permission
2062 /// denied) — must be reported as *found* (`Some(candidate)`), not
2063 /// propagated as an `Err` from `find_config_in_tree` itself. Regression
2064 /// guard for the #1370/#1369 interaction: `find_config_in_tree`'s probe
2065 /// read used to propagate this error directly, which — since it carries
2066 /// no path — surfaced to callers as a bare `LoadError::Io` instead of
2067 /// the path-attributed `LoadError::ConfigRead` the caller's own `read`
2068 /// of the returned key is meant to produce.
2069 struct ErrorsOnRead;
2070
2071 impl SourceTree for ErrorsOnRead {
2072 fn list(&self) -> io::Result<Vec<String>> {
2073 Ok(vec![CONFIG_FILE_NAME.to_owned()])
2074 }
2075
2076 fn read(&self, key: &str) -> io::Result<String> {
2077 if key == CONFIG_FILE_NAME {
2078 Err(io::Error::new(
2079 io::ErrorKind::InvalidData,
2080 "not valid utf-8",
2081 ))
2082 } else {
2083 Err(io::Error::new(
2084 io::ErrorKind::NotFound,
2085 format!("{key}: not found"),
2086 ))
2087 }
2088 }
2089 }
2090
2091 #[test]
2092 fn find_config_in_tree_reports_found_when_the_candidate_read_errors_non_not_found() {
2093 let found = find_config_in_tree(&ErrorsOnRead, "a/b")
2094 .expect("a non-NotFound read error is not propagated")
2095 .expect("the unreadable brink.toml is still reported as found");
2096 assert_eq!(found, CONFIG_FILE_NAME);
2097 }
2098
2099 #[test]
2100 fn discover_from_entry_in_tree_starts_at_entry_parent_key() {
2101 use brink_source_tree::InMemory;
2102 use std::collections::BTreeMap;
2103
2104 let mut files = BTreeMap::new();
2105 files.insert(
2106 CONFIG_FILE_NAME.to_owned(),
2107 "[project]\ntypes = \"strict\"\n".to_owned(),
2108 );
2109 files.insert("story.ink".to_owned(), "content".to_owned());
2110 let tree = InMemory::new(files);
2111
2112 let found = discover_from_entry_in_tree(&tree, "story.ink")
2113 .expect("list succeeds")
2114 .expect("should find brink.toml beside entry key");
2115 assert_eq!(found, CONFIG_FILE_NAME);
2116 }
2117
2118 #[test]
2119 fn load_from_entry_reads_and_parses() {
2120 let root = unique_tmp_dir("load-some");
2121 std::fs::create_dir_all(&root).unwrap();
2122 std::fs::write(
2123 root.join(CONFIG_FILE_NAME),
2124 "[project]\ndialect = \"brink\"\ntypes = \"strict\"\n",
2125 )
2126 .unwrap();
2127 let entry = root.join("story.ink");
2128 std::fs::write(&entry, "content").unwrap();
2129
2130 let (loaded, discovery_warnings) = load_from_entry(&entry).unwrap();
2131 let loaded = loaded.expect("config found");
2132 assert_eq!(loaded.path, root.join(CONFIG_FILE_NAME));
2133 assert_eq!(loaded.config.dialect, Some(Dialect::Brink));
2134 assert_eq!(loaded.config.types, Some(TypePolicy::Strict));
2135 assert!(loaded.warnings.is_empty());
2136 assert!(discovery_warnings.is_empty(), "got: {discovery_warnings:?}");
2137
2138 std::fs::remove_dir_all(&root).unwrap();
2139 }
2140}