Skip to main content

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//! [fix]
61//! E033 = "auto"   # promote a Suggested fix to batch for this project
62//! E014 = "off"    # never offer this fixer here
63//! # absent ⇒ "ask": offered per click only (Suggested) / batchable (Safe)
64//! ```
65//!
66//! `[fix]` (`docs/autofix-spec.md` §6.1, issue #3419) maps a diagnostic code
67//! to a [`FixPolicy`]. Shaped exactly like `[lints]` — same dependency-free
68//! split (this crate validates the *value*; validating the *code* against
69//! the real `DiagnosticCode` set is a downstream crate's job, the same as
70//! `[lints]`'s `validate_lint_code`) — and resolved by
71//! [`ProjectConfig::effective_fix_policy`], the `[fix]`-table analog of
72//! `[lints]`'s `effective_severity`.
73//!
74//! ```toml
75//! [project]
76//! unprune-dirs = ["node_modules"]  # directory names discovery must NOT
77//!                                  # prune, on top of the standing
78//!                                  # `target`/`.git`/`node_modules` policy
79//!                                  # (issue #1407's escape hatch — see
80//!                                  # `brink_source_tree::Walk::allow`). A
81//!                                  # name that isn't one of those three is
82//!                                  # a no-op (there was nothing to
83//!                                  # un-prune) and warns.
84//! ```
85//!
86//! ```toml
87//! [project]
88//! conventions = "conventions.brink"  # docs/prose-dialect-spec.md §3.4: a
89//!                                    # built-in preset name ("screenplay")
90//!                                    # or a project-relative path to a
91//!                                    # `.brink` conventions module. Names
92//!                                    # the ONE file a pattern-claiming
93//!                                    # `@[convention(claims = "…", order =
94//!                                    # N)]` handler may be declared in
95//!                                    # (issue #1844's confinement rule,
96//!                                    # `E169` elsewhere) — unset means no
97//!                                    # conventions module is configured, so
98//!                                    # nothing is enforced yet.
99//!                                    #
100//!                                    # `elements` is a DEPRECATED alias for
101//!                                    # this key (issue #2180: the key
102//!                                    # predates the split of `@[element]`
103//!                                    # from `@[convention]` and now names a
104//!                                    # module of the latter, not the
105//!                                    # former). Setting `elements` still
106//!                                    # works but warns; setting both keys
107//!                                    # prefers `conventions` and warns
108//!                                    # about the conflict. The alias will
109//!                                    # be removed in a future release —
110//!                                    # migrate to `conventions`.
111//! ```
112//!
113//! ```toml
114//! [project]
115//! entry = "story.ink"  # the project's entry file, project-relative
116//!                      # (issue #2331, ruled 2026-08-07 "[project] entry
117//!                      # beats mountStudio's entryFile"). When both this
118//!                      # key and a host's own entry-file argument
119//!                      # (`mountStudio`'s `entryFile`, `ProjectSession`'s
120//!                      # constructor option) are present, THIS KEY WINS —
121//!                      # the host argument is only the fallback for a
122//!                      # configless project. Unset means "no opinion": the
123//!                      # host argument decides alone, unchanged from
124//!                      # pre-#2331 behavior.
125//! ```
126//!
127//! (`E014` — a plainly `Warning`-by-default code — is used here rather than
128//! `E063`: `E063`'s own *base* severity is `types`-policy-dependent (`Error`
129//! under `types = strict`, see `brink_analyzer::effective_severity`'s doc
130//! comment), so it makes a confusing flagship example — under `types =
131//! strict` a `[lints]` entry for it is never even consulted.)
132//!
133//! Every key is optional; an empty or absent `[project]`/`[lints]` table is
134//! valid and contributes nothing (`ProjectConfig::default()`).
135//!
136//! `[lints]` is shaped like Rust's own `[lints]` table (issue #1160) but is
137//! **not** a drop-in semantic match: each key other than the reserved
138//! `deny-warnings` is taken as a diagnostic code (`"E014"`) mapped to a
139//! [`LintLevel`], and `Deny`/`Warn` behave as their Rust namesakes suggest —
140//! but `Allow` does not *remove* the diagnostic the way Rust's `allow`
141//! does. `LintLevel::Allow` only buys immunity from `deny-warnings`; the
142//! diagnostic still resolves to `Severity::Warning` and is still reported
143//! (`brink_analyzer::effective_severity`'s doc comment, step 3). An author
144//! who wants a code gone entirely wants `brink_ir::suppressions`
145//! (`//brink-disable`), a different, per-site mechanism — not `[lints]`.
146//!
147//! This crate does not know the closed set of real `DiagnosticCode`s
148//! (keeping it dependency-free, #1234), so it accepts any key here without
149//! validation — resolving a key against the real code set, and deciding
150//! which codes are actually overridable (a `Warning`-base-severity code
151//! only — see `effective_severity`'s hard-error exemption), is
152//! `AnalysisOptions::apply_project_config`'s job in `brink-analyzer` (which
153//! owns `DiagnosticCode`): an unknown or non-overridable key is never
154//! merged into the resolved policy, and is surfaced back to the caller as a
155//! [`ConfigWarning`]-shaped string through that function's return value —
156//! the same "warn, never silently drop" channel this crate's own unknown-key
157//! warnings use.
158
159pub mod edit;
160pub mod globs;
161pub use edit::{ConfigDocument, EditError};
162
163use std::collections::BTreeMap;
164use std::fmt;
165use std::io;
166use std::path::{Path, PathBuf};
167
168use brink_source_tree::{IGNORED_DIR_NAMES, SourceTree};
169
170/// Compiler dialect: gates T1b brink-extension syntax. Default `StrictInk` —
171/// divergence from the oracle-anchored ink subset is a visible, one-time,
172/// per-project choice (docs/t1b-surface-spec.md §1).
173///
174/// Defined here rather than in `brink-analyzer` because it is a
175/// **project-policy** type: the analyzer consumes it, this crate parses it,
176/// and owning it here is what keeps this crate free of workspace
177/// dependencies (#1234). `brink-analyzer` re-exports it, so
178/// `brink_analyzer::Dialect` remains the canonical path for consumers.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
180pub enum Dialect {
181    #[default]
182    StrictInk,
183    Brink,
184}
185
186/// `types` project policy (docs/typed-mode-spec.md §1). `Gradual` is the
187/// pre-flip behavior — `Unknown` unifies with anything, annotations are
188/// optional seasoning, and the strict checks do not run. `Strict` requires
189/// `dialect = brink`.
190///
191/// The *default* is dialect-keyed since the 2026-07-19 "Typing posture
192/// ruled" decision (issue #1127) — see `brink_analyzer::resolve_type_policy`.
193/// The derived `Default` (`Gradual`) exists only so pre-resolution containers
194/// can derive theirs; policy defaulting must never read it directly.
195///
196/// Defined here for the same reason as [`Dialect`], and re-exported by
197/// `brink-analyzer`.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
199pub enum TypePolicy {
200    #[default]
201    Gradual,
202    Strict,
203}
204
205/// A `[lints]` table entry's severity (issue #1160) — mirrors Rust's own
206/// `[lints]` levels. `Warn` is every diagnostic code's implicit level when
207/// `[lints]` doesn't mention it, so it doubles as this type's `Default`.
208///
209/// Defined here for the same reason as [`Dialect`]/[`TypePolicy`]: a
210/// project-policy type this crate parses but doesn't interpret, kept
211/// dependency-free (#1234) and re-exported by `brink-analyzer`, which owns
212/// applying it against the real `DiagnosticCode` set.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
214pub enum LintLevel {
215    /// Never escalate this code past `Warning`, even under `deny-warnings`.
216    Allow,
217    /// The code's ordinary behavior: `Warning`, promoted to `Error` by
218    /// `deny-warnings` like any other unconfigured warning.
219    #[default]
220    Warn,
221    /// Always `Error`, regardless of `deny-warnings`.
222    Deny,
223    /// Down-level to `Severity::Info` (issue #1162) — an advisory tier below
224    /// `Warning`, immune to `deny-warnings` like `Allow` (escalating an
225    /// author's deliberate downgrade back up would defeat the point of it).
226    Info,
227    /// Down-level to `Severity::Hint` (issue #1162) — the quietest tier,
228    /// immune to `deny-warnings` for the same reason as `Info`. The IDE-
229    /// convention use case this exists for (e.g. unused-symbol dimming) is
230    /// exactly the case where even an `Info` squiggle is too loud.
231    Hint,
232}
233
234/// A `[fix]` table entry's policy (`docs/autofix-spec.md` §6, issue #3419):
235/// whether a fixer for this diagnostic code may be batched, offered only per
236/// instance, or never offered here at all.
237///
238/// Declared `Off < Ask < Auto` — least aggressive to most — so the derived
239/// [`Ord`] IS the aggressiveness ordering [`ProjectConfig::effective_fix_policy`]
240/// intersects on (`docs/autofix-spec.md` §6.2's ceiling, TENTATIVE ruling):
241/// `a.min(b)` is always the more conservative of the two. Don't reorder the
242/// variants without checking that call site.
243///
244/// Defined here for the same reason as [`LintLevel`]: a project-policy type
245/// this crate parses but doesn't interpret against the real fixer/code sets
246/// (kept dependency-free, #1234).
247#[derive(
248    Debug,
249    Clone,
250    Copy,
251    PartialEq,
252    Eq,
253    PartialOrd,
254    Ord,
255    Default,
256    serde::Serialize,
257    serde::Deserialize,
258)]
259pub enum FixPolicy {
260    /// Never offer or batch a fixer for this code in this project.
261    Off,
262    /// The code's ordinary behavior when `[fix]` doesn't mention it: a Safe
263    /// fixer is batchable, a Suggested fixer is offered only per explicit
264    /// click.
265    #[default]
266    Ask,
267    /// Promote a Suggested fixer to batchable for this project too (a Safe
268    /// fixer is already batchable regardless of `[fix]`).
269    Auto,
270}
271
272use thiserror::Error;
273use toml::Value;
274
275/// The config filename every mount discovers, beside the root `.ink` entry
276/// file (or in an ancestor directory — see [`find_config`]).
277pub const CONFIG_FILE_NAME: &str = "brink.toml";
278
279/// `[prose] dialect` — which English the spell checker judges by.
280///
281/// Not cosmetic and not deferrable: measured against Harper with the
282/// American dialect, `"The colour of the harbour at night."` reports BOTH
283/// words as misspellings. A British-English author with no way to say so
284/// gets their whole manuscript underlined, which is indistinguishable from
285/// the feature being broken.
286///
287/// The variants are Harper's (`harper_core::Dialect`). This crate stays
288/// dependency-free of the checker — same reason `lints` doesn't validate
289/// diagnostic codes (#1234) — so the mapping lives at the wasm boundary.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
291pub enum ProseDialect {
292    #[default]
293    American,
294    British,
295    Canadian,
296    Australian,
297}
298
299impl ProseDialect {
300    /// The spelling used in `brink.toml`, and the string the checker's
301    /// boundary takes.
302    pub fn as_str(self) -> &'static str {
303        match self {
304            Self::American => "american",
305            Self::British => "british",
306            Self::Canadian => "canadian",
307            Self::Australian => "australian",
308        }
309    }
310}
311
312/// The `[project]`/`[lints]` tables' recognized keys, parsed out of
313/// `brink.toml`. `dialect`/`types` are `None` when the file doesn't set
314/// them — callers fall back to `AnalysisOptions::default()` (or an explicit
315/// override), never to a default invented by this crate. `lints`/
316/// `deny_warnings` follow the same "unset means untouched" rule: an empty
317/// `lints` map and a `None` `deny_warnings` both mean "`[lints]` didn't say,
318/// leave whatever the caller already had."
319///
320/// No longer `Copy` (issue #1160): `lints` is a `BTreeMap`, which isn't
321/// `Copy`. Every construction site now needs `.clone()` where it used to
322/// rely on an implicit copy.
323/// `[dialogue]` — the project's dialogue dialect declaration (RULED
324/// 2026-08-30, "Project-declared dialogue dialect lives in brink.toml").
325/// The **authoring** form of `docs/dialect-spec.md`'s `DialogueDialect`:
326/// a shipped preset name plus overlay elements written in the spec's affix
327/// sugar, or a path to a full hand-written artifact. Resolution into a
328/// `DialogueDialect` (preset merged, sugar compiled) happens above this
329/// crate (`brink-ide`), which knows the presets; this crate only parses.
330///
331/// `Option<DialogueConfig>` on [`ProjectConfig`]: `None` = the file
332/// declares no `[dialogue]` at all, which per the "No dialect by default"
333/// ruling means NO dialect — plain lines — not "the preset".
334#[derive(Debug, Clone, Default, PartialEq, Eq)]
335pub struct DialogueConfig {
336    /// `[dialogue] preset`, a shipped preset name (`"at-cue"`), if set.
337    pub preset: Option<String>,
338    /// The string form `dialogue = "path.json"` — a full artifact relative
339    /// to `brink.toml`. Mutually exclusive with the table form.
340    pub file: Option<String>,
341    /// `[[dialogue.elements]]` overlay declarations, in file order
342    /// (classification precedence is author-controlled — a `Vec`, never a
343    /// map).
344    pub elements: Vec<DialogueElementConfig>,
345    /// `[dialogue] run-ends-at` — the emitted-side run rule (#3388): kinds
346    /// whose appearance ends the active speaker's run. Parsed here so the
347    /// schema is complete; consumed by the resolver once #3388 lands.
348    pub run_ends_at: Vec<String>,
349}
350
351/// One `[[dialogue.elements]]` row: the affix-sugar form of a kind
352/// (`prefix`/`suffix`/`glued`/`content-role`) or, for the unusual case, an
353/// explicit `pattern` + `template`. Which form it is is decided by the
354/// resolver; this crate keeps every key it recognizes.
355#[derive(Debug, Clone, Default, PartialEq, Eq)]
356pub struct DialogueElementConfig {
357    pub kind: String,
358    /// `nature`: `"narrative"` (default) | `"machinery"` | `"structural"`.
359    pub nature: Option<String>,
360    pub prefix: Option<String>,
361    pub suffix: Option<String>,
362    pub glued: Option<bool>,
363    /// `content-role` — the named content group (`"content"` by default).
364    pub content_role: Option<String>,
365    /// Explicit pattern form (`pattern` + `template`), for kinds the affix
366    /// sugar can't express.
367    pub pattern: Option<String>,
368    pub template: Option<String>,
369}
370
371#[derive(Debug, Clone, Default, PartialEq, Eq)]
372pub struct ProjectConfig {
373    /// `[project] dialect`, if set.
374    pub dialect: Option<Dialect>,
375    /// `[project] types`, if set.
376    pub types: Option<TypePolicy>,
377    /// `[lints]` per-code severity overrides, keyed by the raw code string
378    /// as written in the file (e.g. `"E063"`) — this crate doesn't validate
379    /// codes against the real `DiagnosticCode` set (#1234 dependency-free
380    /// constraint); resolving unknown/non-overridable codes is
381    /// `brink-analyzer`'s job. Sorted (`BTreeMap`) for deterministic
382    /// iteration.
383    pub lints: BTreeMap<String, LintLevel>,
384    /// `[lints] deny-warnings`, if set.
385    pub deny_warnings: Option<bool>,
386    /// `[fix]` per-code policy overrides (`docs/autofix-spec.md` §6.1, issue
387    /// #3419), keyed by the raw code string as written in the file — this
388    /// crate doesn't validate codes against the real `DiagnosticCode` set,
389    /// same as [`Self::lints`]. A code absent from this map resolves to
390    /// [`FixPolicy::Ask`] via [`Self::effective_fix_policy`], never to a
391    /// default invented at the call site. Sorted (`BTreeMap`) for
392    /// deterministic iteration.
393    pub fix: BTreeMap<String, FixPolicy>,
394    /// `[project] unprune-dirs`, if set: directory names discovery must not
395    /// prune, layered on top of the standing
396    /// [`brink_source_tree::IGNORED_DIR_NAMES`] policy (issue #1407's escape
397    /// hatch). Empty (the default) means "the standing policy applies with
398    /// no override" — same "unset means untouched" convention as `lints`.
399    /// Raw strings as written in the file; a name outside
400    /// [`brink_source_tree::IGNORED_DIR_NAMES`] parses fine (this crate
401    /// stays dependency-free of anything beyond `brink_source_tree`, and
402    /// there is nothing wrong in principle with naming a directory that
403    /// isn't pruned in the first place) but is a no-op, so [`parse_str_at`]
404    /// warns about it rather than silently accepting a likely typo (e.g.
405    /// `"node-modules"` instead of `"node_modules"`).
406    pub unprune_dirs: Vec<String>,
407    /// `[project] indent`, if set: the number of spaces one indent level
408    /// occupies. THE single source for indentation across the project
409    /// (decision log 2026-08-27) — the formatter emits it, the editor's
410    /// `indentUnit` adopts it, and the indent guides position against it.
411    /// No component may keep its own default, because the failure mode is
412    /// disagreement: a formatter writing four spaces while guides are drawn
413    /// every two looks like a rendering glitch rather than a config
414    /// mismatch, and the author cannot tell which component is wrong.
415    ///
416    /// `None` means unset — callers apply [`DEFAULT_INDENT`].
417    pub indent: Option<u8>,
418
419    /// `[project] drafts`, if set: path globs (see [`globs`]) naming
420    /// work-in-progress the author has deliberately not wired into the story
421    /// — scratch scenes, cut material, notes.
422    ///
423    /// A match here is only HALF of draft status. Ruled 2026-08-27
424    /// ("reachability wins"): a file is a draft when it matches one of these
425    /// globs **and** is not reachable from the entry. A marked file the entry
426    /// still INCLUDEs is not a draft at all — it compiles normally. That is
427    /// deliberately a deleted state rather than a diagnosed one: draft status
428    /// can then never exclude a file the story actually reaches, so it can
429    /// never break a divert. This crate carries only the glob half, because
430    /// reachability is the compile closure's answer and lives in the analysis
431    /// roads; see `EditorSession::draft_paths` for the conjunction.
432    ///
433    /// Empty (the default) means no file is ever a draft.
434    pub drafts: Vec<String>,
435
436    /// `[prose] dialect`, if set. `None` means unset — callers apply
437    /// [`ProseDialect::default`].
438    pub prose_dialect: Option<ProseDialect>,
439
440    /// `[prose] enable`, if set: whether prose checking runs at all.
441    ///
442    /// Its own key rather than "unregister the checker", because those are
443    /// different decisions by different people: an embedder decides whether
444    /// the engine is available at all (it is a separate 6.5 MB module), and
445    /// this decides whether a project that *has* it wants its prose checked.
446    /// `None` means unset — callers apply their own default.
447    pub prose_enable: Option<bool>,
448
449    /// `[prose] dictionary` — the author's own word list: place names,
450    /// in-world jargon, a character who is never a cue.
451    ///
452    /// In `brink.toml` rather than a sidecar because a character's name is a
453    /// fact about the manuscript, not about one machine — so it is shared by
454    /// collaborators and survives a fresh clone (decision log, "Spellcheck:
455    /// prose only, squiggles always, dictionary in brink.toml").
456    ///
457    /// Empty and absent are the same thing here, unlike the two options
458    /// above: there is no behaviour a project could want from "declared but
459    /// empty" that it does not get from "absent".
460    pub prose_dictionary: Vec<String>,
461
462    /// `[project] conventions`, if set (docs/prose-dialect-spec.md §3.4's
463    /// pointer mechanism): either a built-in preset name (`"screenplay"`)
464    /// or a project-relative path to a `.brink` conventions module
465    /// (`"conventions.brink"`, `"scenes/conventions.brink"`). This crate
466    /// only carries the raw string — it doesn't know the closed preset-name
467    /// set or validate the path exists, for the same dependency-free
468    /// reason `lints` doesn't validate codes (#1234); resolving it (and, if
469    /// it names a project path, checking that pattern-claiming handlers
470    /// only live in that one file, issue #1844's confinement rule) is
471    /// `brink-analyzer`/`brink-db`'s job.
472    ///
473    /// Renamed from `elements` by issue #2180 (the key predates the split
474    /// of `@[element]` from `@[convention]`, docs/decision-log.md's
475    /// 2026-08-03 ruling, and now names a module of the latter, not the
476    /// former). [`parse_str_at`] still accepts the old `[project] elements`
477    /// spelling as a deprecated alias — see its own doc comment for the
478    /// precedence/warning rules — but every in-memory representation past
479    /// parsing uses only this field; there is no separate `elements` field
480    /// to keep in sync.
481    pub conventions: Option<String>,
482    /// `[dialogue]` (or the string form `dialogue = "path.json"`), if the
483    /// file declares one — see [`DialogueConfig`]. `None` = no dialect.
484    pub dialogue: Option<DialogueConfig>,
485    /// `[project] entry`, if set (issue #2331, ruled 2026-08-07 "`[project]
486    /// entry` beats `mountStudio`'s `entryFile`"): a project-relative path
487    /// naming the project's entry file (e.g. `"story.ink"`,
488    /// `"chapters/main.brink"`). Same shape as [`Self::conventions`] — this
489    /// crate only carries the raw string, without checking the path exists
490    /// or resolving it against a real project tree (kept dependency-free,
491    /// #1234); that is each mount's own job (e.g. `ProjectSession` in
492    /// `packages/ink-editor/src/project-session.ts`, which knows the
493    /// project's actual file set).
494    ///
495    /// The ruling: when both this key and a host's own entry-file argument
496    /// are present, this key WINS — the host argument is only the fallback
497    /// for a configless project (one with no `brink.toml`, or a
498    /// `brink.toml` that doesn't set `entry`). Unlike `dialect`/`types`,
499    /// there is no "explicit API call always wins" precedence tier here:
500    /// the host argument was never an explicit *override* API in the first
501    /// place, just a constructor-time default that had nowhere better to
502    /// come from before this field existed.
503    pub entry: Option<String>,
504}
505
506impl ProjectConfig {
507    /// True if the file set nothing at all (an all-default/empty
508    /// `[project]`/`[lints]` table, or neither table present).
509    #[must_use]
510    pub fn is_empty(&self) -> bool {
511        self.dialect.is_none()
512            && self.types.is_none()
513            && self.lints.is_empty()
514            && self.deny_warnings.is_none()
515            && self.fix.is_empty()
516            && self.unprune_dirs.is_empty()
517            && self.indent.is_none()
518            && self.conventions.is_none()
519            && self.entry.is_none()
520            && self.prose_dialect.is_none()
521            && self.prose_enable.is_none()
522            && self.prose_dictionary.is_empty()
523            && self.dialogue.is_none()
524    }
525
526    /// The effective `[fix]` policy for `code` (`docs/autofix-spec.md` §6,
527    /// issue #3419): the project's own `[fix]` entry (or [`FixPolicy::Ask`]
528    /// when it doesn't mention `code`), narrowed by an optional app-scope
529    /// `app_ceiling` (§6.2, TENTATIVE ruling) — a personal "how far may the
530    /// editor go on save" setting the host passes in, kept in the same
531    /// [`FixPolicy`] space.
532    ///
533    /// `app_ceiling` only LOWERS the result, never raises it past what the
534    /// project allows: `None` means "no app opinion", so the project entry
535    /// alone decides. This is the one function §6.2 asks to keep singular so
536    /// the still-tentative ceiling relationship can change in one place.
537    ///
538    /// This crate doesn't validate `code` against the real `DiagnosticCode`
539    /// set (dependency-free, #1234, same split as [`Self::lints`]) — an
540    /// unknown code resolves through the same default/ceiling math as a real
541    /// one here; surfacing "this code doesn't exist" as a diagnostic is a
542    /// downstream crate's job, the same as `[lints]`'s `validate_lint_code`.
543    #[must_use]
544    pub fn effective_fix_policy(&self, code: &str, app_ceiling: Option<FixPolicy>) -> FixPolicy {
545        let project = self.fix.get(code).copied().unwrap_or_default();
546        match app_ceiling {
547            Some(ceiling) => project.min(ceiling),
548            None => project,
549        }
550    }
551}
552
553/// A recognized-but-not-understood corner of `brink.toml`: an unknown
554/// top-level key, or an unknown key inside `[project]`. Never fatal —
555/// forward compat (#1005): an older `brink` binary reading a `brink.toml`
556/// written for a newer schema warns instead of refusing to compile.
557#[derive(Debug, Clone, PartialEq, Eq)]
558pub struct ConfigWarning(pub String);
559
560impl fmt::Display for ConfigWarning {
561    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
562        write!(f, "{}", self.0)
563    }
564}
565
566/// A `brink.toml` that couldn't be read or parsed. Unlike [`ConfigWarning`],
567/// these are genuine failures: malformed TOML syntax, or a *recognized* key
568/// holding a value outside its enum (`dialect = "sideways"`) — never an
569/// unrecognized key, which is always a warning.
570///
571/// Every variant carries `path` — the file this error came from (#1384: the
572/// path/span threading [`parse_str`]'s doc comment describes below). Before
573/// #1384 only [`ConfigError::Io`] carried one; a caller with a discovered
574/// path in scope (every one of them, in practice — see [`parse_str_at`]) had
575/// to re-derive and hand-format the "which file" prefix itself for every
576/// other variant, a duplicated, easy-to-forget convention that is exactly
577/// how #1369 happened in the first place (`LoadError::Config` lost its path
578/// for a release when that hand-formatting was dropped). Structural fields
579/// mean a new caller gets it for free.
580#[derive(Debug, Error)]
581pub enum ConfigError {
582    /// The file exists but couldn't be read (permissions, race, …).
583    #[error("failed to read {path}: {source}")]
584    Io {
585        path: PathBuf,
586        #[source]
587        source: std::io::Error,
588    },
589    /// Malformed TOML syntax. `source` (`toml::de::Error`) carries its own
590    /// byte span into the document — see [`ConfigError::span`] — and its
591    /// `Display` already renders a `line X, column Y` location plus a
592    /// caret-annotated snippet on its own, independent of `path` (`toml`'s
593    /// own error type does this regardless of whether a path is threaded
594    /// in). What `path` adds here is the file-name attribution this variant
595    /// lacked before #1384; the line/column were always there.
596    #[error("invalid TOML syntax in {path}: {source}")]
597    Toml {
598        path: String,
599        #[source]
600        source: toml::de::Error,
601    },
602    /// The document's root, or a table where one is expected, isn't a table.
603    #[error("`{key}` must be a table, found {found} (in {path})")]
604    NotATable {
605        path: String,
606        key: String,
607        found: &'static str,
608    },
609    /// A recognized key's value has the wrong TOML type (e.g. `dialect = 1`).
610    #[error("`{key}` must be a string, found {found} (in {path})")]
611    WrongType {
612        path: String,
613        key: String,
614        found: &'static str,
615    },
616    /// A recognized key's value is a string, but not one of its allowed
617    /// variants (e.g. `dialect = "sideways"`). No span: this fires *after*
618    /// the document parsed successfully — a syntactically valid string in an
619    /// out-of-range value — so the `toml` crate never attaches a byte range
620    /// to it the way it does for [`ConfigError::Toml`]; `path` is the most
621    /// precise location available (#1384).
622    #[error("`{key}` must be one of {expected:?}, found {found:?} (in {path})")]
623    InvalidValue {
624        path: String,
625        key: String,
626        expected: &'static [&'static str],
627        found: String,
628    },
629}
630
631impl ConfigError {
632    /// The file this error came from, for every variant (#1384).
633    #[must_use]
634    pub fn path(&self) -> &str {
635        match self {
636            ConfigError::Io { path, .. } => path.to_str().unwrap_or_default(),
637            ConfigError::Toml { path, .. }
638            | ConfigError::NotATable { path, .. }
639            | ConfigError::WrongType { path, .. }
640            | ConfigError::InvalidValue { path, .. } => path,
641        }
642    }
643
644    /// The byte range into the parsed document where this error occurred,
645    /// when the underlying TOML parser reported one (#1384) — only ever
646    /// `Some` for [`ConfigError::Toml`] (malformed syntax): every other
647    /// variant is raised *after* the document parsed successfully (a
648    /// recognized key holding an out-of-range value, or the wrong shape), so
649    /// there is no narrower-than-"the whole file" location the `toml` crate
650    /// ever attached to it. Centralizes the match `brink-lsp` previously
651    /// re-derived itself (`toml_span_to_lsp_range`) so a new caller doesn't
652    /// have to.
653    #[must_use]
654    pub fn span(&self) -> Option<std::ops::Range<usize>> {
655        match self {
656            ConfigError::Toml { source, .. } => source.span(),
657            _ => None,
658        }
659    }
660}
661
662/// A successfully discovered + parsed `brink.toml`.
663#[derive(Debug, Clone, PartialEq, Eq)]
664pub struct LoadedConfig {
665    /// The path the config was read from (for diagnostics/logging).
666    pub path: PathBuf,
667    /// The parsed `[project]` table.
668    pub config: ProjectConfig,
669    /// Unknown-key warnings (never errors — see [`ConfigWarning`]).
670    pub warnings: Vec<ConfigWarning>,
671}
672
673/// Parse `brink.toml` source text (already read, by whatever means the
674/// caller has — a native `std::fs::read_to_string`, a wasm embedder's own
675/// host filesystem API, …). This is the sandbox-agnostic half of the crate:
676/// no filesystem access, so it's also what the wasm editor mount uses
677/// (the browser sandbox has no `walk up the directory tree` of its own).
678///
679/// Unknown top-level keys and unknown `[project]` keys become
680/// [`ConfigWarning`]s. Only malformed TOML syntax or a recognized key with
681/// an invalid value is a [`ConfigError`].
682///
683/// Every [`ConfigError`] this can raise still needs *some* `path` (#1384);
684/// this is [`parse_str_at`] with [`CONFIG_FILE_NAME`] as a fallback label,
685/// for the one caller that genuinely has no location of its own — an
686/// embedder pushing raw `brink.toml` text it read through its own host API,
687/// with no discovered key to give (`EditorSession::apply_project_config` in
688/// `brink-web`). A caller that *did* discover the file (walked up to find
689/// it, has a `SourceTree` key or filesystem path in hand) should call
690/// [`parse_str_at`] directly with that path instead.
691pub fn parse_str(text: &str) -> Result<(ProjectConfig, Vec<ConfigWarning>), ConfigError> {
692    parse_str_at(CONFIG_FILE_NAME, text)
693}
694
695/// [`parse_str`], attaching `path` to every [`ConfigError`] it raises
696/// (#1384) — the discovered file's `SourceTree` key or filesystem path,
697/// rendered into each variant's own `Display`. `ConfigError::Toml`'s message
698/// already named the line/column on its own, via the wrapped
699/// `toml::de::Error`'s own `Display` (see [`ConfigError::span`]) —
700/// independent of `path`; what threading `path` in adds is the file-name
701/// attribution.
702///
703/// Every discovery-based caller in the workspace has a path in scope at this
704/// point and should call this rather than [`parse_str`]:
705/// [`load_from_entry`], `brink-environment::resolve_options`, `brink ide`'s
706/// `resolve_analysis_options`, brink-web's `discover_project_config`, and
707/// the LSP's `resolve_language_options`.
708pub fn parse_str_at(
709    path: impl Into<String>,
710    text: &str,
711) -> Result<(ProjectConfig, Vec<ConfigWarning>), ConfigError> {
712    let path = path.into();
713    let doc: Value = toml::from_str(text).map_err(|source| ConfigError::Toml {
714        path: path.clone(),
715        source,
716    })?;
717    let root = match doc {
718        Value::Table(t) => t,
719        other => {
720            return Err(ConfigError::NotATable {
721                path,
722                key: "<root>".to_owned(),
723                found: value_type_name(&other),
724            });
725        }
726    };
727
728    let mut config = ProjectConfig::default();
729    let mut warnings = Vec::new();
730
731    for (key, value) in &root {
732        if key == "project" {
733            let project = match value {
734                Value::Table(t) => t,
735                other => {
736                    return Err(ConfigError::NotATable {
737                        path,
738                        key: "project".to_owned(),
739                        found: value_type_name(other),
740                    });
741                }
742            };
743            parse_project_table(&path, project, &mut config, &mut warnings)?;
744        } else if key == "prose" {
745            let prose = match value {
746                Value::Table(t) => t,
747                other => {
748                    return Err(ConfigError::NotATable {
749                        path,
750                        key: "prose".to_owned(),
751                        found: value_type_name(other),
752                    });
753                }
754            };
755            parse_prose_table(&path, prose, &mut config, &mut warnings)?;
756        } else if key == "dialogue" {
757            match value {
758                Value::Table(t) => {
759                    let mut cfg = DialogueConfig::default();
760                    parse_dialogue_table(&path, t, &mut cfg, &mut warnings)?;
761                    config.dialogue = Some(cfg);
762                }
763                Value::String(file) => {
764                    config.dialogue = Some(DialogueConfig {
765                        file: Some(file.clone()),
766                        ..DialogueConfig::default()
767                    });
768                }
769                other => {
770                    return Err(ConfigError::WrongType {
771                        path,
772                        key: "dialogue".to_owned(),
773                        found: value_type_name(other),
774                    });
775                }
776            }
777        } else if key == "lints" {
778            let lints = match value {
779                Value::Table(t) => t,
780                other => {
781                    return Err(ConfigError::NotATable {
782                        path,
783                        key: "lints".to_owned(),
784                        found: value_type_name(other),
785                    });
786                }
787            };
788            for (lkey, lvalue) in lints {
789                if lkey == "deny-warnings" {
790                    config.deny_warnings = Some(parse_deny_warnings(&path, lkey, lvalue)?);
791                } else {
792                    config
793                        .lints
794                        .insert(lkey.clone(), parse_lint_level(&path, lkey, lvalue)?);
795                }
796            }
797        } else if key == "fix" {
798            parse_fix_table(&path, value, &mut config)?;
799        } else {
800            warnings.push(ConfigWarning(format!(
801                "unknown top-level key `{key}` in {CONFIG_FILE_NAME} (ignored)"
802            )));
803        }
804    }
805
806    Ok((config, warnings))
807}
808
809/// Parse the whole `[fix]` table entry — table-shape check plus every
810/// per-code value (issue #3419) — into `config.fix`. Split out of
811/// [`parse_str_at`] for the same reason [`parse_project_table`] is: keeping
812/// the caller under clippy's `too_many_lines`.
813fn parse_fix_table(
814    path: &str,
815    value: &Value,
816    config: &mut ProjectConfig,
817) -> Result<(), ConfigError> {
818    let fix = match value {
819        Value::Table(t) => t,
820        other => {
821            return Err(ConfigError::NotATable {
822                path: path.to_owned(),
823                key: "fix".to_owned(),
824                found: value_type_name(other),
825            });
826        }
827    };
828    for (fkey, fvalue) in fix {
829        config
830            .fix
831            .insert(fkey.clone(), parse_fix_policy(path, fkey, fvalue)?);
832    }
833    Ok(())
834}
835
836/// Parse the `[project]` table's keys into `config`/`warnings` — the body
837/// [`parse_str_at`] used to inline directly before it grew too long
838/// (clippy's `too_many_lines`) once `conventions`/`elements` reconciliation
839/// (issue #2180) was added.
840fn parse_project_table(
841    path: &str,
842    project: &toml::map::Map<String, Value>,
843    config: &mut ProjectConfig,
844    warnings: &mut Vec<ConfigWarning>,
845) -> Result<(), ConfigError> {
846    // `conventions` (issue #2180) and its deprecated `elements` alias are
847    // collected separately, rather than writing straight into
848    // `config.conventions` inside the match arm below, and reconciled only
849    // after the whole `[project]` table has been walked. `toml::Table`'s
850    // iteration order is not "as written in the file" in general, so
851    // resolving "both keys set" precedence arm-by-arm as each key is
852    // visited would make the outcome depend on iteration order —
853    // collecting both first and resolving once afterward keeps it
854    // deterministic regardless of which key the file happens to list
855    // first.
856    let mut conventions_value: Option<String> = None;
857    let mut elements_value: Option<String> = None;
858    for (pkey, pvalue) in project {
859        match pkey.as_str() {
860            "dialect" => config.dialect = Some(parse_dialect(path, pkey, pvalue)?),
861            "types" => config.types = Some(parse_types(path, pkey, pvalue)?),
862            "indent" => {
863                config.indent = Some(parse_indent(path, pkey, pvalue, warnings)?);
864            }
865            "drafts" => {
866                let globs = parse_string_list(path, &format!("project.{pkey}"), pvalue)?;
867                for glob in &globs {
868                    if glob.is_empty() {
869                        warnings.push(ConfigWarning(format!(
870                            "`project.drafts` in {CONFIG_FILE_NAME} contains an empty string \
871                             (ignored) — expected a project-relative path or glob (e.g. \
872                             \"scratch/**\")"
873                        )));
874                    } else if glob.starts_with('/') || glob.contains("..") {
875                        warnings.push(ConfigWarning(format!(
876                            "`project.drafts` entry `{glob}` in {CONFIG_FILE_NAME} is not \
877                             project-relative (ignored) — drafts globs match paths inside the \
878                             project, so leading `/` and `..` never match anything"
879                        )));
880                    }
881                }
882                config.drafts = globs;
883            }
884            "unprune-dirs" => {
885                let dirs = parse_string_list(path, pkey, pvalue)?;
886                for dir in &dirs {
887                    if !IGNORED_DIR_NAMES.contains(&dir.as_str()) {
888                        warnings.push(ConfigWarning(format!(
889                            "`project.unprune-dirs` entry `{dir}` in {CONFIG_FILE_NAME} is not \
890                             one of {IGNORED_DIR_NAMES:?} — it was never pruned, so this has no \
891                             effect (check for a typo)"
892                        )));
893                    }
894                }
895                config.unprune_dirs = dirs;
896            }
897            "conventions" => {
898                let s = parse_path_like_string(path, pkey, pvalue)?;
899                if s.is_empty() {
900                    warnings.push(ConfigWarning(format!(
901                        "`project.conventions` in {CONFIG_FILE_NAME} is an empty string \
902                         (ignored) — expected a built-in preset name (e.g. \"screenplay\") or a \
903                         path to a conventions module (e.g. \"conventions.brink\")"
904                    )));
905                } else {
906                    conventions_value = Some(s);
907                }
908            }
909            "elements" => {
910                let s = parse_path_like_string(path, pkey, pvalue)?;
911                if s.is_empty() {
912                    warnings.push(ConfigWarning(format!(
913                        "`project.elements` in {CONFIG_FILE_NAME} is an empty string (ignored) \
914                         — expected a built-in preset name (e.g. \"screenplay\") or a path to a \
915                         conventions module (e.g. \"conventions.brink\")"
916                    )));
917                } else {
918                    elements_value = Some(s);
919                }
920            }
921            "entry" => {
922                let s = parse_path_like_string(path, pkey, pvalue)?;
923                if s.is_empty() {
924                    warnings.push(ConfigWarning(format!(
925                        "`project.entry` in {CONFIG_FILE_NAME} is an empty string (ignored) — \
926                         expected a project-relative path to the entry file (e.g. \
927                         \"story.ink\")"
928                    )));
929                } else {
930                    config.entry = Some(s);
931                }
932            }
933            _ => warnings.push(ConfigWarning(format!(
934                "unknown key `project.{pkey}` in {CONFIG_FILE_NAME} (ignored)"
935            ))),
936        }
937    }
938    config.conventions = resolve_conventions_key(conventions_value, elements_value, warnings);
939    Ok(())
940}
941
942/// Parse `[prose]`.
943///
944/// An unrecognized dialect is a WARNING that falls back to the default
945/// rather than an error, matching how `indent` treats an out-of-range width:
946/// a typo in one key must not fail the whole config and take the project's
947/// entry point down with it.
948/// Parse the `[dialogue]` table (RULED 2026-08-30): `preset`,
949/// `run-ends-at`, and the `[[dialogue.elements]]` array of affix-sugar /
950/// pattern rows. Unknown keys warn (forward compat); wrong types error,
951/// like every other table here.
952fn parse_dialogue_table(
953    path: &str,
954    table: &toml::map::Map<String, Value>,
955    cfg: &mut DialogueConfig,
956    warnings: &mut Vec<ConfigWarning>,
957) -> Result<(), ConfigError> {
958    for (dkey, dvalue) in table {
959        match dkey.as_str() {
960            "preset" => {
961                cfg.preset = Some(
962                    dvalue
963                        .as_str()
964                        .ok_or_else(|| ConfigError::WrongType {
965                            path: path.to_owned(),
966                            key: format!("dialogue.{dkey}"),
967                            found: value_type_name(dvalue),
968                        })?
969                        .to_owned(),
970                );
971            }
972            "file" => {
973                cfg.file = Some(
974                    dvalue
975                        .as_str()
976                        .ok_or_else(|| ConfigError::WrongType {
977                            path: path.to_owned(),
978                            key: format!("dialogue.{dkey}"),
979                            found: value_type_name(dvalue),
980                        })?
981                        .to_owned(),
982                );
983            }
984            "run-ends-at" => {
985                cfg.run_ends_at = parse_string_list(path, &format!("dialogue.{dkey}"), dvalue)?;
986            }
987            "elements" => {
988                let rows = dvalue.as_array().ok_or_else(|| ConfigError::WrongType {
989                    path: path.to_owned(),
990                    key: format!("dialogue.{dkey}"),
991                    found: value_type_name(dvalue),
992                })?;
993                for (i, row) in rows.iter().enumerate() {
994                    let t = row.as_table().ok_or_else(|| ConfigError::NotATable {
995                        path: path.to_owned(),
996                        key: format!("dialogue.elements[{i}]"),
997                        found: value_type_name(row),
998                    })?;
999                    cfg.elements
1000                        .push(parse_dialogue_element(path, i, t, warnings)?);
1001                }
1002            }
1003            _ => warnings.push(ConfigWarning(format!(
1004                "unknown key `dialogue.{dkey}` in {CONFIG_FILE_NAME} (ignored)"
1005            ))),
1006        }
1007    }
1008    Ok(())
1009}
1010
1011/// One `[[dialogue.elements]]` row. `kind` is required; everything else is
1012/// optional and typed. Keys are kebab-case like the rest of the file
1013/// (`content-role`).
1014fn parse_dialogue_element(
1015    path: &str,
1016    index: usize,
1017    t: &toml::map::Map<String, Value>,
1018    warnings: &mut Vec<ConfigWarning>,
1019) -> Result<DialogueElementConfig, ConfigError> {
1020    let mut el = DialogueElementConfig::default();
1021    let key_of = |k: &str| format!("dialogue.elements[{index}].{k}");
1022    let str_at = |k: &str, v: &Value| -> Result<String, ConfigError> {
1023        v.as_str()
1024            .map(str::to_owned)
1025            .ok_or_else(|| ConfigError::WrongType {
1026                path: path.to_owned(),
1027                key: key_of(k),
1028                found: value_type_name(v),
1029            })
1030    };
1031    for (k, v) in t {
1032        match k.as_str() {
1033            "kind" => el.kind = str_at(k, v)?,
1034            "nature" => el.nature = Some(str_at(k, v)?),
1035            "prefix" => el.prefix = Some(str_at(k, v)?),
1036            "suffix" => el.suffix = Some(str_at(k, v)?),
1037            "content-role" => el.content_role = Some(str_at(k, v)?),
1038            "pattern" => el.pattern = Some(str_at(k, v)?),
1039            "template" => el.template = Some(str_at(k, v)?),
1040            "glued" => {
1041                el.glued = Some(v.as_bool().ok_or_else(|| ConfigError::WrongType {
1042                    path: path.to_owned(),
1043                    key: key_of(k),
1044                    found: value_type_name(v),
1045                })?);
1046            }
1047            _ => warnings.push(ConfigWarning(format!(
1048                "unknown key `{}` in {CONFIG_FILE_NAME} (ignored)",
1049                key_of(k)
1050            ))),
1051        }
1052    }
1053    if el.kind.is_empty() {
1054        return Err(ConfigError::WrongType {
1055            path: path.to_owned(),
1056            key: key_of("kind"),
1057            found: "missing (every element needs a `kind`)",
1058        });
1059    }
1060    Ok(el)
1061}
1062
1063fn parse_prose_table(
1064    path: &str,
1065    prose: &toml::map::Map<String, Value>,
1066    config: &mut ProjectConfig,
1067    warnings: &mut Vec<ConfigWarning>,
1068) -> Result<(), ConfigError> {
1069    for (pkey, pvalue) in prose {
1070        match pkey.as_str() {
1071            "dialect" => {
1072                let raw = pvalue.as_str().ok_or_else(|| ConfigError::WrongType {
1073                    path: path.to_owned(),
1074                    key: format!("prose.{pkey}"),
1075                    found: value_type_name(pvalue),
1076                })?;
1077                match raw {
1078                    "american" => config.prose_dialect = Some(ProseDialect::American),
1079                    "british" => config.prose_dialect = Some(ProseDialect::British),
1080                    "canadian" => config.prose_dialect = Some(ProseDialect::Canadian),
1081                    "australian" => config.prose_dialect = Some(ProseDialect::Australian),
1082                    other => warnings.push(ConfigWarning(format!(
1083                        "`prose.dialect` in {CONFIG_FILE_NAME} is `{other}` — expected one of \
1084                         `american`, `british`, `canadian`, `australian`; using \
1085                         `{}`",
1086                        ProseDialect::default().as_str()
1087                    ))),
1088                }
1089            }
1090            "enable" => {
1091                config.prose_enable =
1092                    Some(pvalue.as_bool().ok_or_else(|| ConfigError::WrongType {
1093                        path: path.to_owned(),
1094                        key: format!("prose.{pkey}"),
1095                        found: value_type_name(pvalue),
1096                    })?);
1097            }
1098            "dictionary" => {
1099                config.prose_dictionary =
1100                    parse_string_list(path, &format!("prose.{pkey}"), pvalue)?;
1101            }
1102            _ => warnings.push(ConfigWarning(format!(
1103                "unknown key `prose.{pkey}` in {CONFIG_FILE_NAME} (ignored)"
1104            ))),
1105        }
1106    }
1107    Ok(())
1108}
1109
1110fn parse_dialect(path: &str, key: &str, value: &Value) -> Result<Dialect, ConfigError> {
1111    let s = value.as_str().ok_or_else(|| ConfigError::WrongType {
1112        path: path.to_owned(),
1113        key: format!("project.{key}"),
1114        found: value_type_name(value),
1115    })?;
1116    match s {
1117        "brink" => Ok(Dialect::Brink),
1118        "strict-ink" => Ok(Dialect::StrictInk),
1119        other => Err(ConfigError::InvalidValue {
1120            path: path.to_owned(),
1121            key: format!("project.{key}"),
1122            expected: &["brink", "strict-ink"],
1123            found: other.to_owned(),
1124        }),
1125    }
1126}
1127
1128/// Reconcile `[project] conventions` against its deprecated `elements`
1129/// alias (issue #2180) into the one value [`ProjectConfig::conventions`]
1130/// carries, pushing whatever [`ConfigWarning`]s the reconciliation itself
1131/// warrants onto `warnings`.
1132///
1133/// `elements` is `conventions`'s deprecated predecessor (renamed post the
1134/// `@[element]`/`@[convention]` split, docs/decision-log.md's 2026-08-03
1135/// ruling) — accepted for a deprecation window rather than hard-broken,
1136/// since it's a silent-misconfiguration risk otherwise (an existing
1137/// project's `brink.toml` would stop configuring its conventions module
1138/// with no error at all, just quietly-disabled `E169` enforcement).
1139/// `conventions` always wins when both are set.
1140fn resolve_conventions_key(
1141    conventions_value: Option<String>,
1142    elements_value: Option<String>,
1143    warnings: &mut Vec<ConfigWarning>,
1144) -> Option<String> {
1145    match (conventions_value, elements_value) {
1146        (Some(c), Some(_)) => {
1147            warnings.push(ConfigWarning(format!(
1148                "`project.elements` and `project.conventions` are both set in \
1149                 {CONFIG_FILE_NAME} — `project.elements` is deprecated (renamed to \
1150                 `project.conventions`, issue #2180) and was ignored in favor of \
1151                 `project.conventions`"
1152            )));
1153            Some(c)
1154        }
1155        (Some(c), None) => Some(c),
1156        (None, Some(e)) => {
1157            warnings.push(ConfigWarning(format!(
1158                "`project.elements` in {CONFIG_FILE_NAME} is deprecated — rename to \
1159                 `project.conventions` (issue #2180: the key now names a module of \
1160                 `@[convention]` declarations, not `@[element]` ones)"
1161            )));
1162            Some(e)
1163        }
1164        (None, None) => None,
1165    }
1166}
1167
1168/// Parse a `[project]` key whose value is a bare project-relative path (or,
1169/// for `conventions`/`elements`, a built-in preset name): `conventions`
1170/// (§3.4's pointer mechanism), its deprecated `elements` alias (issue
1171/// #2180 — the raw string shape is identical for either key), and `entry`
1172/// (issue #2331) all share this exact validation. Accepts any non-empty
1173/// string, since this crate doesn't know the closed set of built-in preset
1174/// names and can't check a project path exists (kept dependency-free,
1175/// #1234) — each caller in [`parse_project_table`] flags an empty string as
1176/// a warning itself; this only enforces the TOML shape (a string, full
1177/// stop). Checking a bare (preset-shaped) `conventions`/`elements` value
1178/// against the real closed preset-name set is
1179/// `brink-analyzer::AnalysisOptions::apply_project_config`'s job (issue
1180/// #1874), the same "this crate stays dependency-free; the crate that owns
1181/// the closed set validates" split `[lints]`'s `validate_lint_code` uses;
1182/// `entry` has no preset-name form to check in the first place —
1183/// resolving whether it names a real project file is `ProjectSession`'s job
1184/// (`packages/ink-editor/src/project-session.ts`).
1185fn parse_path_like_string(path: &str, key: &str, value: &Value) -> Result<String, ConfigError> {
1186    value
1187        .as_str()
1188        .map(str::to_owned)
1189        .ok_or_else(|| ConfigError::WrongType {
1190            path: path.to_owned(),
1191            key: format!("project.{key}"),
1192            found: value_type_name(value),
1193        })
1194}
1195
1196fn parse_types(path: &str, key: &str, value: &Value) -> Result<TypePolicy, ConfigError> {
1197    let s = value.as_str().ok_or_else(|| ConfigError::WrongType {
1198        path: path.to_owned(),
1199        key: format!("project.{key}"),
1200        found: value_type_name(value),
1201    })?;
1202    match s {
1203        "gradual" => Ok(TypePolicy::Gradual),
1204        "strict" => Ok(TypePolicy::Strict),
1205        other => Err(ConfigError::InvalidValue {
1206            path: path.to_owned(),
1207            key: format!("project.{key}"),
1208            expected: &["gradual", "strict"],
1209            found: other.to_owned(),
1210        }),
1211    }
1212}
1213
1214fn parse_deny_warnings(path: &str, key: &str, value: &Value) -> Result<bool, ConfigError> {
1215    value.as_bool().ok_or_else(|| ConfigError::WrongType {
1216        path: path.to_owned(),
1217        key: format!("lints.{key}"),
1218        found: value_type_name(value),
1219    })
1220}
1221
1222fn parse_lint_level(path: &str, key: &str, value: &Value) -> Result<LintLevel, ConfigError> {
1223    let s = value.as_str().ok_or_else(|| ConfigError::WrongType {
1224        path: path.to_owned(),
1225        key: format!("lints.{key}"),
1226        found: value_type_name(value),
1227    })?;
1228    match s {
1229        "allow" => Ok(LintLevel::Allow),
1230        "warn" => Ok(LintLevel::Warn),
1231        "deny" => Ok(LintLevel::Deny),
1232        "info" => Ok(LintLevel::Info),
1233        "hint" => Ok(LintLevel::Hint),
1234        other => Err(ConfigError::InvalidValue {
1235            path: path.to_owned(),
1236            key: format!("lints.{key}"),
1237            expected: &["allow", "warn", "deny", "info", "hint"],
1238            found: other.to_owned(),
1239        }),
1240    }
1241}
1242
1243/// Parse one `[fix]` entry's value (issue #3419). Mirrors [`parse_lint_level`]
1244/// exactly: a wrong TOML type is [`ConfigError::WrongType`], a syntactically
1245/// fine string outside the three recognized spellings is
1246/// [`ConfigError::InvalidValue`] — never a panic either way.
1247fn parse_fix_policy(path: &str, key: &str, value: &Value) -> Result<FixPolicy, ConfigError> {
1248    let s = value.as_str().ok_or_else(|| ConfigError::WrongType {
1249        path: path.to_owned(),
1250        key: format!("fix.{key}"),
1251        found: value_type_name(value),
1252    })?;
1253    match s {
1254        "off" => Ok(FixPolicy::Off),
1255        "ask" => Ok(FixPolicy::Ask),
1256        "auto" => Ok(FixPolicy::Auto),
1257        other => Err(ConfigError::InvalidValue {
1258            path: path.to_owned(),
1259            key: format!("fix.{key}"),
1260            expected: &["off", "ask", "auto"],
1261            found: other.to_owned(),
1262        }),
1263    }
1264}
1265
1266/// Parse a TOML array-of-strings value (e.g. `[project] unprune-dirs`).
1267/// Every element must itself be a string — a non-string element (`[1, 2]`,
1268/// `[true]`) is [`ConfigError::WrongType`], matching the treatment every
1269/// other recognized-but-wrong-shaped value gets.
1270/// The indent width applied when `[project] indent` is unset.
1271pub const DEFAULT_INDENT: u8 = 4;
1272
1273/// The narrowest and widest indent this accepts.
1274///
1275/// Zero would make indentation meaningless (and indent guides undrawable);
1276/// the upper bound is a sanity rail rather than a technical limit — a value
1277/// past it is far more likely to be a typo than an intention, and silently
1278/// honouring `indent = 400` would produce a document nobody can read.
1279const INDENT_RANGE: std::ops::RangeInclusive<i64> = 1..=16;
1280
1281/// Parse `[project] indent`.
1282///
1283/// A non-integer is an ERROR (the author wrote something that cannot mean an
1284/// indent width), but an out-of-range integer is a WARNING that falls back to
1285/// [`DEFAULT_INDENT`] — unlike `dialect`, this key has a sensible default, so
1286/// a typo should not stop the project loading, and the result stays defined.
1287fn parse_indent(
1288    path: &str,
1289    key: &str,
1290    value: &Value,
1291    warnings: &mut Vec<ConfigWarning>,
1292) -> Result<u8, ConfigError> {
1293    let raw = value.as_integer().ok_or_else(|| ConfigError::WrongType {
1294        path: path.to_owned(),
1295        key: format!("project.{key}"),
1296        found: value_type_name(value),
1297    })?;
1298    if !INDENT_RANGE.contains(&raw) {
1299        let (lo, hi) = (INDENT_RANGE.start(), INDENT_RANGE.end());
1300        warnings.push(ConfigWarning(format!(
1301            "`project.indent` in {CONFIG_FILE_NAME} is {raw}, outside {lo}..={hi} — using the \
1302             default of {DEFAULT_INDENT} spaces instead"
1303        )));
1304        return Ok(DEFAULT_INDENT);
1305    }
1306    // The range check above bounds this to INDENT_RANGE.
1307    Ok(u8::try_from(raw).unwrap_or(DEFAULT_INDENT))
1308}
1309
1310/// Parse an array-of-strings value.
1311///
1312/// `key` is the FULLY QUALIFIED key (`project.drafts`, `prose.dictionary`),
1313/// not a bare name: this is reached from more than one table now, and a
1314/// hardcoded `project.` prefix would report the wrong path to an author
1315/// trying to find the line they mistyped.
1316fn parse_string_list(path: &str, key: &str, value: &Value) -> Result<Vec<String>, ConfigError> {
1317    let arr = value.as_array().ok_or_else(|| ConfigError::WrongType {
1318        path: path.to_owned(),
1319        key: key.to_owned(),
1320        found: value_type_name(value),
1321    })?;
1322    arr.iter()
1323        .map(|item| {
1324            item.as_str()
1325                .map(str::to_owned)
1326                .ok_or_else(|| ConfigError::WrongType {
1327                    path: path.to_owned(),
1328                    key: key.to_owned(),
1329                    found: value_type_name(item),
1330                })
1331        })
1332        .collect()
1333}
1334
1335fn value_type_name(value: &Value) -> &'static str {
1336    match value {
1337        Value::String(_) => "string",
1338        Value::Integer(_) => "integer",
1339        Value::Float(_) => "float",
1340        Value::Boolean(_) => "boolean",
1341        Value::Datetime(_) => "datetime",
1342        Value::Array(_) => "array",
1343        Value::Table(_) => "table",
1344    }
1345}
1346
1347/// Maximum number of ancestor directories [`find_config`]'s walk will climb
1348/// above `start_dir`, whether or not a `.git` boundary is ever found (#1435).
1349///
1350/// #1425 bounded the walk at a workspace/git boundary, but that boundary
1351/// only exists for a project under version control — a VCS-less tree has no
1352/// `.git` anywhere above it, so the walk still climbed all the way to the
1353/// filesystem root, exactly the unbounded-ancestor-walk shape this
1354/// codebase's "guard against unbounded growth" rule exists to catch. This
1355/// cap closes that gap unconditionally: it applies to *every* walk, not just
1356/// the VCS-less case, so the bound is one rule instead of two.
1357///
1358/// A fixed constant, not an environment- or filesystem-derived limit:
1359/// config discovery is a deterministic-compilation input (#1306), so how far
1360/// the walk climbs must never vary by machine, `$HOME` depth, or anything
1361/// else runtime-observable — only by `start_dir` itself. 32 is generously
1362/// above any real project layout in this workspace (the deepest nested
1363/// fixture is a handful of levels) while still being nowhere near "walk to
1364/// the filesystem root."
1365pub const MAX_ANCESTOR_DEPTH: usize = 32;
1366
1367/// Walk up from `start_dir` (inclusive) through every ancestor directory,
1368/// returning the path to the first [`CONFIG_FILE_NAME`] found. This is the
1369/// "walk up from the entry file to the nearest `brink.toml`" discovery rule
1370/// (#1005) — a project's entry `.ink` file doesn't have to sit directly
1371/// beside the config for every mount to find the same one.
1372///
1373/// A thin wrapper over [`find_config_with_warnings`] that discards its
1374/// [`ConfigWarning`]s — for callers with no warning channel of their own to
1375/// report them through. A caller that *does* have one (the LSP's
1376/// `tracing::warn!`, [`load_from_entry`]'s returned `Vec<ConfigWarning>` via
1377/// [`discover_from_entry_with_warnings`]) should call
1378/// [`find_config_with_warnings`] directly instead, per house rule 9 (silent
1379/// drops are always bugs until proven otherwise).
1380#[must_use]
1381pub fn find_config(start_dir: &Path) -> Option<PathBuf> {
1382    find_config_inner(start_dir, false).0
1383}
1384
1385/// [`find_config`], additionally reporting when the bounded walk stepped
1386/// over a `brink.toml` an author might reasonably have expected to be
1387/// discovered (#1435) — never used as the result, only as a
1388/// [`ConfigWarning`] so the caller can tell them it was ignored instead of
1389/// silently proceeding as if no config existed anywhere.
1390///
1391/// **Bounded two ways**, either of which stops the search phase:
1392///
1393/// - **Workspace/git boundary (#1425).** Before checking a directory's
1394///   parent, this stops if the directory itself contains a `.git` entry —
1395///   the marker of a repository root, whether it's an ordinary repository
1396///   (`.git/` is a directory) or a linked worktree (`.git` is a *file*
1397///   holding a `gitdir:` pointer, e.g. `.claude/worktrees/*` in this very
1398///   repo — checked with [`Path::exists`], not `is_dir`, so both shapes
1399///   count; the marker name itself is [`brink_source_tree::GIT_DIR_NAME`],
1400///   the same constant [`brink_source_tree::IGNORED_DIR_NAMES`] uses, so the
1401///   two never drift apart, #1435).
1402/// - **Ancestor depth cap ([`MAX_ANCESTOR_DEPTH`], #1435).** Applies
1403///   regardless of any `.git` boundary — the VCS-less case #1425 didn't
1404///   cover.
1405///
1406/// `start_dir` and every ancestor up to and including whichever boundary is
1407/// hit first are still probed for `brink.toml` — only climbing *past* it is
1408/// refused.
1409///
1410/// If neither bound stops the walk before it runs out of ancestors
1411/// naturally (reaches the filesystem root with nothing found), the search is
1412/// exhaustive and there is nothing above to warn about. If a bound *does*
1413/// stop it short, a second, equally bounded probe continues past that point
1414/// — read-only, purely to check whether a `brink.toml` exists somewhere
1415/// above (walk-up call sites in this workspace: [`find_config`],
1416/// `brink-lsp`'s `resolve_language_options`, `brink-driver`'s
1417/// `native_source_root`) — and if one does, [`ConfigError`]-free but
1418/// warning-worthy: the returned path is still `None` (it was never a
1419/// candidate the bound allowed), but a [`ConfigWarning`] names it so the
1420/// caller can tell the author their file was ignored.
1421#[must_use]
1422pub fn find_config_with_warnings(start_dir: &Path) -> (Option<PathBuf>, Vec<ConfigWarning>) {
1423    find_config_inner(start_dir, true)
1424}
1425
1426/// Shared implementation behind [`find_config`] and
1427/// [`find_config_with_warnings`]. `want_warnings` gates the second, bounded
1428/// probe past the stop point: [`find_config`] has nowhere to put a
1429/// [`ConfigWarning`] it would only immediately discard, so it passes `false`
1430/// and this function skips the probe's filesystem stats entirely instead of
1431/// running them and throwing the result away — up to [`MAX_ANCESTOR_DEPTH`]
1432/// (32) extra `is_file` calls per miss, climbing *past* the very
1433/// git/depth boundary the bound exists to stay inside, was wasted work every
1434/// discarding caller paid for unconditionally (review finding on #1435).
1435fn find_config_inner(
1436    start_dir: &Path,
1437    want_warnings: bool,
1438) -> (Option<PathBuf>, Vec<ConfigWarning>) {
1439    let mut dir = Some(start_dir);
1440    let mut depth = 0usize;
1441    // Where (and why) the primary search stopped short of the filesystem
1442    // root, if it did — `None` means it ran out of ancestors naturally.
1443    let mut stopped_at: Option<(PathBuf, &'static str)> = None;
1444
1445    while let Some(d) = dir {
1446        let candidate = d.join(CONFIG_FILE_NAME);
1447        if candidate.is_file() {
1448            return (Some(candidate), Vec::new());
1449        }
1450        if d.join(brink_source_tree::GIT_DIR_NAME).exists() {
1451            // Workspace/git boundary: this directory is the repository
1452            // root (or a linked worktree's root) and had no `brink.toml`
1453            // of its own — do not climb past it.
1454            stopped_at = Some((d.to_path_buf(), "workspace/git boundary"));
1455            break;
1456        }
1457        if depth >= MAX_ANCESTOR_DEPTH {
1458            // Ancestor depth cap: no `.git` boundary was found within
1459            // MAX_ANCESTOR_DEPTH climbs — do not climb further.
1460            stopped_at = Some((d.to_path_buf(), "ancestor depth limit"));
1461            break;
1462        }
1463        depth += 1;
1464        dir = d.parent();
1465    }
1466
1467    let Some((stopped_at, reason)) = stopped_at else {
1468        // The walk exhausted every real ancestor without hitting either
1469        // bound — there is nothing further up to have missed.
1470        return (None, Vec::new());
1471    };
1472
1473    if !want_warnings {
1474        // No warning channel to report through — skip the probe rather than
1475        // running it and discarding the result (#1435 review finding).
1476        return (None, Vec::new());
1477    }
1478
1479    // Bounded peek past the stop point, purely to detect a stray config an
1480    // author might expect to be picked up — its existence is reported as a
1481    // warning, but it is never returned as a result. Bounded by the same
1482    // cap so this detection pass cannot itself become an unbounded climb.
1483    let mut probe = stopped_at.parent();
1484    let mut probe_depth = 0usize;
1485    while let Some(p) = probe {
1486        let candidate = p.join(CONFIG_FILE_NAME);
1487        if candidate.is_file() {
1488            return (
1489                None,
1490                vec![ConfigWarning(format!(
1491                    "{} exists above the {reason} at {} and was ignored",
1492                    candidate.display(),
1493                    stopped_at.display(),
1494                ))],
1495            );
1496        }
1497        probe_depth += 1;
1498        if probe_depth >= MAX_ANCESTOR_DEPTH {
1499            break;
1500        }
1501        probe = p.parent();
1502    }
1503
1504    (None, Vec::new())
1505}
1506
1507/// [`find_config`], starting from an entry `.ink` file's directory rather
1508/// than a directory directly. The common case: `brink compile story.ink`
1509/// discovers `brink.toml` starting from `story.ink`'s parent.
1510#[must_use]
1511pub fn discover_from_entry(entry_file: &Path) -> Option<PathBuf> {
1512    let start = entry_file.parent().unwrap_or_else(|| Path::new("."));
1513    find_config(start)
1514}
1515
1516/// [`discover_from_entry`], surfacing [`find_config_with_warnings`]'s
1517/// [`ConfigWarning`]s instead of discarding them. [`load_from_entry`] uses
1518/// this rather than [`discover_from_entry`] so a config skipped by the
1519/// bounded walk is never silently dropped (#1435, house rule 9).
1520#[must_use]
1521pub fn discover_from_entry_with_warnings(
1522    entry_file: &Path,
1523) -> (Option<PathBuf>, Vec<ConfigWarning>) {
1524    let start = entry_file.parent().unwrap_or_else(|| Path::new("."));
1525    find_config_with_warnings(start)
1526}
1527
1528/// [`find_config`], but discovering over a [`SourceTree`] rather than the
1529/// real filesystem (#1312) — mount-agnostic: the same walk-up rule serves
1530/// the CLI's `RealFs` mount, a wasm sandbox's `InMemory` mount, a git
1531/// baseline's `GitRev` mount, or any future host, with no per-mount
1532/// discovery code duplicated outside the seam.
1533///
1534/// `start_key` is a root-relative directory key (`""` for the tree root
1535/// itself), in the same forward-slash-joined form [`SourceTree::list`]
1536/// returns. Walks `start_key` and every ancestor, closest first, probing
1537/// whether `{ancestor}/brink.toml` (bare `brink.toml` at the tree root)
1538/// exists via a direct [`SourceTree::read`] of each candidate key — the
1539/// tree-relative analog of [`find_config`]'s `Path::is_file` check at each
1540/// `Path::parent`.
1541///
1542/// This is an O(depth) probe, **not** a tree enumeration: unlike an earlier
1543/// version of this function, it never calls [`SourceTree::list`] (issue
1544/// #1370 — a full recursive tree walk, including `target/`/`.git`/
1545/// `node_modules`, just to test a handful of ancestor candidates was the
1546/// same waste #1357 removed from the CLI drain, relocated here). A `read`
1547/// that fails with [`io::ErrorKind::NotFound`] means "no `brink.toml` at
1548/// this candidate, keep walking up"; any other error kind (permission
1549/// denied, invalid encoding, ...) means a `brink.toml` *exists* at this
1550/// candidate but this probe couldn't read it — treated as "found" (returns
1551/// `Some(candidate)`) rather than propagated, so the caller's own
1552/// [`SourceTree::read`] of the returned key is what actually surfaces the
1553/// failure, with the path correctly attributed (see `brink-environment`'s
1554/// `LoadError::ConfigRead`, #1369). Propagating this probe's own read error
1555/// instead would report the same failure without a path — issue #1370's
1556/// fix regressed exactly that for a moment before this doc/behavior was
1557/// tightened; `tree`'s own [`SourceTree::read`] already resolves keys
1558/// against whatever root the tree was constructed with, so this function
1559/// needs no enumeration to know where to look.
1560///
1561/// Takes no `root` parameter: every current [`SourceTree`] implementation
1562/// resolves `read` keys against its own constructor-held root (issue #1371),
1563/// so there is nothing for a caller to supply here. An earlier version of
1564/// this function accepted (and ignored) a `root: &Path` for shape-symmetry
1565/// with [`SourceTree::list`]'s old signature; issue #1395 dropped it once
1566/// #1371 made the equivalent parameter dead on `list` too, closing the gap
1567/// left when this function's own dead parameter wasn't swept up at the same
1568/// time.
1569///
1570/// Returns the matching key, not file content — callers read it via
1571/// [`SourceTree::read`] (mirroring how [`find_config`] returns a path the
1572/// caller reads via `std::fs`, not file content).
1573///
1574/// Already bounded at the tree's own root, so it needed no change for #1425
1575/// or #1435 (unlike [`find_config`]'s `.git`-directory and
1576/// [`MAX_ANCESTOR_DEPTH`] bounds): a key's ancestors are string-derived
1577/// (`rsplit_once('/')`), bottoming out at the empty root key with nothing
1578/// further to strip — there is no lexical equivalent of `find_config`'s
1579/// `Path::parent` climb here for a depth cap to even apply to. It can only
1580/// ever "escape" the project if the `tree` itself is rooted somewhere too
1581/// wide (a caller concern, not this function's).
1582pub fn find_config_in_tree(tree: &dyn SourceTree, start_key: &str) -> io::Result<Option<String>> {
1583    let mut dir = start_key.trim_matches('/');
1584    loop {
1585        let candidate = if dir.is_empty() {
1586            CONFIG_FILE_NAME.to_owned()
1587        } else {
1588            format!("{dir}/{CONFIG_FILE_NAME}")
1589        };
1590        match tree.read(&candidate) {
1591            Err(err) if err.kind() == io::ErrorKind::NotFound => {}
1592            // Found: either the read actually succeeded, or it failed with
1593            // some other error kind — which, under the `SourceTree::read`
1594            // contract, implies the candidate exists but this probe read
1595            // just couldn't consume it. Report it as found either way; the
1596            // caller's own read of the same key is what turns a probe-read
1597            // failure into a path-attributed error (`LoadError::ConfigRead`)
1598            // instead of a bare, pathless one.
1599            Ok(_) | Err(_) => return Ok(Some(candidate)),
1600        }
1601        if dir.is_empty() {
1602            return Ok(None);
1603        }
1604        dir = match dir.rsplit_once('/') {
1605            Some((parent, _)) => parent,
1606            None => "",
1607        };
1608    }
1609}
1610
1611/// [`find_config_in_tree`], starting from an entry `.brink`/`.ink` file's
1612/// root-relative key rather than a directory key directly — the
1613/// [`SourceTree`] analog of [`discover_from_entry`].
1614pub fn discover_from_entry_in_tree(
1615    tree: &dyn SourceTree,
1616    entry_key: &str,
1617) -> io::Result<Option<String>> {
1618    let start = match entry_key.trim_matches('/').rsplit_once('/') {
1619        Some((parent, _)) => parent,
1620        None => "",
1621    };
1622    find_config_in_tree(tree, start)
1623}
1624
1625/// Discover (via [`discover_from_entry_with_warnings`]) and parse (via
1626/// [`parse_str`]) the `brink.toml` governing `entry_file`'s project, if one
1627/// exists.
1628///
1629/// Returns `Ok((None, warnings))` — never an error — when no `brink.toml` is
1630/// found within the bounded walk (see [`find_config_with_warnings`]):
1631/// `warnings` is empty in the ordinary "genuinely no config anywhere" case
1632/// (current behavior exactly, no regression), and carries a
1633/// [`ConfigWarning`] in the `#1435` case — a `brink.toml` existed above the
1634/// walk's workspace/git or ancestor-depth bound and was skipped. Discovery
1635/// warnings are returned alongside the result rather than folded into
1636/// [`LoadedConfig::warnings`] because there is no [`LoadedConfig`] to hold
1637/// them when nothing was loaded; when a config *is* found, this vec is
1638/// always empty and [`LoadedConfig::warnings`] (the file's own parse-time
1639/// warnings) is the vec to read instead.
1640pub fn load_from_entry(
1641    entry_file: &Path,
1642) -> Result<(Option<LoadedConfig>, Vec<ConfigWarning>), ConfigError> {
1643    let (path, discovery_warnings) = discover_from_entry_with_warnings(entry_file);
1644    let Some(path) = path else {
1645        return Ok((None, discovery_warnings));
1646    };
1647    let text = std::fs::read_to_string(&path).map_err(|source| ConfigError::Io {
1648        path: path.clone(),
1649        source,
1650    })?;
1651    let (config, warnings) = parse_str_at(path.display().to_string(), &text)?;
1652    Ok((
1653        Some(LoadedConfig {
1654            path,
1655            config,
1656            warnings,
1657        }),
1658        Vec::new(),
1659    ))
1660}
1661
1662#[cfg(test)]
1663mod tests {
1664
1665    mod drafts {
1666        use super::super::{globs, parse_str};
1667
1668        #[test]
1669        fn drafts_parse_as_a_string_list() {
1670            let (config, warnings) =
1671                parse_str("[project]\ndrafts = [\"scratch/**\", \"*.draft.ink\"]\n")
1672                    .expect("valid config");
1673            assert_eq!(config.drafts, vec!["scratch/**", "*.draft.ink"]);
1674            assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1675        }
1676
1677        #[test]
1678        fn drafts_default_to_empty() {
1679            let (config, _) = parse_str("[project]\nentry = \"main.ink\"\n").expect("valid");
1680            assert!(config.drafts.is_empty());
1681        }
1682
1683        #[test]
1684        fn a_non_project_relative_glob_warns_but_still_parses() {
1685            // Kept rather than rejected: an absolute or `..` pattern simply
1686            // never matches, and a warning says so where an error would
1687            // block the whole config over one inert line.
1688            let (config, warnings) =
1689                parse_str("[project]\ndrafts = [\"/tmp/**\", \"../out/**\", \"\"]\n")
1690                    .expect("valid config");
1691            assert_eq!(config.drafts.len(), 3);
1692            assert_eq!(warnings.len(), 3, "got {warnings:?}");
1693            assert!(warnings.iter().any(|w| w.0.contains("/tmp/**")));
1694            assert!(warnings.iter().any(|w| w.0.contains("../out/**")));
1695            assert!(warnings.iter().any(|w| w.0.contains("empty string")));
1696            // ...and the warning is honest about them being inert.
1697            assert!(!globs::matches_any("tmp/notes.ink", &config.drafts));
1698        }
1699
1700        #[test]
1701        fn a_non_list_value_is_an_error() {
1702            assert!(parse_str("[project]\ndrafts = \"scratch/**\"\n").is_err());
1703        }
1704    }
1705    use super::*;
1706
1707    // ── parse_str ────────────────────────────────────────────────────
1708
1709    #[test]
1710    fn empty_document_is_empty_config_no_warnings() {
1711        let (config, warnings) = parse_str("").unwrap();
1712        assert_eq!(config, ProjectConfig::default());
1713        assert!(config.is_empty());
1714        assert!(warnings.is_empty());
1715    }
1716
1717    #[test]
1718    fn parses_dialect_and_types() {
1719        let (config, warnings) = parse_str(
1720            r#"
1721            [project]
1722            dialect = "brink"
1723            types = "strict"
1724            "#,
1725        )
1726        .unwrap();
1727        assert_eq!(config.dialect, Some(Dialect::Brink));
1728        assert_eq!(config.types, Some(TypePolicy::Strict));
1729        assert!(warnings.is_empty());
1730    }
1731
1732    #[test]
1733    fn parses_strict_ink_and_gradual() {
1734        let (config, warnings) = parse_str(
1735            r#"
1736            [project]
1737            dialect = "strict-ink"
1738            types = "gradual"
1739            "#,
1740        )
1741        .unwrap();
1742        assert_eq!(config.dialect, Some(Dialect::StrictInk));
1743        assert_eq!(config.types, Some(TypePolicy::Gradual));
1744        assert!(warnings.is_empty());
1745    }
1746
1747    #[test]
1748    fn partial_project_table_leaves_other_field_none() {
1749        let (config, _) = parse_str("[project]\ndialect = \"brink\"\n").unwrap();
1750        assert_eq!(config.dialect, Some(Dialect::Brink));
1751        assert_eq!(config.types, None);
1752    }
1753
1754    #[test]
1755    fn unknown_top_level_key_warns_not_errors() {
1756        let (config, warnings) = parse_str("future_section = 1\n").unwrap();
1757        assert!(config.is_empty());
1758        assert_eq!(warnings.len(), 1);
1759        assert!(warnings[0].0.contains("future_section"));
1760    }
1761
1762    #[test]
1763    fn unknown_project_key_warns_not_errors() {
1764        let (config, warnings) =
1765            parse_str("[project]\ndialect = \"brink\"\nfuture_key = \"x\"\n").unwrap();
1766        assert_eq!(config.dialect, Some(Dialect::Brink));
1767        assert_eq!(warnings.len(), 1);
1768        assert!(warnings[0].0.contains("project.future_key"));
1769    }
1770
1771    // ── [prose] (issue #3211) ───────────────────────────────────────────
1772
1773    #[test]
1774    fn parses_the_prose_table() {
1775        let (config, warnings) =
1776            parse_str("[prose]\ndialect = \"british\"\nenable = true\n").expect("valid");
1777        assert_eq!(config.prose_dialect, Some(ProseDialect::British));
1778        assert_eq!(config.prose_enable, Some(true));
1779        assert!(warnings.is_empty(), "{warnings:?}");
1780    }
1781
1782    #[test]
1783    fn parses_the_prose_dictionary() {
1784        let (config, warnings) =
1785            parse_str("[prose]\ndictionary = [\n  \"Griswold\",\n  \"Kaelen\",\n]\n")
1786                .expect("valid");
1787        assert_eq!(config.prose_dictionary, vec!["Griswold", "Kaelen"]);
1788        assert!(warnings.is_empty(), "{warnings:?}");
1789    }
1790
1791    #[test]
1792    fn an_absent_prose_dictionary_is_empty_rather_than_an_error() {
1793        let (config, _) = parse_str("[prose]\ndialect = \"british\"\n").expect("valid");
1794        assert!(config.prose_dictionary.is_empty());
1795    }
1796
1797    #[test]
1798    fn a_prose_dictionary_that_is_not_a_list_of_strings_reports_its_own_key() {
1799        // Not `project.dictionary` — `parse_string_list` used to hardcode the
1800        // `project.` prefix, which would send an author looking in the wrong
1801        // table for the line they mistyped.
1802        let err = parse_str("[prose]\ndictionary = [1, 2]\n").expect_err("not strings");
1803        let text = err.to_string();
1804        assert!(text.contains("prose.dictionary"), "{text}");
1805    }
1806
1807    #[test]
1808    fn a_dictionary_makes_the_config_non_empty() {
1809        // `is_empty` gates whether a discovered config is applied at all; a
1810        // file whose only content is a dictionary must still count.
1811        let (config, _) = parse_str("[prose]\ndictionary = [\"Ada\"]\n").expect("valid");
1812        assert!(!config.is_empty());
1813    }
1814
1815    #[test]
1816    fn prose_keys_are_none_when_unset_so_callers_apply_their_own_defaults() {
1817        let (config, _) = parse_str("[project]\nentry = \"story.ink\"\n").expect("valid");
1818        assert_eq!(config.prose_dialect, None);
1819        assert_eq!(config.prose_enable, None);
1820    }
1821
1822    #[test]
1823    fn every_dialect_spelling_round_trips() {
1824        // The four strings are the wire contract with the checker; a rename
1825        // on either side has to break something visible.
1826        for (raw, expected) in [
1827            ("american", ProseDialect::American),
1828            ("british", ProseDialect::British),
1829            ("canadian", ProseDialect::Canadian),
1830            ("australian", ProseDialect::Australian),
1831        ] {
1832            let (config, warnings) =
1833                parse_str(&format!("[prose]\ndialect = \"{raw}\"\n")).expect("valid");
1834            assert_eq!(config.prose_dialect, Some(expected), "parsing {raw}");
1835            assert_eq!(expected.as_str(), raw, "as_str for {raw}");
1836            assert!(warnings.is_empty(), "{warnings:?}");
1837        }
1838    }
1839
1840    #[test]
1841    fn an_unknown_dialect_warns_and_falls_back_rather_than_failing_the_config() {
1842        // A typo in one key must not take the project's entry point down with
1843        // it — same treatment `indent` gives an out-of-range width.
1844        let (config, warnings) =
1845            parse_str("[project]\nentry = \"story.ink\"\n\n[prose]\ndialect = \"martian\"\n")
1846                .expect("still valid");
1847        assert_eq!(
1848            config.prose_dialect, None,
1849            "falls back to the caller default"
1850        );
1851        assert_eq!(
1852            config.entry.as_deref(),
1853            Some("story.ink"),
1854            "the rest still parsed"
1855        );
1856        assert_eq!(warnings.len(), 1, "{warnings:?}");
1857        assert!(warnings[0].0.contains("martian"), "{warnings:?}");
1858    }
1859
1860    #[test]
1861    fn an_unknown_prose_key_warns_and_is_ignored() {
1862        let (config, warnings) = parse_str("[prose]\nvoice = \"formal\"\n").expect("valid");
1863        assert!(config.is_empty());
1864        assert_eq!(warnings.len(), 1, "{warnings:?}");
1865        assert!(warnings[0].0.contains("prose.voice"), "{warnings:?}");
1866    }
1867
1868    #[test]
1869    fn a_prose_table_that_is_not_a_table_is_an_error() {
1870        assert!(parse_str("prose = 3\n").is_err());
1871    }
1872
1873    // ── indent (issue #3149) ────────────────────────────────────────────
1874
1875    #[test]
1876    fn parses_indent() {
1877        let (config, warnings) = parse_str("[project]\nindent = 2\n").expect("valid");
1878        assert_eq!(config.indent, Some(2));
1879        assert!(warnings.is_empty(), "{warnings:?}");
1880    }
1881
1882    #[test]
1883    fn indent_is_none_when_unset_so_callers_apply_the_default() {
1884        // `None` is meaningfully different from `Some(DEFAULT_INDENT)`: it is
1885        // what lets a caller tell "the author did not say" from "the author
1886        // chose four", which matters if the default ever moves.
1887        let (config, _) = parse_str("[project]\n").expect("valid");
1888        assert_eq!(config.indent, None);
1889    }
1890
1891    #[test]
1892    fn a_non_integer_indent_is_an_error() {
1893        let err = parse_str("[project]\nindent = \"four\"\n").unwrap_err();
1894        assert!(matches!(err, ConfigError::WrongType { .. }), "got {err:?}");
1895    }
1896
1897    #[test]
1898    fn an_out_of_range_indent_warns_and_falls_back() {
1899        // Unlike `dialect`, this key HAS a sensible default, so a silly value
1900        // should not stop the project loading — but it must not pass silently
1901        // either, or the author sees indentation they did not ask for with no
1902        // explanation.
1903        for raw in ["0", "17", "400", "-2"] {
1904            let (config, warnings) =
1905                parse_str(&format!("[project]\nindent = {raw}\n")).expect("loads anyway");
1906            assert_eq!(config.indent, Some(DEFAULT_INDENT), "for {raw}");
1907            assert_eq!(warnings.len(), 1, "for {raw}: {warnings:?}");
1908            assert!(warnings[0].0.contains("indent"), "for {raw}: {warnings:?}");
1909        }
1910    }
1911
1912    #[test]
1913    fn the_range_bounds_are_themselves_accepted() {
1914        for raw in ["1", "16"] {
1915            let (config, warnings) =
1916                parse_str(&format!("[project]\nindent = {raw}\n")).expect("valid");
1917            assert!(warnings.is_empty(), "for {raw}: {warnings:?}");
1918            assert_eq!(
1919                config.indent.map(u32::from),
1920                Some(raw.parse::<u32>().expect("num"))
1921            );
1922        }
1923    }
1924
1925    // ── unprune-dirs (issue #1407) ──────────────────────────────────────
1926
1927    #[test]
1928    fn parses_unprune_dirs() {
1929        let (config, warnings) = parse_str(
1930            r#"
1931            [project]
1932            unprune-dirs = ["node_modules", "target"]
1933            "#,
1934        )
1935        .unwrap();
1936        assert_eq!(
1937            config.unprune_dirs,
1938            vec!["node_modules".to_string(), "target".to_string()]
1939        );
1940        assert!(!config.is_empty());
1941        assert!(
1942            warnings.is_empty(),
1943            "both names are real IGNORED_DIR_NAMES entries, no warning expected: {warnings:?}"
1944        );
1945    }
1946
1947    /// An `unprune-dirs` entry that isn't one of the three actually-pruned
1948    /// names is a no-op (there was nothing to un-prune) — likely a typo, so
1949    /// it warns rather than silently doing nothing (house-rule "validate
1950    /// user-supplied config keys").
1951    #[test]
1952    fn unprune_dirs_entry_outside_ignored_dir_names_warns() {
1953        let (config, warnings) = parse_str(
1954            r#"
1955            [project]
1956            unprune-dirs = ["node-modules"]
1957            "#,
1958        )
1959        .unwrap();
1960        assert_eq!(config.unprune_dirs, vec!["node-modules".to_string()]);
1961        assert_eq!(warnings.len(), 1);
1962        assert!(warnings[0].0.contains("node-modules"));
1963        assert!(warnings[0].0.contains("unprune-dirs"));
1964    }
1965
1966    #[test]
1967    fn unprune_dirs_wrong_element_type_is_an_error() {
1968        let err = parse_str("[project]\nunprune-dirs = [1, 2]\n").unwrap_err();
1969        assert!(matches!(err, ConfigError::WrongType { .. }));
1970    }
1971
1972    #[test]
1973    fn unprune_dirs_not_an_array_is_an_error() {
1974        let err = parse_str("[project]\nunprune-dirs = \"node_modules\"\n").unwrap_err();
1975        assert!(matches!(err, ConfigError::WrongType { .. }));
1976    }
1977
1978    #[test]
1979    fn empty_unprune_dirs_is_not_a_warning_and_leaves_config_empty_by_itself() {
1980        let (config, warnings) = parse_str("[project]\nunprune-dirs = []\n").unwrap();
1981        assert!(config.unprune_dirs.is_empty());
1982        assert!(warnings.is_empty());
1983        // An explicit empty array still counts as "set" for `is_empty()`'s
1984        // purposes only if non-empty — an empty list is indistinguishable
1985        // from unset here, matching `lints`' own empty-map convention.
1986        assert!(config.is_empty());
1987    }
1988
1989    #[test]
1990    fn invalid_dialect_value_is_an_error() {
1991        let err = parse_str("[project]\ndialect = \"sideways\"\n").unwrap_err();
1992        assert!(matches!(err, ConfigError::InvalidValue { .. }));
1993    }
1994
1995    // ── conventions (issue #1844, renamed from `elements` by #2180) ──────
1996
1997    #[test]
1998    fn parses_conventions_as_a_path() {
1999        let (config, warnings) = parse_str(
2000            r#"
2001            [project]
2002            conventions = "conventions.brink"
2003            "#,
2004        )
2005        .unwrap();
2006        assert_eq!(config.conventions.as_deref(), Some("conventions.brink"));
2007        assert!(!config.is_empty());
2008        assert!(warnings.is_empty(), "{warnings:?}");
2009    }
2010
2011    #[test]
2012    fn parses_conventions_as_a_preset_name() {
2013        let (config, _warnings) = parse_str("[project]\nconventions = \"screenplay\"\n").unwrap();
2014        assert_eq!(config.conventions.as_deref(), Some("screenplay"));
2015    }
2016
2017    #[test]
2018    fn empty_conventions_string_warns_and_is_not_set() {
2019        let (config, warnings) = parse_str("[project]\nconventions = \"\"\n").unwrap();
2020        assert_eq!(config.conventions, None);
2021        assert!(config.is_empty());
2022        assert_eq!(warnings.len(), 1);
2023        assert!(warnings[0].0.contains("conventions"));
2024    }
2025
2026    #[test]
2027    fn conventions_wrong_type_is_an_error() {
2028        let err = parse_str("[project]\nconventions = 1\n").unwrap_err();
2029        assert!(matches!(err, ConfigError::WrongType { .. }));
2030    }
2031
2032    #[test]
2033    fn unset_conventions_leaves_config_empty_by_itself() {
2034        let (config, _warnings) = parse_str("[project]\ndialect = \"brink\"\n").unwrap();
2035        assert_eq!(config.conventions, None);
2036    }
2037
2038    // ── entry (issue #2331, ruled 2026-08-07) ────────────────────────────
2039
2040    #[test]
2041    fn parses_entry_as_a_project_relative_path() {
2042        let (config, warnings) = parse_str(
2043            r#"
2044            [project]
2045            entry = "story.ink"
2046            "#,
2047        )
2048        .unwrap();
2049        assert_eq!(config.entry.as_deref(), Some("story.ink"));
2050        assert!(!config.is_empty());
2051        assert!(warnings.is_empty(), "{warnings:?}");
2052    }
2053
2054    #[test]
2055    fn parses_entry_nested_under_a_directory() {
2056        let (config, _warnings) =
2057            parse_str("[project]\nentry = \"chapters/main.brink\"\n").unwrap();
2058        assert_eq!(config.entry.as_deref(), Some("chapters/main.brink"));
2059    }
2060
2061    #[test]
2062    fn empty_entry_string_warns_and_is_not_set() {
2063        let (config, warnings) = parse_str("[project]\nentry = \"\"\n").unwrap();
2064        assert_eq!(config.entry, None);
2065        assert!(config.is_empty());
2066        assert_eq!(warnings.len(), 1);
2067        assert!(warnings[0].0.contains("entry"));
2068    }
2069
2070    #[test]
2071    fn entry_wrong_type_is_an_error() {
2072        let err = parse_str("[project]\nentry = 1\n").unwrap_err();
2073        assert!(matches!(err, ConfigError::WrongType { .. }));
2074    }
2075
2076    #[test]
2077    fn unset_entry_leaves_config_empty_by_itself() {
2078        let (config, _warnings) = parse_str("[project]\ndialect = \"brink\"\n").unwrap();
2079        assert_eq!(config.entry, None);
2080    }
2081
2082    #[test]
2083    fn entry_and_conventions_coexist_independently() {
2084        let (config, warnings) =
2085            parse_str("[project]\nentry = \"story.ink\"\nconventions = \"conventions.brink\"\n")
2086                .unwrap();
2087        assert_eq!(config.entry.as_deref(), Some("story.ink"));
2088        assert_eq!(config.conventions.as_deref(), Some("conventions.brink"));
2089        assert!(warnings.is_empty(), "{warnings:?}");
2090    }
2091
2092    // ── `elements` deprecated alias (issue #2180) ────────────────────────
2093
2094    /// The old key still works — a hard break would silently un-configure
2095    /// every existing project's conventions module (and its `E169`
2096    /// enforcement) the moment it upgrades, with no error at all.
2097    #[test]
2098    fn elements_alias_still_sets_conventions_but_warns() {
2099        let (config, warnings) = parse_str("[project]\nelements = \"conventions.brink\"\n")
2100            .expect("deprecated `elements` key must still parse, not hard-error");
2101        assert_eq!(config.conventions.as_deref(), Some("conventions.brink"));
2102        assert_eq!(warnings.len(), 1, "{warnings:?}");
2103        assert!(warnings[0].0.contains("project.elements"));
2104        assert!(warnings[0].0.contains("deprecated"));
2105        assert!(warnings[0].0.contains("project.conventions"));
2106    }
2107
2108    #[test]
2109    fn empty_elements_alias_string_warns_and_is_not_set() {
2110        let (config, warnings) = parse_str("[project]\nelements = \"\"\n").unwrap();
2111        assert_eq!(config.conventions, None);
2112        assert!(config.is_empty());
2113        // Only the empty-string warning fires — an empty value never
2114        // reaches `elements_value`, so there is nothing to also warn as a
2115        // deprecated-but-set alias.
2116        assert_eq!(warnings.len(), 1, "{warnings:?}");
2117        assert!(warnings[0].0.contains("elements"));
2118    }
2119
2120    #[test]
2121    fn elements_alias_wrong_type_is_an_error() {
2122        let err = parse_str("[project]\nelements = 1\n").unwrap_err();
2123        assert!(matches!(err, ConfigError::WrongType { .. }));
2124    }
2125
2126    /// `conventions` always wins when both keys are set — and the conflict
2127    /// itself is warned about, so an author isn't left guessing which value
2128    /// took effect.
2129    #[test]
2130    fn both_conventions_and_elements_set_prefers_conventions_and_warns() {
2131        let (config, warnings) = parse_str(
2132            r#"
2133            [project]
2134            conventions = "new.brink"
2135            elements = "old.brink"
2136            "#,
2137        )
2138        .unwrap();
2139        assert_eq!(config.conventions.as_deref(), Some("new.brink"));
2140        assert_eq!(warnings.len(), 1, "{warnings:?}");
2141        assert!(warnings[0].0.contains("project.elements"));
2142        assert!(warnings[0].0.contains("project.conventions"));
2143        assert!(warnings[0].0.contains("both set"));
2144    }
2145
2146    #[test]
2147    fn invalid_types_value_is_an_error() {
2148        let err = parse_str("[project]\ntypes = \"loose\"\n").unwrap_err();
2149        assert!(matches!(err, ConfigError::InvalidValue { .. }));
2150    }
2151
2152    #[test]
2153    fn wrong_type_value_is_an_error() {
2154        let err = parse_str("[project]\ndialect = 1\n").unwrap_err();
2155        assert!(matches!(err, ConfigError::WrongType { .. }));
2156    }
2157
2158    #[test]
2159    fn malformed_toml_is_an_error() {
2160        let err = parse_str("this is not [ toml").unwrap_err();
2161        assert!(matches!(err, ConfigError::Toml { .. }));
2162    }
2163
2164    #[test]
2165    fn non_table_root_is_an_error() {
2166        let err = parse_str("\"just a string\"").unwrap_err();
2167        assert!(matches!(
2168            err,
2169            ConfigError::NotATable { .. } | ConfigError::Toml { .. }
2170        ));
2171    }
2172
2173    // ── path/span threading (#1384) ─────────────────────────────────────
2174
2175    /// Every [`ConfigError`] channel names the file it came from — the CLI
2176    /// message, the LSP diagnostic, and now (#1384) the error's own
2177    /// `Display`, structurally rather than by convention at each call site.
2178    #[test]
2179    fn parse_str_at_names_its_path_on_invalid_value() {
2180        let err =
2181            parse_str_at("chapters/brink.toml", "[project]\ndialect = \"sideways\"\n").unwrap_err();
2182        assert_eq!(err.path(), "chapters/brink.toml");
2183        assert!(
2184            err.to_string().contains("chapters/brink.toml"),
2185            "message must name the file, got: {err}"
2186        );
2187        assert!(
2188            matches!(err, ConfigError::InvalidValue { .. }),
2189            "expected InvalidValue, got: {err:?}"
2190        );
2191    }
2192
2193    #[test]
2194    fn parse_str_at_names_its_path_on_malformed_toml() {
2195        let err = parse_str_at("chapters/brink.toml", "this is not [ toml").unwrap_err();
2196        assert_eq!(err.path(), "chapters/brink.toml");
2197        assert!(
2198            err.to_string().contains("chapters/brink.toml"),
2199            "message must name the file, got: {err}"
2200        );
2201        assert!(
2202            matches!(err, ConfigError::Toml { .. }),
2203            "expected Toml, got: {err:?}"
2204        );
2205    }
2206
2207    #[test]
2208    fn parse_str_at_names_its_path_on_wrong_type() {
2209        let err = parse_str_at("chapters/brink.toml", "[project]\ndialect = 1\n").unwrap_err();
2210        assert_eq!(err.path(), "chapters/brink.toml");
2211        assert!(err.to_string().contains("chapters/brink.toml"));
2212        assert!(
2213            matches!(err, ConfigError::WrongType { .. }),
2214            "expected WrongType, got: {err:?}"
2215        );
2216    }
2217
2218    #[test]
2219    fn parse_str_at_names_its_path_on_not_a_table() {
2220        // `project = 1` parses fine as TOML (root table with an integer
2221        // value), so this exercises `NotATable`, not `Toml` — a bare string
2222        // like `"just a string"` is invalid TOML *syntax* and would hit the
2223        // `Toml` arm instead, duplicating the malformed-syntax test above and
2224        // leaving `NotATable`'s `path` field uncovered.
2225        let err = parse_str_at("chapters/brink.toml", "project = 1\n").unwrap_err();
2226        assert_eq!(err.path(), "chapters/brink.toml");
2227        assert!(err.to_string().contains("chapters/brink.toml"));
2228        assert!(
2229            matches!(err, ConfigError::NotATable { .. }),
2230            "expected NotATable, got: {err:?}"
2231        );
2232    }
2233
2234    /// `parse_str` (the pathless entry point) still falls back to the bare
2235    /// [`CONFIG_FILE_NAME`] rather than an empty/absent path — a caller with
2236    /// no discovered location still gets a named, non-empty `path()`.
2237    #[test]
2238    fn parse_str_falls_back_to_config_file_name_as_path() {
2239        let err = parse_str("[project]\ndialect = \"sideways\"\n").unwrap_err();
2240        assert_eq!(err.path(), CONFIG_FILE_NAME);
2241    }
2242
2243    /// Malformed TOML *syntax* carries a byte span from the underlying
2244    /// `toml` crate — a malformed value's line, not just its file, is
2245    /// locatable (#1384's "a malformed value cannot be located precisely"
2246    /// gap, for the syntax-error half of it). The span must point at the
2247    /// actual offending text, not just be present.
2248    #[test]
2249    fn toml_syntax_error_carries_a_span_pointing_at_the_bad_text() {
2250        let text = "[project]\ndialect = \"brink\" oops\n";
2251        let err = parse_str_at("brink.toml", text).unwrap_err();
2252        let span = err.span().expect("malformed TOML syntax must carry a span");
2253        assert!(span.start > 0, "span must not point at the file start");
2254        // The reported range must fall on the malformed second line, not the
2255        // first (well-formed) line.
2256        let first_line_end = text.find('\n').unwrap();
2257        assert!(
2258            span.start > first_line_end,
2259            "span {span:?} must point past the first line (ends at {first_line_end})"
2260        );
2261    }
2262
2263    /// `InvalidValue` fires *after* the document parses successfully (a
2264    /// syntactically fine string that just isn't a recognized variant), so
2265    /// there is no narrower-than-file location available — `span()` must be
2266    /// `None`, not a stale or zeroed range that looks meaningful but isn't.
2267    #[test]
2268    fn invalid_value_error_has_no_span() {
2269        let err = parse_str_at("brink.toml", "[project]\ndialect = \"sideways\"\n").unwrap_err();
2270        assert_eq!(err.span(), None);
2271    }
2272
2273    // ── [lints] ──────────────────────────────────────────────────────
2274
2275    #[test]
2276    fn parses_per_code_lint_levels() {
2277        let (config, warnings) = parse_str(
2278            r#"
2279            [lints]
2280            E063 = "deny"
2281            E014 = "allow"
2282            E022 = "warn"
2283            "#,
2284        )
2285        .unwrap();
2286        assert_eq!(config.lints.get("E063"), Some(&LintLevel::Deny));
2287        assert_eq!(config.lints.get("E014"), Some(&LintLevel::Allow));
2288        assert_eq!(config.lints.get("E022"), Some(&LintLevel::Warn));
2289        assert!(warnings.is_empty());
2290    }
2291
2292    /// #1162: `[lints]` must be able to down-level a code to either advisory
2293    /// tier below `Warning`, not just `allow`/`warn`/`deny`.
2294    #[test]
2295    fn parses_info_and_hint_lint_levels() {
2296        let (config, warnings) = parse_str(
2297            r#"
2298            [lints]
2299            E014 = "info"
2300            E022 = "hint"
2301            "#,
2302        )
2303        .unwrap();
2304        assert_eq!(config.lints.get("E014"), Some(&LintLevel::Info));
2305        assert_eq!(config.lints.get("E022"), Some(&LintLevel::Hint));
2306        assert!(warnings.is_empty());
2307    }
2308
2309    #[test]
2310    fn parses_deny_warnings_flag() {
2311        let (config, _) = parse_str("[lints]\ndeny-warnings = true\n").unwrap();
2312        assert_eq!(config.deny_warnings, Some(true));
2313    }
2314
2315    #[test]
2316    fn deny_warnings_and_codes_coexist() {
2317        let (config, _) = parse_str(
2318            r#"
2319            [lints]
2320            deny-warnings = true
2321            E063 = "allow"
2322            "#,
2323        )
2324        .unwrap();
2325        assert_eq!(config.deny_warnings, Some(true));
2326        assert_eq!(config.lints.get("E063"), Some(&LintLevel::Allow));
2327    }
2328
2329    #[test]
2330    fn absent_lints_table_is_empty_config() {
2331        let (config, _) = parse_str("[project]\ndialect = \"brink\"\n").unwrap();
2332        assert!(config.lints.is_empty());
2333        assert_eq!(config.deny_warnings, None);
2334    }
2335
2336    #[test]
2337    fn invalid_lint_level_value_is_an_error() {
2338        let err = parse_str("[lints]\nE063 = \"sideways\"\n").unwrap_err();
2339        assert!(matches!(err, ConfigError::InvalidValue { .. }));
2340    }
2341
2342    #[test]
2343    fn wrong_type_deny_warnings_is_an_error() {
2344        let err = parse_str("[lints]\ndeny-warnings = \"yes\"\n").unwrap_err();
2345        assert!(matches!(err, ConfigError::WrongType { .. }));
2346    }
2347
2348    #[test]
2349    fn wrong_type_lint_level_is_an_error() {
2350        let err = parse_str("[lints]\nE063 = 1\n").unwrap_err();
2351        assert!(matches!(err, ConfigError::WrongType { .. }));
2352    }
2353
2354    #[test]
2355    fn non_table_lints_is_an_error() {
2356        let err = parse_str("lints = 1\n").unwrap_err();
2357        assert!(matches!(err, ConfigError::NotATable { .. }));
2358    }
2359
2360    // ── [fix] (issue #3419) ──────────────────────────────────────────────
2361
2362    #[test]
2363    fn parses_per_code_fix_policies() {
2364        let (config, warnings) = parse_str(
2365            r#"
2366            [fix]
2367            E033 = "auto"
2368            E014 = "off"
2369            E022 = "ask"
2370            "#,
2371        )
2372        .unwrap();
2373        assert_eq!(config.fix.get("E033"), Some(&FixPolicy::Auto));
2374        assert_eq!(config.fix.get("E014"), Some(&FixPolicy::Off));
2375        assert_eq!(config.fix.get("E022"), Some(&FixPolicy::Ask));
2376        assert!(warnings.is_empty());
2377    }
2378
2379    #[test]
2380    fn absent_fix_table_is_empty_config() {
2381        let (config, _) = parse_str("[project]\ndialect = \"brink\"\n").unwrap();
2382        assert!(config.fix.is_empty());
2383    }
2384
2385    #[test]
2386    fn invalid_fix_policy_value_is_an_error_not_a_panic() {
2387        let err = parse_str("[fix]\nE033 = \"sideways\"\n").unwrap_err();
2388        assert!(matches!(err, ConfigError::InvalidValue { .. }));
2389    }
2390
2391    #[test]
2392    fn wrong_type_fix_policy_is_an_error_not_a_panic() {
2393        let err = parse_str("[fix]\nE033 = 1\n").unwrap_err();
2394        assert!(matches!(err, ConfigError::WrongType { .. }));
2395    }
2396
2397    #[test]
2398    fn non_table_fix_is_an_error_not_a_panic() {
2399        let err = parse_str("fix = 1\n").unwrap_err();
2400        assert!(matches!(err, ConfigError::NotATable { .. }));
2401    }
2402
2403    /// An unknown-to-the-compiler code in `[fix]` (this crate doesn't
2404    /// validate against the real `DiagnosticCode` set, #1234) must still
2405    /// parse cleanly — never a panic — the same as an unknown `[lints]` code.
2406    #[test]
2407    fn unrecognized_fix_code_parses_fine_here() {
2408        let (config, warnings) = parse_str("[fix]\nE9999 = \"auto\"\n").unwrap();
2409        assert_eq!(config.fix.get("E9999"), Some(&FixPolicy::Auto));
2410        assert!(warnings.is_empty());
2411    }
2412
2413    // ── ProjectConfig::effective_fix_policy — ceiling truth table ────────
2414
2415    #[test]
2416    fn effective_fix_policy_defaults_to_ask_when_unset() {
2417        let config = ProjectConfig::default();
2418        assert_eq!(config.effective_fix_policy("E033", None), FixPolicy::Ask);
2419    }
2420
2421    #[test]
2422    fn effective_fix_policy_with_no_ceiling_is_the_project_entry() {
2423        let (config, _) = parse_str("[fix]\nE033 = \"auto\"\n").unwrap();
2424        assert_eq!(config.effective_fix_policy("E033", None), FixPolicy::Auto);
2425    }
2426
2427    /// The full 3x3 (plus "no ceiling") truth table: the ceiling only ever
2428    /// lowers the effective policy, never raises it past the project entry.
2429    #[test]
2430    fn effective_fix_policy_ceiling_truth_table() {
2431        let cases: &[(FixPolicy, Option<FixPolicy>, FixPolicy)] = &[
2432            // project entry, app ceiling, expected effective policy
2433            (FixPolicy::Auto, None, FixPolicy::Auto),
2434            (FixPolicy::Auto, Some(FixPolicy::Auto), FixPolicy::Auto),
2435            (FixPolicy::Auto, Some(FixPolicy::Ask), FixPolicy::Ask),
2436            (FixPolicy::Auto, Some(FixPolicy::Off), FixPolicy::Off),
2437            (FixPolicy::Ask, None, FixPolicy::Ask),
2438            (FixPolicy::Ask, Some(FixPolicy::Auto), FixPolicy::Ask),
2439            (FixPolicy::Ask, Some(FixPolicy::Ask), FixPolicy::Ask),
2440            (FixPolicy::Ask, Some(FixPolicy::Off), FixPolicy::Off),
2441            (FixPolicy::Off, None, FixPolicy::Off),
2442            (FixPolicy::Off, Some(FixPolicy::Auto), FixPolicy::Off),
2443            (FixPolicy::Off, Some(FixPolicy::Ask), FixPolicy::Off),
2444            (FixPolicy::Off, Some(FixPolicy::Off), FixPolicy::Off),
2445        ];
2446        for (project_entry, ceiling, expected) in cases.iter().copied() {
2447            let mut config = ProjectConfig::default();
2448            config.fix.insert("E033".to_owned(), project_entry);
2449            let effective = config.effective_fix_policy("E033", ceiling);
2450            assert_eq!(
2451                effective, expected,
2452                "project={project_entry:?} ceiling={ceiling:?}: expected {expected:?}, got \
2453                 {effective:?}"
2454            );
2455        }
2456    }
2457
2458    #[test]
2459    fn effective_fix_policy_ceiling_never_raises_past_off() {
2460        // A project that turned a fixer off entirely must stay off even
2461        // under the most permissive app ceiling — the ceiling can only
2462        // narrow, an absent project entry (or an explicit "off") is not
2463        // something a ceiling can widen back out.
2464        let (config, _) = parse_str("[fix]\nE014 = \"off\"\n").unwrap();
2465        assert_eq!(
2466            config.effective_fix_policy("E014", Some(FixPolicy::Auto)),
2467            FixPolicy::Off
2468        );
2469    }
2470
2471    /// Round-trip through the settings write path (`edit::ConfigDocument`,
2472    /// the same generic `set_string` the studio's `[lints]` UI already uses)
2473    /// — write, re-parse, and confirm `effective_fix_policy` sees it.
2474    #[test]
2475    fn fix_policy_round_trips_through_the_edit_write_path() {
2476        let mut doc = crate::edit::ConfigDocument::parse("[project]\nentry = \"main.ink\"\n")
2477            .expect("valid toml");
2478        doc.set_string("fix", "E033", "auto").expect("edit");
2479        let text = doc.to_toml_string();
2480
2481        let (config, warnings) = parse_str(&text).expect("round-tripped text still parses");
2482        assert!(warnings.is_empty());
2483        assert_eq!(config.fix.get("E033"), Some(&FixPolicy::Auto));
2484        assert_eq!(config.effective_fix_policy("E033", None), FixPolicy::Auto);
2485        // The write path is a targeted edit, not a whole-file rewrite.
2486        assert!(text.contains("entry = \"main.ink\""));
2487    }
2488
2489    // ── discovery ─────────────────────────────────────────────────────
2490
2491    fn unique_tmp_dir(tag: &str) -> PathBuf {
2492        let mut dir = std::env::temp_dir();
2493        dir.push(format!(
2494            "brink-project-config-test-{tag}-{}-{:?}",
2495            std::process::id(),
2496            std::time::SystemTime::now()
2497                .duration_since(std::time::UNIX_EPOCH)
2498                .unwrap_or_default()
2499                .as_nanos()
2500        ));
2501        dir
2502    }
2503
2504    #[test]
2505    fn find_config_walks_up_from_start_dir() {
2506        let root = unique_tmp_dir("walk-up");
2507        let nested = root.join("a").join("b");
2508        std::fs::create_dir_all(&nested).unwrap();
2509        std::fs::write(
2510            root.join(CONFIG_FILE_NAME),
2511            "[project]\ndialect = \"brink\"\n",
2512        )
2513        .unwrap();
2514
2515        let found = find_config(&nested).expect("should find brink.toml in an ancestor");
2516        assert_eq!(found, root.join(CONFIG_FILE_NAME));
2517
2518        std::fs::remove_dir_all(&root).unwrap();
2519    }
2520
2521    #[test]
2522    fn find_config_returns_none_when_absent() {
2523        let root = unique_tmp_dir("absent");
2524        std::fs::create_dir_all(&root).unwrap();
2525        assert_eq!(find_config(&root), None);
2526        std::fs::remove_dir_all(&root).unwrap();
2527    }
2528
2529    // ── workspace/git boundary (#1425) ──────────────────────────────────
2530
2531    /// The walk must not climb past a directory containing a `.git`
2532    /// subdirectory — an unrelated `brink.toml` sitting further up (outside
2533    /// the repository) must never be picked up.
2534    #[test]
2535    fn find_config_stops_at_git_dir_boundary() {
2536        let root = unique_tmp_dir("git-boundary-dir");
2537        let repo = root.join("repo");
2538        let nested = repo.join("a").join("b");
2539        std::fs::create_dir_all(&nested).unwrap();
2540        std::fs::create_dir_all(repo.join(".git")).unwrap();
2541        // Stray config *above* the repository root — must never be found.
2542        std::fs::write(
2543            root.join(CONFIG_FILE_NAME),
2544            "[project]\ndialect = \"brink\"\n",
2545        )
2546        .unwrap();
2547
2548        assert_eq!(
2549            find_config(&nested),
2550            None,
2551            "must not climb past the .git-marked repository root to a stray ancestor config"
2552        );
2553
2554        std::fs::remove_dir_all(&root).unwrap();
2555    }
2556
2557    /// The boundary check also fires when `.git` is a *file* rather than a
2558    /// directory — the shape a linked git worktree uses (a `gitdir:` pointer
2559    /// file, exactly how this repository's own `.claude/worktrees/*` are
2560    /// laid out), not just an ordinary clone's `.git/` directory.
2561    #[test]
2562    fn find_config_stops_at_git_file_boundary_worktree_shape() {
2563        let root = unique_tmp_dir("git-boundary-file");
2564        let repo = root.join("repo");
2565        let nested = repo.join("a").join("b");
2566        std::fs::create_dir_all(&nested).unwrap();
2567        std::fs::write(repo.join(".git"), "gitdir: /elsewhere/.git/worktrees/x\n").unwrap();
2568        std::fs::write(
2569            root.join(CONFIG_FILE_NAME),
2570            "[project]\ndialect = \"brink\"\n",
2571        )
2572        .unwrap();
2573
2574        assert_eq!(
2575            find_config(&nested),
2576            None,
2577            "a `.git` worktree-pointer *file* must bound the walk exactly like a `.git` dir"
2578        );
2579
2580        std::fs::remove_dir_all(&root).unwrap();
2581    }
2582
2583    /// The boundary directory itself (the one holding `.git`) is still
2584    /// checked for `brink.toml` before the walk refuses to climb further —
2585    /// bounding the walk must not also blind it to a config at the boundary.
2586    #[test]
2587    fn find_config_still_finds_config_at_the_git_boundary_dir_itself() {
2588        let root = unique_tmp_dir("git-boundary-config-at-root");
2589        let repo = root.join("repo");
2590        let nested = repo.join("a").join("b");
2591        std::fs::create_dir_all(&nested).unwrap();
2592        std::fs::create_dir_all(repo.join(".git")).unwrap();
2593        std::fs::write(
2594            repo.join(CONFIG_FILE_NAME),
2595            "[project]\ndialect = \"brink\"\n",
2596        )
2597        .unwrap();
2598
2599        let found = find_config(&nested).expect("brink.toml at the repo root must still be found");
2600        assert_eq!(found, repo.join(CONFIG_FILE_NAME));
2601
2602        std::fs::remove_dir_all(&root).unwrap();
2603    }
2604
2605    /// A project with no `.git` anywhere above it (no VCS at all) is
2606    /// unaffected by the bound as long as the config is within
2607    /// [`MAX_ANCESTOR_DEPTH`] — a shallow nesting (well inside the cap)
2608    /// behaves exactly as before #1425/#1435.
2609    #[test]
2610    fn find_config_without_any_git_boundary_still_finds_config_within_depth_cap() {
2611        let root = unique_tmp_dir("no-git-anywhere");
2612        let nested = root.join("a").join("b").join("c");
2613        std::fs::create_dir_all(&nested).unwrap();
2614        std::fs::write(
2615            root.join(CONFIG_FILE_NAME),
2616            "[project]\ndialect = \"brink\"\n",
2617        )
2618        .unwrap();
2619
2620        let found =
2621            find_config(&nested).expect("should still find brink.toml with no .git anywhere");
2622        assert_eq!(found, root.join(CONFIG_FILE_NAME));
2623
2624        std::fs::remove_dir_all(&root).unwrap();
2625    }
2626
2627    // ── ancestor depth cap, VCS-less trees (#1435) ──────────────────────
2628
2629    /// Builds `root/d0/d1/.../d{depth-1}`, creating every intermediate
2630    /// directory, and returns the deepest one.
2631    fn nested_chain(root: &Path, depth: usize) -> PathBuf {
2632        let mut dir = root.to_path_buf();
2633        for i in 0..depth {
2634            dir = dir.join(format!("d{i}"));
2635        }
2636        std::fs::create_dir_all(&dir).unwrap();
2637        dir
2638    }
2639
2640    /// The defect #1435 exists to fix: a VCS-less tree (no `.git` anywhere)
2641    /// nested deeper than [`MAX_ANCESTOR_DEPTH`] must not have its
2642    /// `brink.toml` discovered — before this fix, `find_config`'s
2643    /// `Path::parent`-only walk had no stop condition at all here and would
2644    /// have found it regardless of depth.
2645    #[test]
2646    fn find_config_bounds_vcs_less_walk_at_max_ancestor_depth() {
2647        let root = unique_tmp_dir("vcs-less-too-deep");
2648        let deepest = nested_chain(&root, MAX_ANCESTOR_DEPTH + 10);
2649        std::fs::write(
2650            root.join(CONFIG_FILE_NAME),
2651            "[project]\ndialect = \"brink\"\n",
2652        )
2653        .unwrap();
2654
2655        assert_eq!(
2656            find_config(&deepest),
2657            None,
2658            "a VCS-less walk must not climb past MAX_ANCESTOR_DEPTH ancestors, even with no \
2659             .git boundary to stop it otherwise"
2660        );
2661
2662        std::fs::remove_dir_all(&root).unwrap();
2663    }
2664
2665    /// A VCS-less tree nested exactly at the cap (not beyond it) still finds
2666    /// its `brink.toml` — the cap must not be off-by-one in the stricter
2667    /// direction.
2668    #[test]
2669    fn find_config_finds_config_exactly_at_max_ancestor_depth() {
2670        let root = unique_tmp_dir("vcs-less-at-cap");
2671        let deepest = nested_chain(&root, MAX_ANCESTOR_DEPTH);
2672        std::fs::write(
2673            root.join(CONFIG_FILE_NAME),
2674            "[project]\ndialect = \"brink\"\n",
2675        )
2676        .unwrap();
2677
2678        let found = find_config(&deepest)
2679            .expect("a brink.toml exactly MAX_ANCESTOR_DEPTH ancestors up must still be found");
2680        assert_eq!(found, root.join(CONFIG_FILE_NAME));
2681
2682        std::fs::remove_dir_all(&root).unwrap();
2683    }
2684
2685    // ── silent-drop warnings (#1435) ─────────────────────────────────────
2686
2687    /// A `brink.toml` sitting above the workspace/git boundary is not just
2688    /// silently ignored — [`find_config_with_warnings`] reports it via a
2689    /// [`ConfigWarning`] naming both the skipped file and the boundary.
2690    #[test]
2691    fn find_config_with_warnings_reports_config_skipped_above_git_boundary() {
2692        let root = unique_tmp_dir("warn-git-boundary");
2693        let repo = root.join("repo");
2694        let nested = repo.join("a").join("b");
2695        std::fs::create_dir_all(&nested).unwrap();
2696        std::fs::create_dir_all(repo.join(".git")).unwrap();
2697        let stray = root.join(CONFIG_FILE_NAME);
2698        std::fs::write(&stray, "[project]\ndialect = \"brink\"\n").unwrap();
2699
2700        let (found, warnings) = find_config_with_warnings(&nested);
2701        assert_eq!(found, None, "the stray config must still never be returned");
2702        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
2703        assert!(
2704            warnings[0].0.contains(&stray.display().to_string()),
2705            "warning must name the skipped file, got: {}",
2706            warnings[0]
2707        );
2708
2709        std::fs::remove_dir_all(&root).unwrap();
2710    }
2711
2712    /// The VCS-less analog: a `brink.toml` sitting beyond
2713    /// [`MAX_ANCESTOR_DEPTH`] in a tree with no `.git` anywhere is reported
2714    /// the same way.
2715    #[test]
2716    fn find_config_with_warnings_reports_config_skipped_beyond_depth_cap() {
2717        let root = unique_tmp_dir("warn-depth-cap");
2718        let deepest = nested_chain(&root, MAX_ANCESTOR_DEPTH + 10);
2719        let stray = root.join(CONFIG_FILE_NAME);
2720        std::fs::write(&stray, "[project]\ndialect = \"brink\"\n").unwrap();
2721
2722        let (found, warnings) = find_config_with_warnings(&deepest);
2723        assert_eq!(found, None, "the stray config must still never be returned");
2724        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
2725        assert!(
2726            warnings[0].0.contains(&stray.display().to_string()),
2727            "warning must name the skipped file, got: {}",
2728            warnings[0]
2729        );
2730
2731        std::fs::remove_dir_all(&root).unwrap();
2732    }
2733
2734    /// No warning when there is genuinely nothing above either — a bound
2735    /// firing is not itself warning-worthy, only a bound that actually
2736    /// skipped a real config.
2737    #[test]
2738    fn find_config_with_warnings_is_silent_when_nothing_skipped() {
2739        let root = unique_tmp_dir("warn-nothing-to-skip");
2740        let deepest = nested_chain(&root, MAX_ANCESTOR_DEPTH + 10);
2741        // No brink.toml anywhere in this tree at all.
2742
2743        let (found, warnings) = find_config_with_warnings(&deepest);
2744        assert_eq!(found, None);
2745        assert!(warnings.is_empty(), "got: {warnings:?}");
2746
2747        std::fs::remove_dir_all(&root).unwrap();
2748    }
2749
2750    /// `find_config` (the discarding wrapper) must behave identically to
2751    /// `find_config_with_warnings(...).0` for a stray config skipped at the
2752    /// git boundary — the shared `find_config_inner(..., want_warnings:
2753    /// false)` path skips the second probe entirely (review finding on
2754    /// #1435: the probe cost was paid and thrown away), but the result must
2755    /// still be `None`, never the stray path.
2756    #[test]
2757    fn find_config_skips_the_warning_probe_but_still_returns_none_at_git_boundary() {
2758        let root = unique_tmp_dir("no-warn-probe-git-boundary");
2759        let repo = root.join("repo");
2760        let nested = repo.join("a").join("b");
2761        std::fs::create_dir_all(&nested).unwrap();
2762        std::fs::create_dir_all(repo.join(".git")).unwrap();
2763        std::fs::write(
2764            root.join(CONFIG_FILE_NAME),
2765            "[project]\ndialect = \"brink\"\n",
2766        )
2767        .unwrap();
2768
2769        assert_eq!(find_config(&nested), None);
2770
2771        std::fs::remove_dir_all(&root).unwrap();
2772    }
2773
2774    /// The depth-cap analog of the above.
2775    #[test]
2776    fn find_config_skips_the_warning_probe_but_still_returns_none_beyond_depth_cap() {
2777        let root = unique_tmp_dir("no-warn-probe-depth-cap");
2778        let deepest = nested_chain(&root, MAX_ANCESTOR_DEPTH + 10);
2779        std::fs::write(
2780            root.join(CONFIG_FILE_NAME),
2781            "[project]\ndialect = \"brink\"\n",
2782        )
2783        .unwrap();
2784
2785        assert_eq!(find_config(&deepest), None);
2786
2787        std::fs::remove_dir_all(&root).unwrap();
2788    }
2789
2790    #[test]
2791    fn discover_from_entry_starts_at_entry_parent() {
2792        let root = unique_tmp_dir("entry-parent");
2793        std::fs::create_dir_all(&root).unwrap();
2794        std::fs::write(
2795            root.join(CONFIG_FILE_NAME),
2796            "[project]\ntypes = \"strict\"\n",
2797        )
2798        .unwrap();
2799        let entry = root.join("story.ink");
2800        std::fs::write(&entry, "content").unwrap();
2801
2802        let found = discover_from_entry(&entry).expect("should find brink.toml beside entry");
2803        assert_eq!(found, root.join(CONFIG_FILE_NAME));
2804
2805        std::fs::remove_dir_all(&root).unwrap();
2806    }
2807
2808    #[test]
2809    fn load_from_entry_none_when_no_config() {
2810        let root = unique_tmp_dir("load-none");
2811        std::fs::create_dir_all(&root).unwrap();
2812        let entry = root.join("story.ink");
2813        std::fs::write(&entry, "content").unwrap();
2814
2815        let (loaded, warnings) = load_from_entry(&entry).unwrap();
2816        assert!(loaded.is_none());
2817        assert!(warnings.is_empty(), "got: {warnings:?}");
2818
2819        std::fs::remove_dir_all(&root).unwrap();
2820    }
2821
2822    /// [`load_from_entry`]'s discovery-warning half of #1435: a config
2823    /// skipped by the bounded walk is surfaced through this function's own
2824    /// return value, not swallowed by its `Ok(None)` "nothing found" case.
2825    #[test]
2826    fn load_from_entry_surfaces_discovery_warning_when_config_skipped() {
2827        let root = unique_tmp_dir("load-skipped-warning");
2828        let repo = root.join("repo");
2829        std::fs::create_dir_all(&repo).unwrap();
2830        std::fs::create_dir_all(repo.join(".git")).unwrap();
2831        let stray = root.join(CONFIG_FILE_NAME);
2832        std::fs::write(&stray, "[project]\ndialect = \"brink\"\n").unwrap();
2833        let entry = repo.join("story.ink");
2834        std::fs::write(&entry, "content").unwrap();
2835
2836        let (loaded, warnings) = load_from_entry(&entry).unwrap();
2837        assert!(
2838            loaded.is_none(),
2839            "the out-of-repo config must never be loaded"
2840        );
2841        assert_eq!(warnings.len(), 1, "got: {warnings:?}");
2842        assert!(
2843            warnings[0].0.contains(&stray.display().to_string()),
2844            "warning must name the skipped file, got: {}",
2845            warnings[0]
2846        );
2847
2848        std::fs::remove_dir_all(&root).unwrap();
2849    }
2850
2851    #[test]
2852    fn find_config_in_tree_walks_up_from_start_key() {
2853        use brink_source_tree::InMemory;
2854        use std::collections::BTreeMap;
2855
2856        let mut files = BTreeMap::new();
2857        files.insert(
2858            CONFIG_FILE_NAME.to_owned(),
2859            "[project]\ndialect = \"brink\"\n".to_owned(),
2860        );
2861        files.insert("a/b/story.ink".to_owned(), "content".to_owned());
2862        let tree = InMemory::new(files);
2863
2864        let found = find_config_in_tree(&tree, "a/b")
2865            .expect("list succeeds")
2866            .expect("should find brink.toml in an ancestor key");
2867        assert_eq!(found, CONFIG_FILE_NAME);
2868    }
2869
2870    #[test]
2871    fn find_config_in_tree_returns_none_when_absent() {
2872        use brink_source_tree::InMemory;
2873        use std::collections::BTreeMap;
2874
2875        let mut files = BTreeMap::new();
2876        files.insert("a/b/story.ink".to_owned(), "content".to_owned());
2877        let tree = InMemory::new(files);
2878
2879        let found = find_config_in_tree(&tree, "a/b").expect("list succeeds");
2880        assert_eq!(found, None);
2881    }
2882
2883    /// A `SourceTree` whose `list` errors out — proves `find_config_in_tree`
2884    /// resolves purely via direct `read` probes of the O(depth) ancestor
2885    /// candidates and never falls back to enumerating the tree (issue
2886    /// #1370): if it ever called `list`, that error would propagate and the
2887    /// test's `.expect(..)` calls below would fail. Seeded with a huge,
2888    /// irrelevant key set (standing in for `target/`/`.git`/`node_modules`
2889    /// clutter a real tree walk would have to traverse) that a `list`-based
2890    /// implementation would have to comb through but a `read`-probing one
2891    /// never touches.
2892    struct ErrorsOnList {
2893        files: BTreeMap<String, String>,
2894    }
2895
2896    impl SourceTree for ErrorsOnList {
2897        fn list(&self) -> io::Result<Vec<String>> {
2898            Err(io::Error::other(
2899                "find_config_in_tree must not enumerate the tree via SourceTree::list (issue #1370)",
2900            ))
2901        }
2902
2903        fn read(&self, key: &str) -> io::Result<String> {
2904            self.files
2905                .get(key)
2906                .cloned()
2907                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("{key}: not found")))
2908        }
2909    }
2910
2911    #[test]
2912    fn find_config_in_tree_probes_directly_without_enumerating_the_tree() {
2913        let mut files = BTreeMap::new();
2914        files.insert(
2915            CONFIG_FILE_NAME.to_owned(),
2916            "[project]\ndialect = \"brink\"\n".to_owned(),
2917        );
2918        for i in 0..10_000 {
2919            files.insert(format!("target/build-artifact-{i}.o"), "ignored".to_owned());
2920        }
2921        let tree = ErrorsOnList { files };
2922
2923        let found = find_config_in_tree(&tree, "a/b/c/d")
2924            .expect("direct probing succeeds without ever calling list")
2925            .expect("should find brink.toml at the tree root");
2926        assert_eq!(found, CONFIG_FILE_NAME);
2927    }
2928
2929    #[test]
2930    fn find_config_in_tree_probes_directly_returns_none_without_enumerating_the_tree() {
2931        let mut files = BTreeMap::new();
2932        for i in 0..10_000 {
2933            files.insert(format!(".git/objects/{i}"), "ignored".to_owned());
2934        }
2935        let tree = ErrorsOnList { files };
2936
2937        let found = find_config_in_tree(&tree, "a/b/c/d")
2938            .expect("direct probing succeeds without ever calling list");
2939        assert_eq!(found, None);
2940    }
2941
2942    /// A `SourceTree` whose `brink.toml` candidate exists but errors on
2943    /// `read` with a non-`NotFound` kind (e.g. invalid encoding, permission
2944    /// denied) — must be reported as *found* (`Some(candidate)`), not
2945    /// propagated as an `Err` from `find_config_in_tree` itself. Regression
2946    /// guard for the #1370/#1369 interaction: `find_config_in_tree`'s probe
2947    /// read used to propagate this error directly, which — since it carries
2948    /// no path — surfaced to callers as a bare `LoadError::Io` instead of
2949    /// the path-attributed `LoadError::ConfigRead` the caller's own `read`
2950    /// of the returned key is meant to produce.
2951    struct ErrorsOnRead;
2952
2953    impl SourceTree for ErrorsOnRead {
2954        fn list(&self) -> io::Result<Vec<String>> {
2955            Ok(vec![CONFIG_FILE_NAME.to_owned()])
2956        }
2957
2958        fn read(&self, key: &str) -> io::Result<String> {
2959            if key == CONFIG_FILE_NAME {
2960                Err(io::Error::new(
2961                    io::ErrorKind::InvalidData,
2962                    "not valid utf-8",
2963                ))
2964            } else {
2965                Err(io::Error::new(
2966                    io::ErrorKind::NotFound,
2967                    format!("{key}: not found"),
2968                ))
2969            }
2970        }
2971    }
2972
2973    #[test]
2974    fn find_config_in_tree_reports_found_when_the_candidate_read_errors_non_not_found() {
2975        let found = find_config_in_tree(&ErrorsOnRead, "a/b")
2976            .expect("a non-NotFound read error is not propagated")
2977            .expect("the unreadable brink.toml is still reported as found");
2978        assert_eq!(found, CONFIG_FILE_NAME);
2979    }
2980
2981    #[test]
2982    fn discover_from_entry_in_tree_starts_at_entry_parent_key() {
2983        use brink_source_tree::InMemory;
2984        use std::collections::BTreeMap;
2985
2986        let mut files = BTreeMap::new();
2987        files.insert(
2988            CONFIG_FILE_NAME.to_owned(),
2989            "[project]\ntypes = \"strict\"\n".to_owned(),
2990        );
2991        files.insert("story.ink".to_owned(), "content".to_owned());
2992        let tree = InMemory::new(files);
2993
2994        let found = discover_from_entry_in_tree(&tree, "story.ink")
2995            .expect("list succeeds")
2996            .expect("should find brink.toml beside entry key");
2997        assert_eq!(found, CONFIG_FILE_NAME);
2998    }
2999
3000    #[test]
3001    fn load_from_entry_reads_and_parses() {
3002        let root = unique_tmp_dir("load-some");
3003        std::fs::create_dir_all(&root).unwrap();
3004        std::fs::write(
3005            root.join(CONFIG_FILE_NAME),
3006            "[project]\ndialect = \"brink\"\ntypes = \"strict\"\n",
3007        )
3008        .unwrap();
3009        let entry = root.join("story.ink");
3010        std::fs::write(&entry, "content").unwrap();
3011
3012        let (loaded, discovery_warnings) = load_from_entry(&entry).unwrap();
3013        let loaded = loaded.expect("config found");
3014        assert_eq!(loaded.path, root.join(CONFIG_FILE_NAME));
3015        assert_eq!(loaded.config.dialect, Some(Dialect::Brink));
3016        assert_eq!(loaded.config.types, Some(TypePolicy::Strict));
3017        assert!(loaded.warnings.is_empty());
3018        assert!(discovery_warnings.is_empty(), "got: {discovery_warnings:?}");
3019
3020        std::fs::remove_dir_all(&root).unwrap();
3021    }
3022
3023    // ── [dialogue] (RULED 2026-08-30, project-declared dialogue dialect) ──
3024
3025    #[test]
3026    fn dialogue_is_none_when_the_file_declares_nothing() {
3027        let (config, _) = parse_str("[project]\nentry = \"story.ink\"\n").expect("valid");
3028        assert_eq!(
3029            config.dialogue, None,
3030            "no [dialogue] = no dialect, never a preset"
3031        );
3032        assert!(!config.is_empty() || config.dialogue.is_none());
3033    }
3034
3035    #[test]
3036    fn dialogue_table_parses_preset_overlay_elements_and_run_rule() {
3037        let toml = r#"
3038[dialogue]
3039preset = "at-cue"
3040run-ends-at = ["character", "action"]
3041
3042[[dialogue.elements]]
3043kind = "action"
3044nature = "narrative"
3045prefix = ">"
3046
3047[[dialogue.elements]]
3048kind = "aside"
3049pattern = "^\\[(?<content>[^\\]]*)\\]$"
3050template = "[${content}]"
3051content-role = "content"
3052glued = false
3053"#;
3054        let (config, warnings) = parse_str(toml).expect("valid");
3055        assert!(warnings.is_empty(), "{warnings:?}");
3056        let d = config.dialogue.expect("declared");
3057        assert_eq!(d.preset.as_deref(), Some("at-cue"));
3058        assert_eq!(d.file, None);
3059        assert_eq!(d.run_ends_at, vec!["character", "action"]);
3060        assert_eq!(d.elements.len(), 2);
3061        assert_eq!(d.elements[0].kind, "action");
3062        assert_eq!(d.elements[0].prefix.as_deref(), Some(">"));
3063        assert_eq!(d.elements[0].nature.as_deref(), Some("narrative"));
3064        assert_eq!(
3065            d.elements[1].pattern.as_deref(),
3066            Some(r"^\[(?<content>[^\]]*)\]$")
3067        );
3068        assert_eq!(d.elements[1].glued, Some(false));
3069    }
3070
3071    #[test]
3072    fn dialogue_string_form_is_the_file_escape_hatch() {
3073        let (config, _) = parse_str("dialogue = \"dialect.json\"\n").expect("valid");
3074        let d = config.dialogue.expect("declared");
3075        assert_eq!(d.file.as_deref(), Some("dialect.json"));
3076        assert_eq!(d.preset, None);
3077        assert!(d.elements.is_empty());
3078    }
3079
3080    #[test]
3081    fn dialogue_unknown_keys_warn_and_wrong_types_error() {
3082        let (_, warnings) =
3083            parse_str("[dialogue]\npreset = \"at-cue\"\ncolour = \"x\"\n").expect("valid");
3084        assert!(
3085            warnings.iter().any(|w| w.0.contains("dialogue.colour")),
3086            "{warnings:?}"
3087        );
3088        let err = parse_str("[dialogue]\npreset = 3\n").expect_err("wrong type");
3089        assert!(matches!(err, ConfigError::WrongType { .. }), "{err:?}");
3090        let err = parse_str("dialogue = 3\n").expect_err("wrong type at the top level");
3091        assert!(matches!(err, ConfigError::WrongType { .. }), "{err:?}");
3092        let err =
3093            parse_str("[[dialogue.elements]]\nprefix = \">\"\n").expect_err("kind is required");
3094        assert!(matches!(err, ConfigError::WrongType { .. }), "{err:?}");
3095    }
3096}