Skip to main content

brink_project_config/
lib.rs

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