Skip to main content

bevy_brink/
source_loader.rs

1//! Asset loader for `.ink` source files (dev mode).
2//!
3//! Compiles ink source at asset-load time, walking the transitive
4//! `INCLUDE` graph asynchronously through Bevy's `AssetReader`. Hot-reload
5//! "just works" because every INCLUDE'd file is fetched via
6//! [`bevy_asset::LoadContext::read_asset_bytes`], which automatically
7//! registers the file as a dependency — when any of them change, Bevy
8//! re-runs the loader.
9//!
10//! Available only when the `dev` feature is enabled. Release builds
11//! should pre-compile to `.inkb` and avoid carrying the compiler.
12//!
13//! ## Async/sync seam (#1360, the `brink-environment` producer consumer)
14//!
15//! [`brink_environment::Project::load`] (and the `compile` it feeds) is
16//! synchronous — it reads through a [`brink_source_tree::SourceTree`], which
17//! has no `async fn`. Bevy's `AssetReader` is async, and on web targets it
18//! has to be — there's no blocking filesystem. We bridge the two by walking
19//! the INCLUDE graph ourselves first (using [`brink_syntax::extract_includes`]
20//! to discover INCLUDEs from cached source), pre-fetching every reachable
21//! file via Bevy's async reader into an in-memory map, then handing that map
22//! to the sync producer as a [`brink_source_tree::InMemory`] tree. The BFS
23//! itself stays here (the mount's async reality); `brink.toml` discovery,
24//! parsing, and override precedence (#1005/#1320) now live entirely in
25//! [`brink_environment::Project::load`] — not re-implemented in this loader.
26
27use std::collections::BTreeMap;
28
29use bevy_app::App;
30use bevy_asset::{AssetLoader, Assets, Handle, LoadContext, io::Reader};
31use bevy_ecs::resource::Resource;
32use bevy_reflect::TypePath;
33use brink_environment::{OptionOverrides, Project};
34use brink_project_config::ProjectConfig;
35use brink_source_tree::InMemory;
36
37use crate::asset::{
38    BrinkStoryAsset, LineTablesAsset, ProgramAsset, emit_story_assets, fresh_context,
39};
40
41/// The `with_config` override [`BrinkAssetsPlugin::build`](crate::BrinkAssetsPlugin)
42/// resolved [`InkLoader::override_config`](InkLoader) from, mirrored into a
43/// resource so [`compile_story_inline`] — a freestanding function with only
44/// `&mut App`, no `AssetLoader` instance to read a field off of — can see the
45/// same value (#1380).
46///
47/// Inserted once, unconditionally, whenever `BrinkAssetsPlugin` builds, from
48/// the exact `self.config` that also seeds `InkLoader` — so the two entry
49/// points read the identical override and can never diverge on it. Both
50/// entry points build their `OptionOverrides` through the one shared
51/// [`overrides_from_config`] mapping fed into `Project::load`'s existing
52/// seam, the same "reuse the resolution path, don't add a second one" fix
53/// applied here that wired `InkLoader` itself.
54/// `None` (the default, or no `BrinkAssetsPlugin` in the app at all) means
55/// [`overrides_from_config`] falls through to [`OptionOverrides::default()`],
56/// byte-identical to pre-#1380 behavior for a host that never calls
57/// `with_config`.
58#[derive(Resource, Debug, Clone, Default)]
59pub(crate) struct BrinkOverrideConfig(pub(crate) Option<ProjectConfig>);
60
61/// Build the [`OptionOverrides`] [`Project::load`] expects from a
62/// `with_config` override — the exact `ProjectConfig` -> `OptionOverrides`
63/// mapping both [`InkLoader::load`] and [`compile_story_inline`] need.
64/// Extracted so the two entry points share one mapping rather than each
65/// hand-rolling their own copy that could silently drift apart (#1380,
66/// following the resolution shape #1553/#1559/#1417 used for the
67/// IDE/LSP/wasm option-propagation gaps).
68///
69/// `config: None` (no override at all) yields
70/// [`OptionOverrides::default()`] — every field unset, so `Project::load`
71/// falls through entirely to the discovered `brink.toml` (or the built-in
72/// default).
73fn overrides_from_config(config: Option<&ProjectConfig>) -> OptionOverrides {
74    OptionOverrides {
75        dialect: config.and_then(|c| c.dialect),
76        types: config.and_then(|c| c.types),
77        lints: config.map(|c| c.lints.clone()).unwrap_or_default(),
78        deny_warnings: config.and_then(|c| c.deny_warnings),
79        // D6 (`docs/debugger-spec.md` §1.2): no engine-runtime path opts a
80        // `bevy-brink` compile into debug info today — that's D9's studio
81        // wiring, not this loader's.
82        debug_info: false,
83    }
84}
85
86/// Asset loader for `.ink` (source) files.
87///
88/// Reads the entry source, walks the `INCLUDE` graph asynchronously
89/// through Bevy's `AssetReader`, lands the drained sources in a
90/// [`brink_source_tree::InMemory`] tree, then goes through the
91/// [`brink_environment`] producer: [`Project::load`] resolves `brink.toml`
92/// and override precedence and freezes an `Environment`, and
93/// [`brink_environment::compile`] compiles it. Links the result via
94/// [`brink_runtime::link`] and emits labeled subassets (`#program`,
95/// `#line_tables`) just like [`InkbLoader`](crate::InkbLoader).
96///
97/// ## `brink.toml` discovery (#1029, #1360, #1406, #1439)
98///
99/// A `brink.toml` beside (or above) the entry asset supplies the
100/// [`ProjectConfig`] (`dialect`/`types`) that gates T1b brink-extension
101/// syntax — the same file the CLI discovers by walking the real
102/// filesystem (`brink-project-config::load_from_entry`). Bevy's
103/// `AssetReader` may be virtual or packed, so [`load`](AssetLoader::load)
104/// probes for it over the async reader itself: `brink.toml` beside the
105/// entry, then each ancestor directory in turn (nearest first), via
106/// [`LoadContext::read_asset_bytes`] — bounded at the asset source root
107/// (never above it) and naturally finite (the entry path has finitely many
108/// `/`-separated ancestors). As of #1439, [`probe_brink_toml`] stops at the
109/// first candidate it finds — the nearest one, since the walk is
110/// nearest-first — so only that candidate is ever read or registered as a
111/// load dependency; a farther, shadowed `brink.toml` is never fetched, so
112/// editing one in dev mode no longer triggers a hot-reload. The candidate's
113/// text is landed at its own key in the drained source map; parsing it and
114/// applying `CLI/API > file > default` precedence is [`Project::load`]'s job
115/// (`brink_project_config::discover_from_entry_in_tree` over the same map,
116/// walking the same bounded ancestor chain again over whatever the map
117/// contains) — this loader only supplies candidate bytes, it does not
118/// re-implement the resolution policy. Because the probe already stops at
119/// the nearest hit, `Project::load`'s walk finds at most one candidate in
120/// practice, but it is still the one place that applies the precedence
121/// rule; the probe is, deliberately, back to being a second "which
122/// candidate is nearest" decider (the duplication #1406 removed and #1439
123/// reintroduced on purpose, for the perf win — see [`probe_brink_toml`]'s
124/// doc). The two decisions agree by construction today because both walk
125/// [`ancestor_dirs`]-equivalent nearest-first order over the same bounded
126/// chain, but they are two independent implementations: a future change to
127/// `brink-project-config`'s bound/precedence shape would need to be mirrored
128/// here to stay honored on this path. A miss at every level leaves
129/// `AnalysisOptions` at its default — byte-identical to pre-#1029 behavior.
130///
131/// [`override_config`](Self::override_config), set via
132/// [`BrinkPlugin::with_config`](crate::BrinkPlugin::with_config) /
133/// [`BrinkAssetsPlugin::with_config`](crate::BrinkAssetsPlugin::with_config),
134/// is the programmatic escape hatch: when set, its fields win over
135/// whatever `brink.toml` supplies — passed through as
136/// [`OptionOverrides`], the same `explicit call always wins over the file`
137/// precedence [`Project::load`] applies for every mount (CLI included).
138#[derive(Default, TypePath)]
139pub struct InkLoader {
140    /// Out-of-band [`ProjectConfig`] override (#1029). `None` (the
141    /// default) means "the discovered `brink.toml` asset (if any) wins";
142    /// `Some` fields win over the asset's, unset fields still fall
143    /// through to whatever the asset (or the built-in default) supplies.
144    pub override_config: Option<ProjectConfig>,
145}
146
147/// Errors that can occur loading an `.ink` source file.
148#[derive(Debug, thiserror::Error)]
149pub enum InkLoaderError {
150    #[error("I/O error: {0}")]
151    Io(#[from] std::io::Error),
152    #[error("read asset: {0}")]
153    ReadAsset(#[from] bevy_asset::ReadAssetBytesError),
154    #[error("source not valid UTF-8: {0}")]
155    InvalidUtf8(#[from] std::string::FromUtf8Error),
156    #[error("entry path missing or non-UTF-8")]
157    BadEntryPath,
158    /// The `brink-environment` producer failed to resolve the drained
159    /// sources into an `Environment` — `INCLUDE` discovery (missing/circular
160    /// include) or a malformed discovered `brink.toml` (bad TOML syntax, or
161    /// a recognized key with a value outside its enum; unknown keys are
162    /// warnings, never this). See `brink_environment::LoadError`.
163    #[error("load environment: {0}")]
164    Load(#[from] brink_environment::LoadError),
165    #[error("compile: {0}")]
166    Compile(#[from] brink_compiler::CompileError),
167    #[error("link error: {0}")]
168    Link(#[from] brink_runtime::RuntimeError),
169}
170
171/// Errors from [`compile_story_inline`].
172#[derive(Debug, thiserror::Error)]
173pub enum CompileStoryInlineError {
174    /// The `brink-environment` producer failed to resolve the single-file
175    /// tree into an `Environment` — most commonly an unresolvable `INCLUDE`
176    /// (the tree has only `name`, so any `INCLUDE`'d path always misses),
177    /// but also reachable via a malformed discovered `brink.toml` or a
178    /// circular `INCLUDE`. See `brink_environment::LoadError`.
179    ///
180    /// The `#[error]` message carries the same authoring guidance the old
181    /// (pre-#1372) read closure used to surface directly, since this is
182    /// often the only diagnostic a caller sees — all three demo call sites
183    /// (`demos/compound/src/ink_{doors,cameras,alarm}.rs`) `.expect()` on
184    /// this function's result.
185    #[error(
186        "load environment: {0} (compile_story_inline compiles a single in-memory source; use InkLoader/AssetServer for multi-file stories)"
187    )]
188    Load(#[from] brink_environment::LoadError),
189    #[error("compile: {0}")]
190    Compile(#[from] brink_compiler::CompileError),
191    #[error("link error: {0}")]
192    Link(#[from] brink_runtime::RuntimeError),
193}
194
195/// Compile an in-memory ink source string straight into story assets,
196/// inserting them into `app`'s asset collections and returning the
197/// resulting `Handle<BrinkStoryAsset>` (G3, issue #1060).
198///
199/// Collapses the four-step dance tests/tools otherwise hand-roll —
200/// `brink_compiler::compile` → `brink_runtime::link` →
201/// `FlowInstance::new_at_root` (only to obtain the initial context) →
202/// hand-inserting `ProgramAsset` + `LineTablesAsset` + `BrinkStoryAsset`
203/// into three `Assets<T>` resources — into one call, wrapping the same
204/// [`emit_story_assets`]-adjacent logic [`InkLoader`] uses at asset-load
205/// time, but synchronously and without the async `AssetServer`.
206///
207/// ## Goes through the same producer as [`InkLoader`] (#1372)
208///
209/// `name`/`source` are landed as the sole entry in a
210/// [`brink_source_tree::InMemory`] tree, then handed to
211/// [`brink_environment::Project::load`] → [`brink_environment::compile`] —
212/// the exact same two-call seam [`InkLoader::load`] uses. This closes the
213/// `brink.toml`-discovery half of the divergence #1360 left open: before,
214/// this function called `brink_compiler::compile` directly, a second compile
215/// path with its own (in this case: no) `brink.toml`/precedence resolution,
216/// separate from `InkLoader`'s. Both entry points now run the identical
217/// `resolve_options` codepath over their respective trees, so a future
218/// change to *that* resolution logic can't silently diverge between them
219/// again.
220///
221/// ## Picks up `with_config` too (#1380)
222///
223/// [`BrinkAssetsPlugin::build`](crate::BrinkAssetsPlugin) mirrors whatever
224/// `with_config` override it resolved for [`InkLoader::override_config`]
225/// into a [`BrinkOverrideConfig`] resource. This function reads that
226/// resource back out of `app`'s `World` (absent — no `BrinkAssetsPlugin` in
227/// the app at all — is treated the same as `None`) and runs it through the
228/// same [`overrides_from_config`] mapping `InkLoader::load` uses, so an app
229/// built with `BrinkPlugin::with_config(dialect = Brink)` now compiles
230/// inline sources under `Brink` too, matching its `InkLoader`-loaded
231/// assets — closing the divergence #1372 narrowed down to "one missing
232/// override wire".
233///
234/// **Call this *after* `app.add_plugins(BrinkPlugin::<M>::with_config(...))`
235/// / `BrinkAssetsPlugin::with_config(...)`, not before.** `BrinkOverrideConfig`
236/// only exists in `app`'s `World` once `BrinkAssetsPlugin::build` has run,
237/// and Bevy runs `Plugin::build` at `add_plugins` time, not at struct
238/// construction. Calling `compile_story_inline` *before* that `add_plugins`
239/// call finds no `BrinkOverrideConfig` resource yet — the same "absent"
240/// case as no plugin at all — and silently compiles under
241/// `OptionOverrides::default()` with no diagnostic, even though a
242/// `with_config` override is sitting right there waiting to be installed.
243/// This is the exact kind of silent divergence #1380 set out to kill,
244/// relocated into a call-ordering footgun rather than eliminated; there is
245/// no runtime check for it (a freestanding `fn(&mut App, ...)` can't tell
246/// "no override was ever configured" apart from "the override exists but
247/// its plugin hasn't built yet"). See
248/// `compile_story_inline_before_plugin_add_silently_falls_back_to_default`
249/// for the regression pinning this fallback.
250///
251/// `name` is the compiler's synthetic entry file name (also its `INCLUDE`
252/// resolution root). Because the tree has exactly one key (`name`), `INCLUDE`
253/// can never resolve here: a directive is still discovered by the same BFS
254/// `InkLoader` uses, but always misses the single-key tree and surfaces as
255/// [`CompileStoryInlineError::Load`] rather than silently doing nothing; a
256/// story spanning multiple files needs [`InkLoader`]/`AssetServer::load`
257/// instead, which walks the graph asynchronously. A `brink.toml` is
258/// discovered the same way — never, for the same single-key reason — so
259/// `AnalysisOptions` always resolves to its default here.
260///
261/// `app` must already have `AssetPlugin` (or equivalent) installed, so
262/// `Assets<ProgramAsset>`, `Assets<LineTablesAsset>`, and
263/// `Assets<BrinkStoryAsset>` exist as resources — the same precondition
264/// `BrinkPlugin` itself has.
265pub fn compile_story_inline(
266    app: &mut App,
267    name: &str,
268    source: &str,
269) -> Result<Handle<BrinkStoryAsset>, CompileStoryInlineError> {
270    let mut sources = BTreeMap::new();
271    sources.insert(name.to_string(), source.to_string());
272    let tree = InMemory::new(sources);
273    let override_config = app
274        .world()
275        .get_resource::<BrinkOverrideConfig>()
276        .and_then(|r| r.0.as_ref());
277    let overrides = overrides_from_config(override_config);
278    let env = Project::load(&tree, name, &overrides)?;
279    let output = brink_environment::compile(&env)?;
280    let (program, tables) = brink_runtime::link(&output.data)?;
281    let initial_context = fresh_context(&program);
282
283    let world = app.world_mut();
284    let program_handle = world
285        .resource_mut::<Assets<ProgramAsset>>()
286        .add(ProgramAsset {
287            program,
288            initial_context,
289            effect_rows: output.data.effect_rows,
290        });
291    let line_tables_handle = world
292        .resource_mut::<Assets<LineTablesAsset>>()
293        .add(LineTablesAsset { tables });
294    Ok(world
295        .resource_mut::<Assets<BrinkStoryAsset>>()
296        .add(BrinkStoryAsset {
297            program: program_handle,
298            line_tables: line_tables_handle,
299        }))
300}
301
302/// Resolve an `INCLUDE` path relative to the including file's directory.
303///
304/// String-based (uses `/`) to match `brink-db`'s WASM-safe resolver and
305/// avoid platform separator issues. The joined path is normalized so `.`/`..`
306/// segments collapse to a clean key (matching `brink-db::resolve_include_path`
307/// system-wide; see docs/decision-log.md).
308fn resolve_include_path(from_file: &str, include_path: &str) -> String {
309    let joined = match from_file.rfind('/') {
310        Some(i) => format!("{}/{include_path}", &from_file[..i]),
311        None => include_path.to_string(),
312    };
313    let absolute = joined.starts_with('/');
314    let mut out: Vec<&str> = Vec::new();
315    for seg in joined.split('/') {
316        match seg {
317            "" | "." => {}
318            ".." if matches!(out.last(), Some(&s) if s != "..") => {
319                out.pop();
320            }
321            s => out.push(s),
322        }
323    }
324    let joined = out.join("/");
325    if absolute {
326        format!("/{joined}")
327    } else {
328        joined
329    }
330}
331
332/// Ancestor directories of `entry_path`'s containing directory, nearest
333/// first, ending at `""` (the asset source root). String-based (matches
334/// [`resolve_include_path`]'s `/`-only convention). E.g.
335/// `"stories/ch1/intro.ink"` yields `["stories/ch1", "stories", ""]`;
336/// `"intro.ink"` (no directory) yields `[""]`.
337///
338/// Finite by construction — each step strictly shortens the remaining
339/// prefix at a `/` boundary, so the walk-up this drives ([`probe_brink_toml`])
340/// is naturally bounded by the entry path's depth (guard against
341/// unbounded growth).
342fn ancestor_dirs(entry_path: &str) -> Vec<String> {
343    let mut dirs = Vec::new();
344    let mut current = match entry_path.rfind('/') {
345        Some(i) => &entry_path[..i],
346        None => "",
347    };
348    loop {
349        dirs.push(current.to_string());
350        if current.is_empty() {
351            break;
352        }
353        current = match current.rfind('/') {
354            Some(i) => &current[..i],
355            None => "",
356        };
357    }
358    dirs
359}
360
361/// Bounded ancestor walk-up (#1029): probe for `brink.toml` beside the
362/// entry asset, then each ancestor directory in turn (nearest first), up
363/// to the asset source root — mirroring the CLI's `brink.toml` walk-up
364/// (`brink_project_config::find_config`), but over the async `AssetReader`
365/// since a bevy source tree may be virtual or packed rather than a real
366/// filesystem.
367///
368/// Returns at most one candidate — the nearest `brink.toml` found, or
369/// `None` if no `brink.toml` exists at any ancestor level. The returned
370/// candidate is read via [`LoadContext::read_asset_bytes`], which
371/// registers it as a load dependency, so hot-reload "just works" exactly
372/// like an `INCLUDE`. Returns key + raw text, **not** parsed, and no
373/// precedence applied — this loader's job stops at supplying bytes; the
374/// caller lands it in the drained source map, and [`Project::load`]
375/// (over the resulting `SourceTree`) then parses it and applies the
376/// `CLI/API > file > default` precedence (#1360).
377///
378/// ## Single pass (#1439)
379///
380/// This probe stops at the first match found, collapsing the *async* side
381/// of the ancestor walk to a single pass. Before #1439, this probe fetched
382/// bytes for **every** candidate ancestor through the async `AssetReader`
383/// and landed all of them in the tree; `Project::load`'s own walk
384/// (`discover_from_entry_in_tree`) then re-walked the same bounded ancestor
385/// chain, synchronously, over that tree to pick the nearest one. Stopping
386/// here at the first match (guaranteed to be nearest, since
387/// [`ancestor_dirs`] returns nearest-first) means at most one candidate is
388/// ever landed, so `Project::load`'s subsequent sync walk finds it (or
389/// nothing) on its very first probe. That sync walk was already
390/// stopping at its first hit before this change (`find_config_in_tree`
391/// returns as soon as it finds a candidate), so its own probe count —
392/// the entry-to-nearest-config distance — is unchanged and still
393/// `O(depth)` when no config exists anywhere; what #1439 saves is strictly
394/// the number of async `read_asset_bytes` calls this probe makes (at most
395/// one hit instead of one per ancestor with a `brink.toml`), not the shape
396/// of either walk. Behavior is identical — only the nearest candidate was
397/// ever read as config before this change either.
398///
399/// A miss at every ancestor returns `Ok(None)` — not an error, matching
400/// `brink-project-config`'s "missing config changes nothing" contract.
401async fn probe_brink_toml(
402    load_context: &mut LoadContext<'_>,
403    entry_path: &str,
404) -> Result<Option<(String, String)>, InkLoaderError> {
405    for dir in ancestor_dirs(entry_path) {
406        let candidate = if dir.is_empty() {
407            brink_project_config::CONFIG_FILE_NAME.to_string()
408        } else {
409            format!("{dir}/{}", brink_project_config::CONFIG_FILE_NAME)
410        };
411        if let Ok(bytes) = load_context.read_asset_bytes(candidate.clone()).await {
412            match String::from_utf8(bytes) {
413                Ok(text) => {
414                    // Found the nearest candidate; return immediately (single
415                    // pass, no need to continue walking).
416                    return Ok(Some((candidate, text)));
417                }
418                // An undecodable nearest candidate fails the load — this
419                // candidate governs, and we can't read it, so this is a
420                // genuine failure (not a case for `discover_from_entry_in_tree`
421                // to potentially shadow with a farther ancestor). Farther
422                // ancestors are never consulted.
423                Err(err) => return Err(err.into()),
424            }
425        }
426    }
427    // No `brink.toml` found at any ancestor level.
428    Ok(None)
429}
430
431impl AssetLoader for InkLoader {
432    type Asset = BrinkStoryAsset;
433    type Settings = ();
434    type Error = InkLoaderError;
435
436    async fn load(
437        &self,
438        reader: &mut dyn Reader,
439        _settings: &Self::Settings,
440        load_context: &mut LoadContext<'_>,
441    ) -> Result<Self::Asset, Self::Error> {
442        // Read the entry source. AssetPath includes optional source +
443        // label; for our purposes the underlying filesystem path is what
444        // INCLUDE resolution needs.
445        let entry_path = load_context
446            .path()
447            .path()
448            .to_str()
449            .ok_or(InkLoaderError::BadEntryPath)?
450            .to_string();
451
452        let mut entry_bytes = Vec::new();
453        reader.read_to_end(&mut entry_bytes).await?;
454        let entry_source = String::from_utf8(entry_bytes)?;
455
456        // BFS the INCLUDE graph, fetching every transitive dep through
457        // Bevy's async reader (which registers each as a dependency for
458        // automatic hot-reload). `BTreeMap` (not `HashMap`): it lands
459        // directly in an `InMemory` `SourceTree` below, whose contract is a
460        // deterministic key order.
461        let mut sources: BTreeMap<String, String> = BTreeMap::new();
462        let mut queue: Vec<String> = brink_syntax::extract_includes(&entry_source)
463            .into_iter()
464            .map(|inc| resolve_include_path(&entry_path, &inc))
465            .collect();
466        sources.insert(entry_path.clone(), entry_source);
467
468        while let Some(path) = queue.pop() {
469            if sources.contains_key(&path) {
470                continue;
471            }
472            let bytes = load_context.read_asset_bytes(path.clone()).await?;
473            let source = String::from_utf8(bytes)?;
474            for inc in brink_syntax::extract_includes(&source) {
475                let resolved = resolve_include_path(&path, &inc);
476                if !sources.contains_key(&resolved) {
477                    queue.push(resolved);
478                }
479            }
480            sources.insert(path, source);
481        }
482
483        // #1029/#1360/#1439: bounded ancestor walk-up for `brink.toml`
484        // through the async AssetReader (see [`probe_brink_toml`]'s doc).
485        // Single-pass discovery (#1439): the probe stops at the first match
486        // (guaranteed nearest), so only one candidate (or none) is added to
487        // the tree — saving async `read_asset_bytes` calls for shadowed
488        // farther candidates. [`Project::load`] below still performs its own
489        // bounded sync walk over the tree to apply precedence; that walk's
490        // shape (and its O(depth) cost when nothing is found) is unchanged
491        // by this optimization.
492        if let Some((config_path, text)) = probe_brink_toml(load_context, &entry_path).await? {
493            sources.insert(config_path, text);
494        }
495
496        // The `CLI/API > file > default` precedence's explicit-override
497        // side (#1005): `override_config` — set via
498        // `BrinkPlugin::with_config` — wins over whatever `brink.toml` (just
499        // landed above) supplies. `brink.toml` itself, and applying that
500        // precedence, is entirely `Project::load`'s job. #1394's lint tier
501        // and #1380's `compile_story_inline` wiring both go through this
502        // same `overrides_from_config` mapping — no second implementation.
503        let overrides = overrides_from_config(self.override_config.as_ref());
504
505        // Land the drained map in a `SourceTree` and go through the
506        // producer: `Project::load` resolves `brink.toml` + precedence and
507        // freezes an `Environment`; `compile` is the pure function over it.
508        // Both are synchronous — the only asynchrony (the BFS above) is
509        // already behind us.
510        let tree = InMemory::new(sources);
511        let env = Project::load(&tree, &entry_path, &overrides)?;
512        let output = brink_environment::compile(&env)?;
513        let (program, tables) = brink_runtime::link(&output.data)?;
514        Ok(emit_story_assets(
515            load_context,
516            program,
517            tables,
518            output.data.effect_rows,
519        ))
520    }
521
522    fn extensions(&self) -> &[&str] {
523        &["ink"]
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    #[test]
532    fn resolves_with_directory_prefix() {
533        assert_eq!(
534            resolve_include_path("src/main.ink", "utils.ink"),
535            "src/utils.ink"
536        );
537    }
538
539    #[test]
540    fn resolves_without_directory() {
541        assert_eq!(resolve_include_path("story.ink", "other.ink"), "other.ink");
542    }
543
544    #[test]
545    fn resolves_nested_directory() {
546        assert_eq!(resolve_include_path("a/b/c.ink", "d.ink"), "a/b/d.ink");
547    }
548
549    #[test]
550    fn normalizes_parent_traversal() {
551        assert_eq!(resolve_include_path("a/b/c.ink", "../d.ink"), "a/d.ink");
552        assert_eq!(resolve_include_path("a/b/c.ink", "../../d.ink"), "d.ink");
553    }
554
555    #[test]
556    fn compile_story_inline_inserts_assets_and_returns_handle() {
557        let mut app = crate::test_support::make_test_app();
558
559        let handle = compile_story_inline(&mut app, "inline.ink", "VAR mood = 3\n-> END\n")
560            .expect("inline source compiles and links");
561
562        let world = app.world();
563        let story = world
564            .resource::<Assets<BrinkStoryAsset>>()
565            .get(&handle)
566            .expect("story asset inserted");
567        let program_asset = world
568            .resource::<Assets<ProgramAsset>>()
569            .get(&story.program)
570            .expect("program asset inserted");
571        assert_eq!(program_asset.program.global_index("mood"), Some(0));
572        assert!(
573            world
574                .resource::<Assets<LineTablesAsset>>()
575                .get(&story.line_tables)
576                .is_some(),
577            "line tables asset inserted"
578        );
579    }
580
581    #[test]
582    fn compile_story_inline_surfaces_compile_error() {
583        let mut app = crate::test_support::make_test_app();
584
585        let err = compile_story_inline(&mut app, "broken.ink", "-> nowhere_knot\n")
586            .expect_err("a divert to an undeclared knot should not compile");
587        assert!(
588            matches!(err, CompileStoryInlineError::Compile(_)),
589            "got {err:?}"
590        );
591    }
592
593    /// #1372: `compile_story_inline` now goes through the same
594    /// `Project::load` → `compile` producer seam as [`InkLoader`], instead of
595    /// calling `brink_compiler::compile` directly. Pin the precedence
596    /// consequence both entry points now share: with no `brink.toml`
597    /// reachable (here, structurally — the tree has only the single inline
598    /// source), `AnalysisOptions` resolves to its default (`StrictInk`),
599    /// which rejects the brink-extension `#@private` form — the exact same
600    /// fixture and expectation as
601    /// `config_discovery_tests::missing_brink_toml_leaves_default_dialect_unchanged`
602    /// for `InkLoader`.
603    #[test]
604    fn compile_story_inline_has_no_brink_toml_and_uses_default_dialect() {
605        let mut app = crate::test_support::make_test_app();
606
607        let err = compile_story_inline(
608            &mut app,
609            "inline.ink",
610            "#@private\nVAR secret = 0\n-> END\n",
611        )
612        .expect_err("brink-extension syntax should be rejected under the default dialect");
613        assert!(
614            matches!(err, CompileStoryInlineError::Compile(_)),
615            "got {err:?}"
616        );
617    }
618
619    /// #1372: an `INCLUDE` in the inline source is followed through the same
620    /// `Project::load` discovery BFS `InkLoader` uses, but the single-key
621    /// tree can never satisfy it — surfaces as `CompileStoryInlineError::Load`
622    /// (a `brink_environment::LoadError`, not `brink_compiler::CompileError`
623    /// as it did before this function was rerouted through the producer).
624    ///
625    /// Also pins the authoring-guidance substring carried in `Load`'s
626    /// `#[error]` message (the old read closure's hint, since #1372 turned
627    /// this into the sole diagnostic every `.expect()`-ing demo call site
628    /// sees) so it cannot silently rot away in a future edit.
629    #[test]
630    fn compile_story_inline_surfaces_load_error_for_unresolvable_include() {
631        let mut app = crate::test_support::make_test_app();
632
633        let err = compile_story_inline(&mut app, "inline.ink", "INCLUDE missing.ink\n-> END\n")
634            .expect_err("an INCLUDE can never resolve in a single-key inline tree");
635        assert!(
636            matches!(err, CompileStoryInlineError::Load(_)),
637            "got {err:?}"
638        );
639        assert!(
640            err.to_string().contains(
641                "compile_story_inline compiles a single in-memory source; use InkLoader/AssetServer for multi-file stories"
642            ),
643            "error message should carry authoring guidance, got: {err}"
644        );
645    }
646
647    // ── `with_config` reaches `compile_story_inline` too (#1380) ───────
648    //
649    // `compile_story_inline_has_no_brink_toml_and_uses_default_dialect`
650    // above pins the no-override baseline: `#@private` is rejected under
651    // the default `StrictInk` dialect. These two tests prove a
652    // `BrinkPlugin::with_config` override actually reaches this entry
653    // point too (it didn't, pre-#1380 — see the removed "one override
654    // channel is still divergent" doc note this PR replaces), each picking
655    // a fixture whose outcome only the override — not the default policy
656    // — can flip (house rule 19q).
657
658    /// `dialect = Brink`, set via `BrinkPlugin::with_config`, must reach
659    /// `compile_story_inline`'s `Project::load` call the same way it
660    /// already reaches `InkLoader::load` — proven by compiling the same
661    /// `#@private` brink-extension source the no-override baseline test
662    /// rejects.
663    #[test]
664    fn compile_story_inline_reaches_plugin_with_config_dialect_override() {
665        use brink_project_config::Dialect;
666
667        let mut app = bevy_app::App::new();
668        app.add_plugins(bevy_asset::AssetPlugin::default());
669        app.add_plugins(
670            crate::BrinkPlugin::<()>::default().with_config(ProjectConfig {
671                dialect: Some(Dialect::Brink),
672                ..ProjectConfig::default()
673            }),
674        );
675
676        compile_story_inline(
677            &mut app,
678            "inline.ink",
679            "#@private\nVAR secret = 0\n-> END\n",
680        )
681        .expect(
682            "dialect = Brink override should reach compile_story_inline and permit \
683                 brink-extension syntax the default dialect rejects",
684        );
685    }
686
687    /// The `[lints] deny-warnings` tier (#1394's addition to the same
688    /// `OptionOverrides` seam) reaches `compile_story_inline` too, not just
689    /// `dialect`/`types` — a logic line with no effect (`~` alone,
690    /// `DiagnosticCode::E014`, `Warning` by default) compiles cleanly with
691    /// no override, but a `deny_warnings = true` override relevels it to a
692    /// blocking `Error`.
693    #[test]
694    fn compile_story_inline_reaches_plugin_with_config_deny_warnings_override() {
695        let mut app = bevy_app::App::new();
696        app.add_plugins(bevy_asset::AssetPlugin::default());
697        app.add_plugins(
698            crate::BrinkPlugin::<()>::default().with_config(ProjectConfig {
699                deny_warnings: Some(true),
700                ..ProjectConfig::default()
701            }),
702        );
703
704        let err = compile_story_inline(&mut app, "inline.ink", "Hello.\n~\n-> END\n")
705            .expect_err("deny_warnings override should relevel E014 to a blocking error");
706        assert!(
707            matches!(err, CompileStoryInlineError::Compile(_)),
708            "got {err:?}"
709        );
710    }
711
712    /// Negative control for the two tests above (house rule 19q): the same
713    /// `deny_warnings`-sensitive source compiles cleanly with no
714    /// `with_config` override at all, so the override above is what
715    /// actually flips the outcome, not something else about the fixture.
716    #[test]
717    fn compile_story_inline_without_config_leaves_e014_a_warning() {
718        let mut app = crate::test_support::make_test_app();
719
720        compile_story_inline(&mut app, "inline.ink", "Hello.\n~\n-> END\n")
721            .expect("E014 is a Warning, never blocking, with no deny_warnings override");
722    }
723
724    /// An untyped function parameter, called with an argument — same fixture
725    /// (and same reasoning) as `config_discovery_tests`'
726    /// `UNTYPED_PARAM_SOURCE`: `dialect = Brink`'s own resolved-policy
727    /// default is `Strict`, which rejects it with 2 diagnostics; only an
728    /// explicit `types = Gradual` override compiles it.
729    const UNTYPED_PARAM_SOURCE: &str =
730        "=== function f(x) ===\n~ return x\n\n=== start ===\n{f(1)}\n-> END\n";
731
732    /// `types = Gradual`, set via `BrinkPlugin::with_config` alongside
733    /// `dialect = Brink`, must reach `compile_story_inline`'s `Project::load`
734    /// call the same way it already reaches `InkLoader::load`
735    /// (`plugin_with_config_types_reaches_ink_loader` in
736    /// `config_discovery_tests`) — mirrored here since #1380's fix covers
737    /// `compile_story_inline` too, not just `InkLoader` (house rule 19q: the
738    /// dialect-keyed default alone, `Strict`, still rejects
739    /// `UNTYPED_PARAM_SOURCE`, so only the `types` override can flip this
740    /// outcome).
741    #[test]
742    fn compile_story_inline_reaches_plugin_with_config_types_override() {
743        use brink_project_config::{Dialect, TypePolicy};
744
745        let mut app = bevy_app::App::new();
746        app.add_plugins(bevy_asset::AssetPlugin::default());
747        app.add_plugins(
748            crate::BrinkPlugin::<()>::default().with_config(ProjectConfig {
749                dialect: Some(Dialect::Brink),
750                types: Some(TypePolicy::Gradual),
751                ..ProjectConfig::default()
752            }),
753        );
754
755        compile_story_inline(&mut app, "inline.ink", UNTYPED_PARAM_SOURCE).expect(
756            "types = Gradual override should reach compile_story_inline and permit the \
757             untyped parameter the dialect-keyed Strict default rejects",
758        );
759    }
760
761    /// A per-code `[lints]` override (not just the blanket `deny_warnings`
762    /// knob already covered above) must reach `compile_story_inline` too —
763    /// mirrors `plugin_override_lints_wins_over_conflicting_asset` /
764    /// `plugin_with_config_dialect_reaches_ink_loader`'s `[lints]` coverage
765    /// for `InkLoader`, but through `compile_story_inline`'s
766    /// `BrinkOverrideConfig` channel.
767    #[test]
768    fn compile_story_inline_reaches_plugin_with_config_per_code_lint_override() {
769        let mut app = bevy_app::App::new();
770        app.add_plugins(bevy_asset::AssetPlugin::default());
771
772        let mut lints = std::collections::BTreeMap::new();
773        lints.insert("E014".to_owned(), brink_project_config::LintLevel::Deny);
774        app.add_plugins(
775            crate::BrinkPlugin::<()>::default().with_config(ProjectConfig {
776                lints,
777                ..ProjectConfig::default()
778            }),
779        );
780
781        let err = compile_story_inline(&mut app, "inline.ink", "Hello.\n~\n-> END\n").expect_err(
782            "a per-code `[lints] E014 = deny` override should relevel E014 \
783                to a blocking error",
784        );
785        assert!(
786            matches!(err, CompileStoryInlineError::Compile(_)),
787            "got {err:?}"
788        );
789    }
790
791    /// #1380 review finding: `compile_story_inline` reads
792    /// `BrinkOverrideConfig` out of `app.world()` at call time, but that
793    /// resource is only inserted once `BrinkAssetsPlugin::build` actually
794    /// runs — which happens at `app.add_plugins(...)` time, not at
795    /// `BrinkPlugin::with_config(...)` construction time. Calling
796    /// `compile_story_inline` *before* `add_plugins` finds no
797    /// `BrinkOverrideConfig` in the world at all — indistinguishable from
798    /// "no `BrinkAssetsPlugin` ever added" — and silently falls back to
799    /// `OptionOverrides::default()`, dropping the override with no
800    /// diagnostic. This pins that fallback so it can't drift into something
801    /// else (e.g. a panic) unnoticed; see `compile_story_inline`'s doc
802    /// comment for the full hazard.
803    #[test]
804    fn compile_story_inline_before_plugin_add_silently_falls_back_to_default() {
805        use brink_project_config::Dialect;
806
807        let mut app = bevy_app::App::new();
808        app.add_plugins(bevy_asset::AssetPlugin::default());
809
810        // Deliberately NOT added yet -- `compile_story_inline` runs first.
811        let pending_plugin = crate::BrinkPlugin::<()>::default().with_config(ProjectConfig {
812            dialect: Some(Dialect::Brink),
813            ..ProjectConfig::default()
814        });
815
816        let err = compile_story_inline(
817            &mut app,
818            "inline.ink",
819            "#@private\nVAR secret = 0\n-> END\n",
820        )
821        .expect_err(
822            "with no BrinkOverrideConfig resource yet inserted, the default StrictInk \
823             dialect must still reject brink-extension syntax -- the override cannot \
824             reach a call made before its plugin builds",
825        );
826        assert!(
827            matches!(err, CompileStoryInlineError::Compile(_)),
828            "got {err:?}"
829        );
830
831        // Adding the plugin after the fact doesn't retroactively help the
832        // already-failed call above -- but confirms the override itself is
833        // wired correctly (would reach a *subsequent* call), isolating the
834        // failure above to ordering, not a broken override.
835        app.add_plugins(pending_plugin);
836        compile_story_inline(
837            &mut app,
838            "inline.ink",
839            "#@private\nVAR secret = 0\n-> END\n",
840        )
841        .expect("once the plugin has built, the same override now reaches the same call");
842    }
843
844    // ── ancestor_dirs (#1029 bounded walk-up) ───────────────────────────
845
846    #[test]
847    fn ancestor_dirs_nested_entry_climbs_to_root() {
848        assert_eq!(
849            ancestor_dirs("stories/ch1/intro.ink"),
850            vec![
851                "stories/ch1".to_string(),
852                "stories".to_string(),
853                String::new()
854            ]
855        );
856    }
857
858    #[test]
859    fn ancestor_dirs_root_entry_is_just_root() {
860        assert_eq!(ancestor_dirs("intro.ink"), vec![String::new()]);
861    }
862
863    #[test]
864    fn ancestor_dirs_single_directory_level() {
865        assert_eq!(
866            ancestor_dirs("stories/intro.ink"),
867            vec!["stories".to_string(), String::new()]
868        );
869    }
870}
871
872/// Integration tests for #1029 (`brink.toml` discovery through the async
873/// `AssetReader`): the bounded ancestor walk-up, the plugin-override
874/// precedence, and hot-reload — all driven through a real `AssetServer`
875/// against an in-memory `AssetSource`
876/// ([`bevy_asset::io::memory::Dir`]/`MemoryAssetReader`), mirroring
877/// `bevy_asset`'s own `create_app` test pattern. In-memory (not a real
878/// temp directory) so these tests are hermetic and can freely rewrite file
879/// content for the hot-reload case without touching disk or a file
880/// watcher.
881#[cfg(test)]
882mod config_discovery_tests {
883    use std::path::Path;
884
885    use bevy_app::TaskPoolPlugin;
886    use bevy_asset::io::memory::{Dir, MemoryAssetReader};
887    use bevy_asset::io::{AssetSourceBuilder, AssetSourceId};
888    use bevy_asset::{AssetApp, AssetPlugin, AssetServer, LoadState};
889    use brink_project_config::{Dialect, TypePolicy};
890
891    use super::{InkLoader, ProjectConfig};
892    use crate::asset::{BrinkStoryAsset, LineTablesAsset, ProgramAsset};
893
894    /// A brink-extension form (`#@private`) that the default `StrictInk`
895    /// dialect rejects (E051-class dialect-gate diagnostic,
896    /// `brink-analyzer`'s `dialect_gate`) but `dialect = brink` compiles —
897    /// the reachability proof #1029 calls for: a bevy story with
898    /// `dialect = brink` in a sibling `brink.toml` compiles a
899    /// brink-extension form that fails under the default.
900    const BRINK_ONLY_SOURCE: &str = "#@private\nVAR secret = 0\n-> END\n";
901
902    /// A logic line with no effect (`~` alone) — `DiagnosticCode::E014`,
903    /// `Warning` by default (mirrors `brink_environment`'s own `E014_SOURCE`
904    /// fixture, `crates/internal/brink-environment/src/lib.rs`). Compiles
905    /// cleanly under the default policy; only relevels to a blocking `Error`
906    /// (and so a `Failed` load) once `[lints]` denies it or sets
907    /// `deny-warnings` (issue #1394).
908    const E014_SOURCE: &str = "Hello.\n~\n-> END\n";
909
910    /// An untyped function parameter (`f(x)`, no `: type` annotation) called
911    /// with an argument: `dialect = Brink`'s own resolved-policy default is
912    /// `Strict` (`AnalysisOptions::type_policy`), which rejects an untyped
913    /// param with 2 diagnostics — only an explicit `types = gradual`
914    /// override compiles it. Verified against this crate's own `brink`
915    /// CLI: `brink compile fnreturn.ink --dialect brink` exits 1 with 2
916    /// diagnostics; `--dialect brink --types gradual` exits 0.
917    ///
918    /// Replaces a prior `STRUCT NPC = ...; VAR npc = NPC#{...}` fixture that
919    /// was meant to hit `E075` (struct literals as declaration defaults) but
920    /// didn't — it compiled cleanly under `types = strict` too (verified the
921    /// same way), so the `types` override test built on it was vacuous
922    /// (house rule 19q; #1426 w52 review).
923    const UNTYPED_PARAM_SOURCE: &str =
924        "=== function f(x) ===\n~ return x\n\n=== start ===\n{f(1)}\n-> END\n";
925
926    /// Build an `App` with an in-memory `AssetSource` and just enough
927    /// registered (asset types + the dev-mode `InkLoader`) to drive a real
928    /// `AssetServer::load` through [`InkLoader::load`] end to end, without
929    /// pulling in all of `BrinkPlugin`'s systems.
930    fn make_memory_asset_app() -> (bevy_app::App, Dir) {
931        let mut app = bevy_app::App::new();
932        let dir = Dir::default();
933        let dir_clone = dir.clone();
934        app.register_asset_source(
935            AssetSourceId::Default,
936            AssetSourceBuilder::new(move || {
937                Box::new(MemoryAssetReader {
938                    root: dir_clone.clone(),
939                })
940            }),
941        )
942        .add_plugins((
943            TaskPoolPlugin::default(),
944            AssetPlugin {
945                watch_for_changes_override: Some(false),
946                use_asset_processor_override: Some(false),
947                ..Default::default()
948            },
949        ));
950        app.init_asset::<BrinkStoryAsset>();
951        app.init_asset::<ProgramAsset>();
952        app.init_asset::<LineTablesAsset>();
953        app.register_asset_loader(InkLoader::default());
954        (app, dir)
955    }
956
957    /// Same as [`make_memory_asset_app`], except the `.ink` loader is wired
958    /// through the *real* [`crate::plugin::BrinkAssetsPlugin::with_config`]
959    /// plugin-build path instead of a hand-constructed `InkLoader {
960    /// override_config }`. Every other case in `config_discovery_tests`
961    /// constructs `InkLoader` directly, which only proves
962    /// `InkLoader::override_config`'s own behavior — not that
963    /// `BrinkPlugin::with_config` / `BrinkAssetsPlugin::with_config`
964    /// actually thread an override into it (`with_config` ->
965    /// `with_config_option` -> `BrinkAssetsPlugin::build` ->
966    /// `InkLoader { override_config }`). Use this builder for any test
967    /// whose claim is specifically about `with_config`'s reachability.
968    fn make_memory_asset_app_with_config(config: ProjectConfig) -> (bevy_app::App, Dir) {
969        let mut app = bevy_app::App::new();
970        let dir = Dir::default();
971        let dir_clone = dir.clone();
972        app.register_asset_source(
973            AssetSourceId::Default,
974            AssetSourceBuilder::new(move || {
975                Box::new(MemoryAssetReader {
976                    root: dir_clone.clone(),
977                })
978            }),
979        )
980        .add_plugins((
981            TaskPoolPlugin::default(),
982            AssetPlugin {
983                watch_for_changes_override: Some(false),
984                use_asset_processor_override: Some(false),
985                ..Default::default()
986            },
987            crate::plugin::BrinkAssetsPlugin::default().with_config(config),
988        ));
989        (app, dir)
990    }
991
992    /// Same as [`make_memory_asset_app_with_config`], but wired through
993    /// [`crate::BrinkPlugin::with_config`] instead of
994    /// [`crate::plugin::BrinkAssetsPlugin::with_config`] directly — proves
995    /// the two-hop delegation (`BrinkPlugin::build` ->
996    /// `BrinkAssetsPlugin::with_config_option` -> `InkLoader {
997    /// override_config }`) a host adding the marker-parameterized plugin
998    /// actually goes through, not just `BrinkAssetsPlugin` in isolation.
999    fn make_memory_asset_app_via_brink_plugin_with_config(
1000        config: ProjectConfig,
1001    ) -> (bevy_app::App, Dir) {
1002        let mut app = bevy_app::App::new();
1003        let dir = Dir::default();
1004        let dir_clone = dir.clone();
1005        app.register_asset_source(
1006            AssetSourceId::Default,
1007            AssetSourceBuilder::new(move || {
1008                Box::new(MemoryAssetReader {
1009                    root: dir_clone.clone(),
1010                })
1011            }),
1012        )
1013        .add_plugins((
1014            TaskPoolPlugin::default(),
1015            AssetPlugin {
1016                watch_for_changes_override: Some(false),
1017                use_asset_processor_override: Some(false),
1018                ..Default::default()
1019            },
1020            crate::BrinkPlugin::<()>::default().with_config(config),
1021        ));
1022        (app, dir)
1023    }
1024
1025    /// Poll `app.update()` until `predicate` returns `Some`, bounded so a
1026    /// stuck load fails the test instead of hanging the suite (guard
1027    /// against unbounded growth).
1028    fn run_until<T>(
1029        app: &mut bevy_app::App,
1030        mut predicate: impl FnMut(&mut bevy_app::App) -> Option<T>,
1031    ) -> Option<T> {
1032        for _ in 0..2000 {
1033            app.update();
1034            let hit = predicate(app);
1035            if hit.is_some() {
1036                return hit;
1037            }
1038        }
1039        None
1040    }
1041
1042    /// Poll until the handle reaches `Loaded`. Also stops early on `Failed`
1043    /// (so a genuine failure reports immediately instead of spinning out
1044    /// the whole bound) but never treats a *stale* `Loaded` as the answer
1045    /// to `wait_for_failed` below — each waiter polls for its own specific
1046    /// target state, which matters for the hot-reload test: right after
1047    /// `AssetServer::reload`, the handle briefly still reads its *previous*
1048    /// terminal state before the reload's spawned task lands.
1049    fn wait_for_loaded(app: &mut bevy_app::App, handle: &bevy_asset::Handle<BrinkStoryAsset>) {
1050        let state = run_until(app, |app| {
1051            match app.world().resource::<AssetServer>().load_state(handle) {
1052                LoadState::NotLoaded | LoadState::Loading => None,
1053                terminal => Some(terminal),
1054            }
1055        })
1056        .expect("asset load did not reach a terminal state within the bounded poll loop");
1057        assert!(
1058            matches!(state, LoadState::Loaded),
1059            "expected the load to succeed; got {state:?}"
1060        );
1061    }
1062
1063    /// Poll until the handle reaches `Failed`, ignoring any `Loaded` seen
1064    /// along the way (see [`wait_for_loaded`]'s doc for why that matters
1065    /// post-reload).
1066    fn wait_for_failed(app: &mut bevy_app::App, handle: &bevy_asset::Handle<BrinkStoryAsset>) {
1067        run_until(app, |app| {
1068            matches!(
1069                app.world().resource::<AssetServer>().load_state(handle),
1070                LoadState::Failed(_)
1071            )
1072            .then_some(())
1073        })
1074        .expect("asset load did not reach Failed within the bounded poll loop");
1075    }
1076
1077    #[test]
1078    fn missing_brink_toml_leaves_default_dialect_unchanged() {
1079        let (mut app, dir) = make_memory_asset_app();
1080        dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1081        // No brink.toml anywhere -- current (pre-#1029) behavior: default
1082        // AnalysisOptions (StrictInk), which rejects `#@private`.
1083
1084        let handle = app
1085            .world()
1086            .resource::<AssetServer>()
1087            .load::<BrinkStoryAsset>("intro.ink");
1088        wait_for_failed(&mut app, &handle);
1089    }
1090
1091    #[test]
1092    fn sibling_brink_toml_sets_brink_dialect() {
1093        let (mut app, dir) = make_memory_asset_app();
1094        dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1095        dir.insert_asset_text(Path::new("brink.toml"), "[project]\ndialect = \"brink\"\n");
1096
1097        let handle = app
1098            .world()
1099            .resource::<AssetServer>()
1100            .load::<BrinkStoryAsset>("intro.ink");
1101        wait_for_loaded(&mut app, &handle);
1102    }
1103
1104    #[test]
1105    fn ancestor_brink_toml_found_via_bounded_walkup() {
1106        let (mut app, dir) = make_memory_asset_app();
1107        dir.insert_asset_text(Path::new("stories/ch1/intro.ink"), BRINK_ONLY_SOURCE);
1108        // brink.toml sits two levels above the entry -- proves the walk-up
1109        // doesn't stop at the immediate sibling directory.
1110        dir.insert_asset_text(Path::new("brink.toml"), "[project]\ndialect = \"brink\"\n");
1111
1112        let handle = app
1113            .world()
1114            .resource::<AssetServer>()
1115            .load::<BrinkStoryAsset>("stories/ch1/intro.ink");
1116        wait_for_loaded(&mut app, &handle);
1117    }
1118
1119    /// #1406/#1439 regression: with a `brink.toml` at *two* ancestor levels
1120    /// (conflicting settings), the nearest one must still govern.
1121    /// [`super::probe_brink_toml`] stops at the first (nearest) hit as of
1122    /// #1439, so the farther, root-level `brink.toml` is never even fetched
1123    /// — it does not land in the drained tree at all, and `Project::load`'s
1124    /// own walk sees only the nearer candidate. The farther file sets
1125    /// `dialect = "strict-ink"`, which alone would reject `BRINK_ONLY_SOURCE`
1126    /// (`#@private`) — only the nearer `stories/ch1/brink.toml`'s
1127    /// `dialect = "brink"` makes it compile, so a `Loaded` outcome here
1128    /// pins that the nearer candidate is the one actually used, both by the
1129    /// probe (which never reads the farther file) and by `Project::load`'s
1130    /// precedence resolution.
1131    ///
1132    /// `single_pass_discovery_finds_nearest_config_for_nested_entry` below
1133    /// covers a distinct case: here, the nearest config sits *beside* the
1134    /// entry's own directory (`stories/ch1/`); there, it sits one level
1135    /// *above* the entry's own directory, pinning that the walk-up still
1136    /// finds a nearest config that isn't a same-directory sibling.
1137    #[test]
1138    fn nearest_ancestor_brink_toml_shadows_a_farther_conflicting_one() {
1139        let (mut app, dir) = make_memory_asset_app();
1140        dir.insert_asset_text(Path::new("stories/ch1/intro.ink"), BRINK_ONLY_SOURCE);
1141        dir.insert_asset_text(
1142            Path::new("stories/ch1/brink.toml"),
1143            "[project]\ndialect = \"brink\"\n",
1144        );
1145        dir.insert_asset_text(
1146            Path::new("brink.toml"),
1147            "[project]\ndialect = \"strict-ink\"\n",
1148        );
1149
1150        let handle = app
1151            .world()
1152            .resource::<AssetServer>()
1153            .load::<BrinkStoryAsset>("stories/ch1/intro.ink");
1154        wait_for_loaded(&mut app, &handle);
1155    }
1156
1157    /// #1439 regression: verify the discovered config path for a nested
1158    /// project is the nearest one, not a farther sibling. The single-pass
1159    /// optimization (_this_ PR) stops at the first match found, which is
1160    /// guaranteed to be nearest since [`ancestor_dirs`] returns nearest
1161    /// first; this test pins that discovered path stays correct after the
1162    /// optimization.
1163    #[test]
1164    fn single_pass_discovery_finds_nearest_config_for_nested_entry() {
1165        let (mut app, dir) = make_memory_asset_app();
1166        // Entry deep in a nested tree.
1167        dir.insert_asset_text(
1168            Path::new("project/nested/deep/story.ink"),
1169            BRINK_ONLY_SOURCE,
1170        );
1171        // Nearer config (should win).
1172        dir.insert_asset_text(
1173            Path::new("project/nested/brink.toml"),
1174            "[project]\ndialect = \"brink\"\n",
1175        );
1176        // Farther config (should be ignored).
1177        dir.insert_asset_text(
1178            Path::new("project/brink.toml"),
1179            "[project]\ndialect = \"strict-ink\"\n",
1180        );
1181
1182        let handle = app
1183            .world()
1184            .resource::<AssetServer>()
1185            .load::<BrinkStoryAsset>("project/nested/deep/story.ink");
1186        wait_for_loaded(&mut app, &handle);
1187    }
1188
1189    /// w-review regression, retained post-#1439: an *undecodable* farther
1190    /// ancestor `brink.toml` must not fail a load whose nearest, decodable
1191    /// candidate is the one that actually governs. As of #1439,
1192    /// `probe_brink_toml` stops at the first (nearest) candidate it finds,
1193    /// so the farther, non-UTF-8 root-level `brink.toml` here is never even
1194    /// fetched — the load succeeds simply because that file is never read,
1195    /// not because of a decode-error-skipping branch (that guard was
1196    /// removed by #1439; see `probe_brink_toml`'s doc). This test is kept
1197    /// as a regression guard for the outcome — a farther, shadowed,
1198    /// undecodable candidate must never be able to fail a load — even
1199    /// though the mechanism that guarantees it changed.
1200    #[test]
1201    fn undecodable_farther_ancestor_brink_toml_does_not_fail_the_load() {
1202        let (mut app, dir) = make_memory_asset_app();
1203        dir.insert_asset_text(Path::new("stories/ch1/intro.ink"), BRINK_ONLY_SOURCE);
1204        dir.insert_asset_text(
1205            Path::new("stories/ch1/brink.toml"),
1206            "[project]\ndialect = \"brink\"\n",
1207        );
1208        // Invalid UTF-8 (a lone continuation byte) -- must never be decoded
1209        // as config text, since the nearer candidate above already governs.
1210        dir.insert_asset(Path::new("brink.toml"), vec![0x80_u8]);
1211
1212        let handle = app
1213            .world()
1214            .resource::<AssetServer>()
1215            .load::<BrinkStoryAsset>("stories/ch1/intro.ink");
1216        wait_for_loaded(&mut app, &handle);
1217    }
1218
1219    /// w-review regression: the mirror of
1220    /// `undecodable_farther_ancestor_brink_toml_does_not_fail_the_load`
1221    /// above — an *undecodable nearest* `brink.toml` must fail the load,
1222    /// since it is the candidate that governs and `probe_brink_toml`
1223    /// propagates its `String::from_utf8` error rather than silently
1224    /// treating it as "no config" or falling through to a farther ancestor.
1225    #[test]
1226    fn undecodable_nearest_brink_toml_fails_the_load() {
1227        let (mut app, dir) = make_memory_asset_app();
1228        dir.insert_asset_text(Path::new("stories/ch1/intro.ink"), BRINK_ONLY_SOURCE);
1229        // Invalid UTF-8 (a lone continuation byte) at the *nearest* level --
1230        // this candidate governs, so an undecodable read here must fail the
1231        // load rather than being skipped.
1232        dir.insert_asset(Path::new("stories/ch1/brink.toml"), vec![0x80_u8]);
1233
1234        let handle = app
1235            .world()
1236            .resource::<AssetServer>()
1237            .load::<BrinkStoryAsset>("stories/ch1/intro.ink");
1238        wait_for_failed(&mut app, &handle);
1239    }
1240
1241    #[test]
1242    fn plugin_override_wins_over_conflicting_asset() {
1243        let (mut app, dir) = make_memory_asset_app();
1244        dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1245        // The asset explicitly sets strict-ink, which alone would reject
1246        // `#@private`.
1247        dir.insert_asset_text(
1248            Path::new("brink.toml"),
1249            "[project]\ndialect = \"strict-ink\"\n",
1250        );
1251
1252        // Re-register the loader with a programmatic override that
1253        // disagrees with the discovered asset -- override must win
1254        // (#1029: override > asset > default).
1255        app.register_asset_loader(InkLoader {
1256            override_config: Some(ProjectConfig {
1257                dialect: Some(Dialect::Brink),
1258                types: None,
1259                ..ProjectConfig::default()
1260            }),
1261        });
1262
1263        let handle = app
1264            .world()
1265            .resource::<AssetServer>()
1266            .load::<BrinkStoryAsset>("intro.ink");
1267        wait_for_loaded(&mut app, &handle);
1268    }
1269
1270    // ── `[lints]` re-level, observable through the bevy load path (#1394) ──
1271    //
1272    // A served `brink.toml`'s `[lints]`/`deny-warnings` table was already
1273    // reaching `Project::load` before this issue — `resolve_options` there
1274    // applies the discovered file's config unconditionally, regardless of
1275    // `OptionOverrides` (see `sibling_brink_toml_lints_deny_relevels_...`
1276    // below, both regression guards for that pre-existing file tier, not
1277    // new coverage). What `InkLoader` actually dropped was narrower: a
1278    // `BrinkPlugin::with_config` override's `lints`/`deny_warnings` never
1279    // reached `OptionOverrides`, so it could never win over (or supply, with
1280    // no `brink.toml` present) the file. `plugin_override_lints_wins_over_conflicting_asset`
1281    // and `plugin_override_deny_warnings_relevels_warning_to_failed_load`
1282    // below are the tests that actually exercise the fixed seam. These
1283    // mirror `brink_environment`'s own `[lints]` tests (`E014_SOURCE`, a
1284    // Warning by default that only blocks compilation once denied), but
1285    // drive them through the real `AssetServer`/`InkLoader` seam this crate
1286    // owns.
1287
1288    #[test]
1289    fn e014_source_loads_by_default_with_no_lints_table() {
1290        // Baseline: E014 is a Warning, never blocking, with no `[lints]`
1291        // anywhere -- the contrast case for the two `Failed` tests below.
1292        let (mut app, dir) = make_memory_asset_app();
1293        dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1294
1295        let handle = app
1296            .world()
1297            .resource::<AssetServer>()
1298            .load::<BrinkStoryAsset>("intro.ink");
1299        wait_for_loaded(&mut app, &handle);
1300    }
1301
1302    #[test]
1303    fn sibling_brink_toml_lints_deny_relevels_warning_to_failed_load() {
1304        // A served brink.toml's `[lints] E014 = "deny"` re-levels the
1305        // Warning to a blocking Error -- observable as a `Failed` load
1306        // through the bevy path, exactly as it already blocks the CLI.
1307        let (mut app, dir) = make_memory_asset_app();
1308        dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1309        dir.insert_asset_text(Path::new("brink.toml"), "[lints]\nE014 = \"deny\"\n");
1310
1311        let handle = app
1312            .world()
1313            .resource::<AssetServer>()
1314            .load::<BrinkStoryAsset>("intro.ink");
1315        wait_for_failed(&mut app, &handle);
1316    }
1317
1318    #[test]
1319    fn sibling_brink_toml_deny_warnings_relevels_warning_to_failed_load() {
1320        // Same re-level, via the `deny-warnings = true` blanket knob rather
1321        // than a per-code entry.
1322        let (mut app, dir) = make_memory_asset_app();
1323        dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1324        dir.insert_asset_text(Path::new("brink.toml"), "[lints]\ndeny-warnings = true\n");
1325
1326        let handle = app
1327            .world()
1328            .resource::<AssetServer>()
1329            .load::<BrinkStoryAsset>("intro.ink");
1330        wait_for_failed(&mut app, &handle);
1331    }
1332
1333    #[test]
1334    fn plugin_override_lints_wins_over_conflicting_asset() {
1335        // The asset explicitly allows E014 (so, alone, the load would
1336        // succeed); the plugin override denies it -- override must win,
1337        // same `override > file > default` precedence as dialect/types.
1338        let (mut app, dir) = make_memory_asset_app();
1339        dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1340        dir.insert_asset_text(Path::new("brink.toml"), "[lints]\nE014 = \"allow\"\n");
1341
1342        let mut lints = std::collections::BTreeMap::new();
1343        lints.insert("E014".to_owned(), brink_project_config::LintLevel::Deny);
1344        app.register_asset_loader(InkLoader {
1345            override_config: Some(ProjectConfig {
1346                lints,
1347                ..ProjectConfig::default()
1348            }),
1349        });
1350
1351        let handle = app
1352            .world()
1353            .resource::<AssetServer>()
1354            .load::<BrinkStoryAsset>("intro.ink");
1355        wait_for_failed(&mut app, &handle);
1356    }
1357
1358    #[test]
1359    fn plugin_override_deny_warnings_relevels_warning_to_failed_load() {
1360        // No `brink.toml` at all -- alone, E014 stays a non-blocking
1361        // Warning (see `e014_source_loads_by_default_with_no_lints_table`).
1362        // The plugin override's blanket `deny_warnings` knob must still
1363        // relevel it to a blocking Error on its own, the same way a served
1364        // `brink.toml`'s `deny-warnings = true` does
1365        // (`sibling_brink_toml_deny_warnings_relevels_warning_to_failed_load`),
1366        // proving the `.deny_warnings` field (not just `.lints`) actually
1367        // reaches `OptionOverrides` through `InkLoader::load`.
1368        let (mut app, dir) = make_memory_asset_app();
1369        dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1370
1371        app.register_asset_loader(InkLoader {
1372            override_config: Some(ProjectConfig {
1373                deny_warnings: Some(true),
1374                ..ProjectConfig::default()
1375            }),
1376        });
1377
1378        let handle = app
1379            .world()
1380            .resource::<AssetServer>()
1381            .load::<BrinkStoryAsset>("intro.ink");
1382        wait_for_failed(&mut app, &handle);
1383    }
1384
1385    // ── unknown/non-overridable `[lints]` codes warn, not drop (#1416) ──
1386    //
1387    // `AnalysisOptions::apply_lint_overrides` (`brink-analyzer`) already
1388    // rejects a code that isn't a real `DiagnosticCode`, or names one whose
1389    // *base* severity isn't `Warning`, returning a `ConfigWarning` instead of
1390    // silently merging it — `resolve_options` (`brink-environment`) loops
1391    // those through `tracing::warn!` unconditionally, regardless of whether
1392    // a `brink.toml` was even discovered (see its own doc comment). Since
1393    // `bevy_log`'s macros are `tracing`'s own, re-exported verbatim, and
1394    // `LogPlugin` installs a process-wide `tracing` subscriber, that
1395    // `tracing::warn!` call already reaches a bevy author's console in any
1396    // real app -- the CLI/`brink.toml` mounts rely on the exact same
1397    // ambient-dispatch mechanism, just with `tracing_subscriber::fmt`
1398    // instead of `LogPlugin` as the installed subscriber. What was actually
1399    // untested (house rule 9) is that `BrinkPlugin::with_config`'s
1400    // `override_config.lints` -- forwarded into `OptionOverrides` by #1394 --
1401    // reaches that channel too, and that an invalid entry doesn't take a
1402    // valid sibling entry down with it. `CapturingSubscriber` below installs
1403    // itself as the *global* `tracing` default (not a thread-local one,
1404    // which `InkLoader::load` -- driven off the asset IO task pool thread --
1405    // would never see) so the warnings loop above is observed no matter
1406    // which pool thread runs the compile.
1407    struct CapturingSubscriber {
1408        messages: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1409    }
1410
1411    impl tracing::field::Visit for CapturedMessage {
1412        fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
1413            if field.name() == "message" {
1414                self.0 = format!("{value:?}");
1415            }
1416        }
1417    }
1418
1419    struct CapturedMessage(String);
1420
1421    impl tracing::Subscriber for CapturingSubscriber {
1422        // Global (process-wide, whole-test-binary-lifetime) subscriber, so
1423        // this must not accept every `trace!`/`debug!`/`info!` from every
1424        // crate (bevy_asset, bevy_ecs, brink-*) in every other test running
1425        // concurrently -- that would tax unrelated tests and let their
1426        // events contaminate this test's substring assertions. Only the
1427        // warnings this test actually cares about need capturing.
1428        fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
1429            *metadata.level() <= tracing::Level::WARN
1430        }
1431
1432        fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
1433            Some(tracing::level_filters::LevelFilter::WARN)
1434        }
1435
1436        fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
1437            tracing::span::Id::from_u64(1)
1438        }
1439
1440        fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
1441
1442        fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
1443
1444        fn event(&self, event: &tracing::Event<'_>) {
1445            let mut captured = CapturedMessage(String::new());
1446            event.record(&mut captured);
1447            self.messages.lock().unwrap().push(captured.0);
1448        }
1449
1450        fn enter(&self, _span: &tracing::span::Id) {}
1451
1452        fn exit(&self, _span: &tracing::span::Id) {}
1453    }
1454
1455    /// Install (once per test binary) a process-wide capturing subscriber
1456    /// and return the shared buffer every event's formatted message lands
1457    /// in. A single global install is required -- `tracing` only allows
1458    /// setting the global default once -- so every test that calls this
1459    /// shares one growing buffer; that's fine here because each test only
1460    /// asserts its own uniquely-spelled codes appear somewhere in it, never
1461    /// that the buffer is otherwise empty.
1462    fn captured_warnings() -> std::sync::Arc<std::sync::Mutex<Vec<String>>> {
1463        static MESSAGES: std::sync::OnceLock<std::sync::Arc<std::sync::Mutex<Vec<String>>>> =
1464            std::sync::OnceLock::new();
1465        let messages = MESSAGES.get_or_init(|| {
1466            let messages = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1467            let subscriber = CapturingSubscriber {
1468                messages: std::sync::Arc::clone(&messages),
1469            };
1470            // Ignore a "someone already set it" error: nothing else in this
1471            // crate's test binary installs a global subscriber, so in
1472            // practice this always wins on first call.
1473            let _ = tracing::subscriber::set_global_default(subscriber);
1474            messages
1475        });
1476        std::sync::Arc::clone(messages)
1477    }
1478
1479    #[test]
1480    fn plugin_override_unknown_and_non_overridable_lint_codes_warn_but_valid_entry_still_applies() {
1481        let warnings = captured_warnings();
1482
1483        let mut lints = std::collections::BTreeMap::new();
1484        // Not a real `DiagnosticCode` -- never parses.
1485        lints.insert(
1486            "E9999_TYPO".to_owned(),
1487            brink_project_config::LintLevel::Deny,
1488        );
1489        // A real code, but its base severity is `Error`, not `Warning` --
1490        // never overridable (mirrors `brink-analyzer`'s own
1491        // `apply_lint_overrides_rejects_non_overridable_code` unit test).
1492        lints.insert("E001".to_owned(), brink_project_config::LintLevel::Deny);
1493        // The valid entry: must still apply and relevel E014 to a blocking
1494        // Error, proving the two invalid siblings above don't take it down
1495        // with them.
1496        lints.insert("E014".to_owned(), brink_project_config::LintLevel::Deny);
1497
1498        // Routed through `BrinkAssetsPlugin::with_config` (not a
1499        // hand-registered `InkLoader`) so this test actually proves the
1500        // `BrinkPlugin::with_config` wiring path, not just
1501        // `InkLoader::override_config` in isolation.
1502        let (mut app, dir) = make_memory_asset_app_with_config(ProjectConfig {
1503            lints,
1504            ..ProjectConfig::default()
1505        });
1506        dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1507
1508        let handle = app
1509            .world()
1510            .resource::<AssetServer>()
1511            .load::<BrinkStoryAsset>("intro.ink");
1512        wait_for_failed(&mut app, &handle);
1513
1514        let joined = warnings.lock().unwrap().join("\n");
1515        // Assert the exact message text `validate_lint_code`
1516        // (`brink-analyzer/src/lib.rs`) emits for each rejection class, not
1517        // just the code substring -- a plain `contains("E001")` can't tell
1518        // "not a recognized diagnostic code" apart from "not overridable",
1519        // so it would pass identically even if the two rejection paths were
1520        // swapped.
1521        assert!(
1522            joined.contains("[lints] `E9999_TYPO` is not a recognized diagnostic code"),
1523            "an unknown lint code must warn with the 'not a recognized diagnostic code' \
1524             message (not silently drop); captured: {joined}"
1525        );
1526        assert!(
1527            joined.contains("[lints] `E001` is not overridable"),
1528            "a non-overridable lint code must warn with the 'not overridable' message \
1529             (not silently drop); captured: {joined}"
1530        );
1531    }
1532
1533    // ── plugin-level `with_config` coverage across all four knobs (#1426) ──
1534    //
1535    // The tests above prove `[lints]` reaches through `with_config` at the
1536    // plugin level. `dialect`/`types`/`deny_warnings` previously only had
1537    // *loader*-level coverage (`InkLoader { override_config }` constructed
1538    // by hand, e.g. `plugin_override_wins_over_conflicting_asset` above) —
1539    // never proven to actually reach `InkLoader` through
1540    // `BrinkAssetsPlugin::with_config` / `BrinkPlugin::with_config`
1541    // themselves (`with_config` -> `with_config_option` ->
1542    // `BrinkAssetsPlugin::build` -> `InkLoader { override_config }`). Each
1543    // test below picks a fixture whose default-policy outcome the override
1544    // must actually flip (house rule: a value the default would already
1545    // produce proves nothing).
1546
1547    #[test]
1548    fn plugin_with_config_dialect_reaches_ink_loader() {
1549        // Default dialect (StrictInk) rejects `#@private`
1550        // (`missing_brink_toml_leaves_default_dialect_unchanged` above) --
1551        // only an explicit `dialect = Brink` override flips that.
1552        let (mut app, dir) = make_memory_asset_app_with_config(ProjectConfig {
1553            dialect: Some(Dialect::Brink),
1554            ..ProjectConfig::default()
1555        });
1556        dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1557
1558        let handle = app
1559            .world()
1560            .resource::<AssetServer>()
1561            .load::<BrinkStoryAsset>("intro.ink");
1562        wait_for_loaded(&mut app, &handle);
1563    }
1564
1565    #[test]
1566    fn plugin_with_config_types_reaches_ink_loader() {
1567        // `dialect = Brink` alone resolves `types` to its dialect-keyed
1568        // default, `Strict` (`AnalysisOptions::type_policy`), which rejects
1569        // `UNTYPED_PARAM_SOURCE`'s untyped function parameter -- only an
1570        // explicit `types = Gradual` override (on top of the same
1571        // `dialect = Brink`) makes it compile. See
1572        // `plugin_with_config_types_absent_leaves_strict_default_rejecting`
1573        // for the negative control proving the default (no `types`
1574        // override) still fails the same source.
1575        let (mut app, dir) = make_memory_asset_app_with_config(ProjectConfig {
1576            dialect: Some(Dialect::Brink),
1577            types: Some(TypePolicy::Gradual),
1578            ..ProjectConfig::default()
1579        });
1580        dir.insert_asset_text(Path::new("intro.ink"), UNTYPED_PARAM_SOURCE);
1581
1582        let handle = app
1583            .world()
1584            .resource::<AssetServer>()
1585            .load::<BrinkStoryAsset>("intro.ink");
1586        wait_for_loaded(&mut app, &handle);
1587    }
1588
1589    #[test]
1590    fn plugin_with_config_types_absent_leaves_strict_default_rejecting() {
1591        // Negative control for `plugin_with_config_types_reaches_ink_loader`
1592        // (house rule 19q): same `UNTYPED_PARAM_SOURCE`, same `dialect =
1593        // Brink`, but no `types` override -- the dialect-keyed default
1594        // (`Strict`) still rejects it, so the override in the sibling test
1595        // is what actually flips the outcome, not something else about the
1596        // fixture or the dialect setting.
1597        let (mut app, dir) = make_memory_asset_app_with_config(ProjectConfig {
1598            dialect: Some(Dialect::Brink),
1599            types: None,
1600            ..ProjectConfig::default()
1601        });
1602        dir.insert_asset_text(Path::new("intro.ink"), UNTYPED_PARAM_SOURCE);
1603
1604        let handle = app
1605            .world()
1606            .resource::<AssetServer>()
1607            .load::<BrinkStoryAsset>("intro.ink");
1608        wait_for_failed(&mut app, &handle);
1609    }
1610
1611    #[test]
1612    fn plugin_with_config_deny_warnings_reaches_ink_loader() {
1613        // `E014_SOURCE` loads cleanly under the default policy
1614        // (`e014_source_loads_by_default_with_no_lints_table` above) --
1615        // only an explicit `deny_warnings = true` override relevels its
1616        // Warning to a blocking Error.
1617        let (mut app, dir) = make_memory_asset_app_with_config(ProjectConfig {
1618            deny_warnings: Some(true),
1619            ..ProjectConfig::default()
1620        });
1621        dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1622
1623        let handle = app
1624            .world()
1625            .resource::<AssetServer>()
1626            .load::<BrinkStoryAsset>("intro.ink");
1627        wait_for_failed(&mut app, &handle);
1628    }
1629
1630    #[test]
1631    fn brink_plugin_with_config_delegates_to_ink_loader() {
1632        // The two-hop path a real app actually uses (`BrinkPlugin<M>`, not
1633        // `BrinkAssetsPlugin` standalone) -- proves `BrinkPlugin::build`'s
1634        // `with_config_option` delegation actually carries the override
1635        // through, using the same dialect fixture as
1636        // `plugin_with_config_dialect_reaches_ink_loader`.
1637        let (mut app, dir) = make_memory_asset_app_via_brink_plugin_with_config(ProjectConfig {
1638            dialect: Some(Dialect::Brink),
1639            ..ProjectConfig::default()
1640        });
1641        dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1642
1643        let handle = app
1644            .world()
1645            .resource::<AssetServer>()
1646            .load::<BrinkStoryAsset>("intro.ink");
1647        wait_for_loaded(&mut app, &handle);
1648    }
1649
1650    /// [`BrinkConfigWarnings`](crate::BrinkConfigWarnings)'s plugin-level
1651    /// wiring (#1426): `BrinkAssetsPlugin::build` inserts it eagerly, so a
1652    /// host never needs a real story load (or a `tracing` subscriber) to
1653    /// read a rejected `with_config` lint code.
1654    #[test]
1655    fn plugin_with_config_invalid_lint_code_reaches_brink_config_warnings_resource() {
1656        let mut lints = std::collections::BTreeMap::new();
1657        lints.insert(
1658            "E9999_TYPO".to_owned(),
1659            brink_project_config::LintLevel::Deny,
1660        );
1661        let (app, _dir) = make_memory_asset_app_with_config(ProjectConfig {
1662            lints,
1663            ..ProjectConfig::default()
1664        });
1665
1666        let warnings = app.world().resource::<crate::BrinkConfigWarnings>();
1667        assert_eq!(warnings.0.len(), 1);
1668        assert!(
1669            warnings.0[0].contains("E9999_TYPO") && warnings.0[0].contains("not a recognized"),
1670            "unexpected resource contents: {warnings:?}"
1671        );
1672    }
1673
1674    /// Additional coverage found while investigating #1436 — not one of the
1675    /// two gaps #1430's review thread actually named (a *served*
1676    /// `brink.toml`'s `[lints]` rejections and unknown-key warnings on the
1677    /// bevy loader path; see #1625, filed as the follow-up, for those).
1678    /// This closes a different, narrower gap: the sibling of
1679    /// [`plugin_with_config_invalid_lint_code_reaches_brink_config_warnings_resource`]
1680    /// above only ever exercised the *unknown-code* rejection class
1681    /// (`E9999_TYPO`, "not a recognized diagnostic code") against
1682    /// [`BrinkConfigWarnings`](crate::BrinkConfigWarnings) — the
1683    /// *non-overridable-code* class ("not overridable", e.g. `E001`, a
1684    /// real code whose default severity isn't `Warning`) had this exact
1685    /// resource-channel reachability covered only by
1686    /// `config_warnings::tests::non_overridable_code_surfaces_a_message_naming_it`,
1687    /// which calls `BrinkConfigWarnings::from_config` directly and so
1688    /// proves nothing about `with_config`'s own plugin-build wiring — the
1689    /// same gap class `plugin_with_config_invalid_lint_code_reaches_brink_config_warnings_resource`
1690    /// closed for the unknown-code class. `validate_lint_code`
1691    /// (`brink-analyzer`) returns a structurally different `ConfigWarning`
1692    /// per rejection class, so covering one does not prove the other is
1693    /// wired the same way.
1694    #[test]
1695    fn plugin_with_config_non_overridable_lint_code_reaches_brink_config_warnings_resource() {
1696        let mut lints = std::collections::BTreeMap::new();
1697        // A real `DiagnosticCode`, but its base severity is `Error`, not
1698        // `Warning` -- never overridable (mirrors
1699        // `apply_lint_overrides_rejects_non_overridable_code` in
1700        // `brink-analyzer`, and this file's own
1701        // `plugin_override_unknown_and_non_overridable_lint_codes_warn_but_valid_entry_still_applies`,
1702        // which proves the same code on the `tracing::warn!` channel, not
1703        // this resource).
1704        lints.insert("E001".to_owned(), brink_project_config::LintLevel::Deny);
1705        let (app, _dir) = make_memory_asset_app_with_config(ProjectConfig {
1706            lints,
1707            ..ProjectConfig::default()
1708        });
1709
1710        let warnings = app.world().resource::<crate::BrinkConfigWarnings>();
1711        assert_eq!(warnings.0.len(), 1);
1712        assert!(
1713            warnings.0[0].contains("E001") && warnings.0[0].contains("not overridable"),
1714            "unexpected resource contents: {warnings:?}"
1715        );
1716    }
1717
1718    /// Additional coverage found while investigating #1436 — not one of the
1719    /// two gaps #1430's review thread actually named (see the doc comment
1720    /// on `plugin_with_config_non_overridable_lint_code_reaches_brink_config_warnings_resource`
1721    /// above for those, and #1625, filed as the follow-up). This closes a
1722    /// different gap: `compile_story_inline` shares the exact same
1723    /// `Project::load` seam `InkLoader::load` uses (#1380's own doc
1724    /// comment: "the exact same two-call seam `InkLoader::load` uses") —
1725    /// so a `with_config` lint-code rejection must warn through
1726    /// `compile_story_inline`'s call too, not just the asset-loader path
1727    /// `plugin_override_unknown_and_non_overridable_lint_codes_warn_but_valid_entry_still_applies`
1728    /// already covers. That test (and the `BrinkConfigWarnings`-resource
1729    /// tests above) only ever drove the rejection through
1730    /// `AssetServer::load` -> `InkLoader::load` -> `Project::load`;
1731    /// `compile_story_inline`'s own `Project::load` call
1732    /// (`source_loader.rs`, `compile_story_inline`) had no coverage proving
1733    /// it reaches the same `tracing::warn!` channel.
1734    #[test]
1735    fn compile_story_inline_invalid_lint_code_warns_via_tracing() {
1736        let captured = captured_warnings();
1737
1738        let mut lints = std::collections::BTreeMap::new();
1739        lints.insert(
1740            "E9998_INLINE_TYPO".to_owned(),
1741            brink_project_config::LintLevel::Deny,
1742        );
1743        let (mut app, _dir) = make_memory_asset_app_with_config(ProjectConfig {
1744            lints,
1745            ..ProjectConfig::default()
1746        });
1747
1748        // The rejected code is never applied (it's invalid, not merged),
1749        // so this trivial, diagnostic-free source still compiles cleanly --
1750        // the point is to observe the warning, not a failed compile.
1751        crate::compile_story_inline(&mut app, "intro.ink", "-> END\n")
1752            .expect("E9998_INLINE_TYPO is rejected, not applied, so nothing blocks the compile");
1753
1754        let joined = captured.lock().unwrap().join("\n");
1755        assert!(
1756            joined.contains("[lints] `E9998_INLINE_TYPO` is not a recognized diagnostic code"),
1757            "an invalid with_config lint code must warn through \
1758             compile_story_inline's own Project::load call too, not just \
1759             InkLoader's; captured: {joined}"
1760        );
1761    }
1762
1763    /// Issue #1382 sweep finding: a *second* `BrinkPlugin<M>` registration's
1764    /// `with_config` override used to vanish with no trace at all once
1765    /// `BrinkAssetsPlugin` already existed (added by an earlier marker's
1766    /// plugin) — `BrinkPlugin::with_config`'s own doc comment already
1767    /// documented the precedence rule ("only the plugin that ends up adding
1768    /// `BrinkAssetsPlugin` applies its config"), but neither the
1769    /// `tracing::warn!` channel nor [`BrinkConfigWarnings`] ever recorded
1770    /// that a *later* marker's whole `ProjectConfig` was the one that lost —
1771    /// exactly the silent-drop pattern this issue swept for, just under a
1772    /// different name than `resolve_*_options`. Two distinct marker types
1773    /// reproduce a real multi-story app rather than a synthetic double
1774    /// registration: `MarkerA`'s plugin (no override) is the one that adds
1775    /// `BrinkAssetsPlugin`, so `MarkerB`'s `with_config` has nothing left to
1776    /// land in.
1777    #[test]
1778    fn second_marker_with_config_drop_is_diagnosed_not_silent() {
1779        struct MarkerA;
1780        struct MarkerB;
1781
1782        let captured = captured_warnings();
1783
1784        let mut app = bevy_app::App::new();
1785        let dir = Dir::default();
1786        let dir_clone = dir.clone();
1787        app.register_asset_source(
1788            AssetSourceId::Default,
1789            AssetSourceBuilder::new(move || {
1790                Box::new(MemoryAssetReader {
1791                    root: dir_clone.clone(),
1792                })
1793            }),
1794        )
1795        .add_plugins((
1796            TaskPoolPlugin::default(),
1797            AssetPlugin {
1798                watch_for_changes_override: Some(false),
1799                use_asset_processor_override: Some(false),
1800                ..Default::default()
1801            },
1802            // `MarkerA`'s plugin has no override and is the one that ends up
1803            // adding `BrinkAssetsPlugin` (first in registration order).
1804            crate::BrinkPlugin::<MarkerA>::default(),
1805            // `MarkerB`'s override arrives after `BrinkAssetsPlugin` already
1806            // exists, so it must be diagnosed rather than silently dropped.
1807            crate::BrinkPlugin::<MarkerB>::default().with_config(ProjectConfig {
1808                dialect: Some(Dialect::Brink),
1809                ..ProjectConfig::default()
1810            }),
1811        ));
1812
1813        let warnings = app.world().resource::<crate::BrinkConfigWarnings>();
1814        assert!(
1815            warnings
1816                .0
1817                .iter()
1818                .any(|w| w.contains("MarkerB") && w.contains("ignored")),
1819            "a second marker's dropped `with_config` override must be recorded \
1820             in `BrinkConfigWarnings`, not silently discarded; got: {warnings:?}"
1821        );
1822
1823        let joined = captured.lock().unwrap().join("\n");
1824        assert!(
1825            joined.contains("MarkerB") && joined.contains("ignored"),
1826            "the same drop must also reach the tracing::warn! channel (the \
1827             'warn, never silently drop' rule every other mount's config \
1828             resolution already follows); captured: {joined}"
1829        );
1830
1831        // Prove the drop, not just its announcement (house rule 19t): the
1832        // shared `InkLoader` this app ends up with must still be running
1833        // under the strict-ink default, not `MarkerB`'s `dialect = brink`
1834        // override -- if a future change ever let a later marker's config
1835        // reach `InkLoader` after all, this would start failing (green,
1836        // while the warning above kept firing as a lie) unless it's pinned
1837        // down here too.
1838        dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1839        let handle = app
1840            .world()
1841            .resource::<AssetServer>()
1842            .load::<BrinkStoryAsset>("intro.ink");
1843        wait_for_failed(&mut app, &handle);
1844    }
1845
1846    #[test]
1847    fn hot_reload_picks_up_edited_brink_toml() {
1848        let (mut app, dir) = make_memory_asset_app();
1849        dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1850        dir.insert_asset_text(Path::new("brink.toml"), "[project]\ndialect = \"brink\"\n");
1851
1852        let handle = app
1853            .world()
1854            .resource::<AssetServer>()
1855            .load::<BrinkStoryAsset>("intro.ink");
1856        wait_for_loaded(&mut app, &handle);
1857
1858        // Edit brink.toml back to strict-ink and force a reload -- this is
1859        // what the dev-mode file watcher does automatically when a
1860        // registered load dependency changes on disk; `reload` here drives
1861        // the same path deterministically without a real watcher.
1862        dir.insert_asset_text(
1863            Path::new("brink.toml"),
1864            "[project]\ndialect = \"strict-ink\"\n",
1865        );
1866        app.world().resource::<AssetServer>().reload("intro.ink");
1867
1868        wait_for_failed(&mut app, &handle);
1869    }
1870
1871    /// #1360 regression: the migrated loader still walks a multi-file
1872    /// `INCLUDE` graph correctly through the real `AssetServer` /
1873    /// `MemoryAssetReader`, including a parent-traversing (`../`) include
1874    /// that exercises [`super::resolve_include_path`]'s `..`-segment
1875    /// normalization, alongside a same-directory include. Before #1360 this
1876    /// path went through `brink_compiler::compile_with_options`'s read
1877    /// closure; it now goes through `brink_source_tree::InMemory` ->
1878    /// `brink_environment::Driver::discover`, so a producer-side change to
1879    /// include-graph keying could silently break multi-file loads without
1880    /// this test catching it.
1881    #[test]
1882    fn multi_file_include_graph_loads_through_asset_server() {
1883        let (mut app, dir) = make_memory_asset_app();
1884        dir.insert_asset_text(
1885            Path::new("stories/ch1/intro.ink"),
1886            "INCLUDE ../shared/util.ink\nINCLUDE local.ink\n-> END\n",
1887        );
1888        // Parent traversal: "../shared/util.ink" from "stories/ch1/" must
1889        // resolve to "stories/shared/util.ink", not "stories/ch1/../shared/util.ink".
1890        dir.insert_asset_text(Path::new("stories/shared/util.ink"), "VAR shared_var = 1\n");
1891        // Same-directory include, resolved relative to the entry's own dir.
1892        dir.insert_asset_text(Path::new("stories/ch1/local.ink"), "VAR local_var = 2\n");
1893
1894        let handle = app
1895            .world()
1896            .resource::<AssetServer>()
1897            .load::<BrinkStoryAsset>("stories/ch1/intro.ink");
1898        wait_for_loaded(&mut app, &handle);
1899    }
1900
1901    // ── served `brink.toml`'s own warnings reach the loader path (#1625) ──
1902    //
1903    // #1430's review thread named two gaps neither #1436's tests
1904    // (`plugin_with_config_non_overridable_lint_code_reaches_brink_config_warnings_resource`,
1905    // `compile_story_inline_invalid_lint_code_warns_via_tracing`, both above)
1906    // nor any other existing test actually closed: a *served* `brink.toml`
1907    // -- one discovered next to the story asset through the real
1908    // `AssetServer::load` -> `InkLoader::load` -> `Project::load` ->
1909    // `resolve_options` seam, not a `with_config`/`InkLoader::override_config`
1910    // override -- whose `[lints]` table rejects an entry, or whose top-level
1911    // keys include an unknown one, must still warn via `tracing::warn!`.
1912    // `resolve_options` (`brink-environment/src/lib.rs`) already does this
1913    // unconditionally for a discovered file (`tracing::warn!("[{config_key}]
1914    // {warning}")`, both for `parse_str_at`'s own unknown-key warnings and
1915    // for `apply_project_config`'s rejected-`[lints]`-entry warnings) -- the
1916    // two tests below are the first to actually observe that through the
1917    // bevy loader path a real host uses, asserting the full `resolve_options`
1918    // -produced message text (not just a code substring) so the two
1919    // rejection classes stay distinguishable from each other, per the issue.
1920    #[test]
1921    fn served_brink_toml_invalid_lint_code_warns_via_tracing() {
1922        let captured = captured_warnings();
1923
1924        let (mut app, dir) = make_memory_asset_app();
1925        dir.insert_asset_text(Path::new("intro.ink"), E014_SOURCE);
1926        // Uniquely-spelled code (the capture buffer is process-global across
1927        // this file's tests) -- a real `DiagnosticCode` whose base severity
1928        // isn't `Warning`, so it's rejected as "not overridable" rather than
1929        // "not a recognized diagnostic code". `E001` is deliberately avoided
1930        // here: it's also used by
1931        // `plugin_override_unknown_and_non_overridable_lint_codes_warn_but_valid_entry_still_applies`'s
1932        // `with_config` case, and that test's `joined.contains("[lints]
1933        // `E001` is not overridable")` assertion would be satisfied by this
1934        // served-file warning too (the capture buffer is shared process-wide)
1935        // whenever this test runs first, making that other test's own
1936        // `with_config` path go unverified without either test failing.
1937        // `E002` is a real, non-overridable `DiagnosticCode`
1938        // (`DiagnosticCode::severity`, `brink-ir/src/hir/types.rs`) used
1939        // nowhere else in this file, so it stays independent of both.
1940        //
1941        // A second entry, `E8888_SERVED_TYPO`, covers the sibling rejection
1942        // class ("not a recognized diagnostic code") on this same served-file
1943        // path -- the precedent test above deliberately covers both classes
1944        // because a bare code substring can't distinguish them, so this test
1945        // does too.
1946        dir.insert_asset_text(
1947            Path::new("brink.toml"),
1948            "[lints]\nE002 = \"deny\"\nE8888_SERVED_TYPO = \"deny\"\n",
1949        );
1950
1951        let handle = app
1952            .world()
1953            .resource::<AssetServer>()
1954            .load::<BrinkStoryAsset>("intro.ink");
1955        // The rejected entries are never applied, so E014 stays a
1956        // non-blocking Warning and the load still succeeds -- the point is
1957        // to observe the warnings, not a failed compile.
1958        wait_for_loaded(&mut app, &handle);
1959
1960        let joined = captured.lock().unwrap().join("\n");
1961        assert!(
1962            joined.contains("[brink.toml] [lints] `E002` is not overridable"),
1963            "a served brink.toml's rejected [lints] entry must warn with the \
1964             full resolve_options-produced message (config-key-prefixed, not \
1965             just the code substring) through the real AssetServer -> \
1966             InkLoader -> Project::load path; captured: {joined}"
1967        );
1968        assert!(
1969            joined.contains(
1970                "[brink.toml] [lints] `E8888_SERVED_TYPO` is not a recognized diagnostic code"
1971            ),
1972            "a served brink.toml's unrecognized [lints] code must also warn, \
1973             distinguishable from the not-overridable class above; \
1974             captured: {joined}"
1975        );
1976    }
1977
1978    #[test]
1979    fn served_brink_toml_unknown_top_level_key_warns_via_tracing() {
1980        let captured = captured_warnings();
1981
1982        let (mut app, dir) = make_memory_asset_app();
1983        dir.insert_asset_text(Path::new("intro.ink"), BRINK_ONLY_SOURCE);
1984        // `[project] dialect = "brink"` so the load still succeeds
1985        // (BRINK_ONLY_SOURCE needs it) -- the unknown top-level key alongside
1986        // it (not nested under `[project]`, which would instead warn as
1987        // "unknown key `project.suprise_typo_key`") is what this test is
1988        // actually about, distinguished from the `[lints]`-rejection class
1989        // above by asserting the "unknown top-level key" wording
1990        // specifically. The bare key must precede the `[project]` table
1991        // header -- TOML requires top-level key/value pairs before the
1992        // first table.
1993        dir.insert_asset_text(
1994            Path::new("brink.toml"),
1995            "suprise_typo_key = true\n\n[project]\ndialect = \"brink\"\n",
1996        );
1997
1998        let handle = app
1999            .world()
2000            .resource::<AssetServer>()
2001            .load::<BrinkStoryAsset>("intro.ink");
2002        wait_for_loaded(&mut app, &handle);
2003
2004        let joined = captured.lock().unwrap().join("\n");
2005        assert!(
2006            joined.contains(
2007                "[brink.toml] unknown top-level key `suprise_typo_key` in brink.toml (ignored)"
2008            ),
2009            "a served brink.toml's unknown top-level key must warn with the \
2010             full resolve_options-produced message through the real \
2011             AssetServer -> InkLoader -> Project::load path, distinguishable \
2012             from the [lints]-rejection class; captured: {joined}"
2013        );
2014    }
2015}