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}