Skip to main content

lumen_runtime/
config.rs

1//! Per-app `lumen.toml` config loader.
2//!
3//! - Reads an optional file at `<app-dir>/lumen.toml`.
4//! - Sets runtime defaults; CLI flags supplied on top take precedence.
5//! - Unknown top-level keys are rejected; section fields default when absent.
6//!
7//! ```toml
8//! [app]
9//! entry = "main.lmn"          # default
10//! locale = "de-DE"            # default: the OS locale, else en-US
11//! fallback_locale = "de-DE"   # default: en-US; the language the source strings are in
12//! single_instance = true      # default: false; second launch forwards argv and exits
13//!
14//! [window]
15//! title = "My App"            # default: app directory name
16//! size  = [1280, 720]         # default: [960, 720]
17//!
18//! [skin]
19//! name = "default"            # default: none (bare framework)
20//!
21//! [mcp]
22//! port = 7878                 # default when absent; 0 disables the server
23//!
24//! [profile]
25//! mode = "off"                # off | chrome | tracy | stderr
26//!
27//! [asset_roots]
28//! paths = ["icons", "../shared"]   # extra dirs scanned for relative src=
29//!
30//! [dependencies]                   # runtime modules + portable plugins, sorted-name order
31//! lumen-audio = { bundled = true }
32//! shape-tools = { path = "modules/shape-tools", config = { units = "mm" } }
33//! weather-feed = "1.2"             # registry package, fetched by lpm and pinned in lumen.lock
34//!
35//! [target.web.dependencies]        # laid over [dependencies] for a `lumenc web` build
36//! chart = { path = "modules/chart" }  # a module with only a web half: chart/web/lumen-addon.toml
37//!
38//! [[hooks]]                        # project build/setup commands; see `crate::hooks`
39//! when    = "prebuild"             # "prebuild" | "prerun"
40//! os      = "linux"                # optional: "linux" | "macos" | "windows"
41//! run     = "mkdir -p lib && cc -shared -fPIC -O2 -o lib/libmd.so md.c"
42//! inputs  = ["md.c"]
43//! outputs = ["lib/libmd.so"]
44//!
45//! [[plugins]]                      # compiler plugins, run in declaration order
46//! name    = "markdown"             # must match what the cdylib reports
47//! path    = "plugins/markdown"     # built cdylib; extensionless probes lib*.so/.dylib/.dll
48//! config  = { flavor = "gfm" }     # optional, handed to the plugin verbatim
49//! ```
50//!
51//! Parse failures surface as [`ConfigError`]; the caller decides whether to abort or fall back to defaults.
52
53use serde::Deserialize;
54use std::collections::{BTreeMap, HashMap};
55use std::path::{Path, PathBuf};
56
57/// Parsed `lumen.toml` config, all fields optional.
58#[derive(Debug, Clone, Default, Deserialize)]
59#[serde(default, deny_unknown_fields)]
60pub struct LumenToml {
61    /// The whole file, parsed but untyped. What an optional subsystem reads
62    /// its own section from: the typed fields above are the runtime's, and a
63    /// subsystem the runtime does not name has no field here.
64    #[serde(skip)]
65    pub raw: toml::Table,
66    /// `[app]` section.
67    pub app: AppCfg,
68    /// `[pages]` section - file-based multi-page navigation config.
69    pub pages: PagesCfg,
70    /// `[window]` section.
71    pub window: WindowCfg,
72    /// `[skin]` section.
73    pub skin: SkinCfg,
74    /// `[mcp]` section.
75    pub mcp: McpCfg,
76    /// `[profile]` section.
77    pub profile: ProfileCfg,
78    /// `[asset_roots]` section.
79    pub asset_roots: AssetRootsCfg,
80    /// `[script]` section - script-engine selection.
81    pub script: ScriptCfg,
82    /// `[render]` section - which render backend draws the app.
83    pub render: RenderCfg,
84    /// `[perf]` section - per-cache memory budgets.
85    pub perf: PerfCfg,
86    /// `[runtime]` section - subsystem init overrides (MCP /
87    /// hot-reload / thread budget).
88    pub runtime: RuntimeCfg,
89    /// `[capabilities]` section - per-app COMPILE-TIME subsystem trim toggles
90    /// for the static `--bundle` build (Part B tree-shaking).
91    pub capabilities: CapabilitiesCfg,
92    /// `[web]` section - how `lumenc web` emits the app as a static site.
93    pub web: WebCfg,
94    /// `[[hooks]]` array - project-declared build/setup commands, run at the
95    /// `prebuild` / `prerun` trigger points by `lumenc run` / `build` /
96    /// `bundle`. See [`crate::hooks`] for the execution semantics (ordering,
97    /// OS filtering, staleness skip) and [`HookCfg`] for the field reference.
98    pub hooks: Vec<HookCfg>,
99    /// `[[plugins]]` array - compiler plugins, run in declaration order by
100    /// every compile path, `check` included. This field only accepts the
101    /// array (`deny_unknown_fields` would otherwise reject any app declaring
102    /// one); nothing reads it. The schema, the loader, and the validation
103    /// live in `lumenc-plugin` (see its `PluginCfg`), lumenc reads the file
104    /// itself, and the chain reaches the pipeline through the injected
105    /// [`crate::compiler_plugins::CompilerPlugins`] boundary, the same
106    /// inversion the parser uses.
107    pub plugins: Vec<toml::Value>,
108    /// `[dependencies]` table - prebuilt dylibs loaded at startup in
109    /// sorted-name order: engine-linked runtime modules and portable C-ABI
110    /// plugins, told apart by their exports. The schema, the loader, and
111    /// the failure policy live in `lumen-modules` (re-exported as
112    /// [`crate::modules`]); `build_app` runs the table through the loader
113    /// when the `modules` feature is on.
114    pub dependencies: lumen_modules::DependenciesCfg,
115    /// `[target.web]` and `[target.desktop]`: what a build for one target
116    /// adds. Each may carry a `dependencies` table of the same shape as
117    /// `[dependencies]`, laid over it for that target's builds; read the
118    /// combined table through [`Self::dependencies_for`].
119    pub target: lumen_modules::TargetTables,
120    /// `[signals]` table - optional typed schema. Each key declares
121    /// the expected `SignalType`. Used by `lumenc lint --signals`
122    /// to flag untyped writes / schema mismatches. Schema entries
123    /// are optional: missing keys just downgrade the lint severity.
124    #[serde(default)]
125    pub signals: SignalsCfg,
126}
127
128/// `[app]` block.
129#[derive(Debug, Clone, Default, Deserialize)]
130#[serde(default, deny_unknown_fields)]
131pub struct AppCfg {
132    /// Markup entry filename relative to the app dir. Defaults to `main.lmn`.
133    pub entry: Option<String>,
134    /// Stable identifier used for per-app state directories (window state, plugin caches). Falls back to the app directory name when absent.
135    pub id: Option<String>,
136    /// Optional app-kind override (`"markup"` | `"rust"` | `"cpp"` |
137    /// `"python"`). Optional and used only to override auto-detection: when
138    /// absent, `crate::app_kind::detect` inspects the directory; when present,
139    /// it wins. Lets an ambiguous directory (e.g. a Rust workspace member that
140    /// should still run as pure markup) pin its build/run route.
141    pub kind: Option<crate::app_kind::AppKind>,
142    /// BCP-47 tag naming the locale the app starts in, e.g. `"de-DE"`.
143    /// Absent means "follow the OS", falling back to `en-US`. The tag
144    /// selects which `locale/<tag>.ftl` catalogue `translatable="..."`
145    /// markup and the scripts' `t()` builtin resolve against; every
146    /// catalogue in the directory is loaded regardless. A tag that is not
147    /// valid BCP-47 is a `lumen.toml` error.
148    #[serde(default, deserialize_with = "de_locale")]
149    pub locale: Option<lumen_i18n::LanguageIdentifier>,
150    /// BCP-47 tag naming the locale a key missing from the active catalogue
151    /// falls through to before the element's own authored text. Defaults to
152    /// `en-US`. Set it to the language the app's source strings are written
153    /// in, so an app authored in German does not probe an `en-US` catalogue
154    /// it does not ship. Naming the active locale here leaves nothing to
155    /// fall through to, so a miss goes straight to the authored text. A tag
156    /// that is not valid BCP-47 is a `lumen.toml` error.
157    #[serde(default, deserialize_with = "de_fallback_locale")]
158    pub fallback_locale: Option<lumen_i18n::LanguageIdentifier>,
159    /// When `true`, a second launch of this app forwards its command-line
160    /// arguments to the already-running instance and exits instead of
161    /// opening a second window; the primary sees them as
162    /// `on_second_instance(args)`. Defaults to `false`: most apps are fine
163    /// with more than one window open, and the lock has a real
164    /// side effect (a bound socket / named pipe) that an app should opt
165    /// into rather than acquire by surprise. Windowed runs only - a
166    /// headless / `--headless` run never locks, so CI and the SDK's
167    /// non-interactive embedding never contend over one socket.
168    #[serde(default)]
169    pub single_instance: bool,
170}
171
172/// Parse one of `[app]`'s BCP-47 tags into a validated language identifier,
173/// so a typo fails at config-load time naming the offending key and tag
174/// rather than silently leaving the app in its default locale.
175fn parse_opt_locale<'de, D>(
176    deserializer: D,
177    field: &str,
178) -> Result<Option<lumen_i18n::LanguageIdentifier>, D::Error>
179where
180    D: serde::Deserializer<'de>,
181{
182    let Some(raw) = Option::<String>::deserialize(deserializer)? else {
183        return Ok(None);
184    };
185    lumen_i18n::Lang::try_from(raw.trim())
186        .map(|l| Some(l.into()))
187        .map_err(|e| {
188            serde::de::Error::custom(format!("app: `{field}` is not a valid BCP-47 tag: {e}"))
189        })
190}
191
192/// Parse `[app] locale`.
193fn de_locale<'de, D>(deserializer: D) -> Result<Option<lumen_i18n::LanguageIdentifier>, D::Error>
194where
195    D: serde::Deserializer<'de>,
196{
197    parse_opt_locale(deserializer, "locale")
198}
199
200/// Parse `[app] fallback_locale`.
201fn de_fallback_locale<'de, D>(
202    deserializer: D,
203) -> Result<Option<lumen_i18n::LanguageIdentifier>, D::Error>
204where
205    D: serde::Deserializer<'de>,
206{
207    parse_opt_locale(deserializer, "fallback_locale")
208}
209
210/// `[pages]` block - file-based multi-page navigation.
211///
212/// Static app structure belongs in `lumen.toml` (the single source of
213/// truth). Auto-discovery loads every `.lmn` file in the app dir as a page
214/// keyed by its filename stem; these fields let the config pin the static
215/// bits the AOT build and the runtime both read.
216///
217/// ```toml
218/// [pages]
219/// entry = "index"                   # home page key (default: "index", else the
220///                                   #   [app] entry stem, else "main")
221/// enabled = true                    # force multi-page on/off (default: auto -
222///                                   #   on when >1 .lmn file is present, or
223///                                   #   `include` names >1 page)
224/// include = ["index.lmn", "settings.lmn", "user.lmn"]  # explicit page set,
225///                                   #   overriding directory auto-discovery
226/// ```
227#[derive(Debug, Clone, Default, Deserialize)]
228#[serde(default, deny_unknown_fields)]
229pub struct PagesCfg {
230    /// Home/entry page key (filename stem, no `.lmn`). Defaults to `index`
231    /// when an `index.lmn` exists, else the `[app] entry` stem, else `main`.
232    pub entry: Option<String>,
233    /// Force multi-page mode on (`true`) or off (`false`) and win over the
234    /// auto default either way. When absent, multi-page activates
235    /// automatically when more than one `.lmn` file sits in the app
236    /// directory, or when `include` names more than one page that exists,
237    /// including pages living in a subfolder the directory scan never sees.
238    /// So single-page apps (`main.lmn` / a lone `index.lmn`, or a
239    /// single-entry `include`) keep the exact legacy single-file load path.
240    pub enabled: Option<bool>,
241    /// Explicit ordered page-file list (relative filenames, may name a
242    /// subfolder). When set, this overrides directory auto-discovery: only
243    /// these files are pages. An entry naming a file that is not there is
244    /// skipped with a warning and does not count toward the multi-page
245    /// default, so the block can be written ahead of the pages it names;
246    /// when every entry is missing the app still fails to load, naming a
247    /// file the list asked for.
248    pub include: Option<Vec<String>>,
249}
250
251/// `[window]` block.
252#[derive(Debug, Clone, Default, Deserialize)]
253#[serde(default, deny_unknown_fields)]
254pub struct WindowCfg {
255    /// Window title override.
256    pub title: Option<String>,
257    /// `[w, h]` in logical pixels.
258    pub size: Option<[u32; 2]>,
259    /// When `true`, persists window position, size, and maximised state to `<state_dir>/<app-id>/window-state.toml` on close and restores from it on the next launch. Defaults to `false`.
260    pub remember_state: Option<bool>,
261}
262
263/// `[skin]` block.
264#[derive(Debug, Clone, Default, Deserialize)]
265#[serde(default, deny_unknown_fields)]
266pub struct SkinCfg {
267    /// Embedded skin name; equivalent to `<root skin="...">`.
268    pub name: Option<String>,
269}
270
271/// `[mcp]` block.
272#[derive(Debug, Clone, Default, Deserialize)]
273#[serde(default, deny_unknown_fields)]
274pub struct McpCfg {
275    /// TCP port for the introspection server. Absent means port 7878; `0`
276    /// disables the server.
277    pub port: Option<u16>,
278    /// When `true`, the MCP plugin drains the `SimulateQueue` each tick and injects pointer, key, and scroll events. Defaults to off.
279    pub simulate: Option<bool>,
280    /// When `true`, `lumen_framework_status` shells out to `git` and `gh` to
281    /// list the checkout's open GitHub issues. Defaults to off: the
282    /// introspection port has no authentication, so a shipped app leaves
283    /// subprocess execution off that surface unless a developer opts in.
284    pub issues: Option<bool>,
285}
286
287/// `[profile]` block.
288#[derive(Debug, Clone, Default, Deserialize)]
289#[serde(default, deny_unknown_fields)]
290pub struct ProfileCfg {
291    /// `off` | `chrome` | `tracy` | `stderr`. Default `off`.
292    pub mode: Option<String>,
293}
294
295/// `[asset_roots]` block.
296#[derive(Debug, Clone, Default, Deserialize)]
297#[serde(default, deny_unknown_fields)]
298pub struct AssetRootsCfg {
299    /// Extra directories (relative to the app dir, or absolute).
300    pub paths: Option<Vec<String>>,
301}
302
303/// `[script]` block - an override that forces every script in the app onto one
304/// language. Without it each script file belongs to the language its
305/// extension names, as the installed host modules declare it, and an app that
306/// ships more than one language runs one host per language. Set `engine` when
307/// the per-file answer is not the one you want, most often because the app
308/// keeps its script inline in the markup, where there is no extension to read.
309///
310/// ```toml
311/// [script]
312/// engine = "candela"
313/// ```
314#[derive(Debug, Clone, Default, Deserialize)]
315#[serde(default, deny_unknown_fields)]
316pub struct ScriptCfg {
317    /// The language name, as a host module registers it. A name no module
318    /// registers leaves the app's script unrun, with a banner that says so.
319    pub engine: Option<String>,
320}
321
322impl ScriptCfg {
323    /// The `engine` override, trimmed, when one is set.
324    pub fn engine(&self) -> Option<&str> {
325        self.engine
326            .as_deref()
327            .map(str::trim)
328            .filter(|e| !e.is_empty())
329    }
330}
331
332/// `[render]` block: which render backend draws the app.
333///
334/// ```toml
335/// [render]
336/// backend = "cpu"
337/// ```
338#[derive(Debug, Clone, Default, Deserialize)]
339#[serde(default, deny_unknown_fields)]
340pub struct RenderCfg {
341    /// The backend. `auto` (the default) tries the GPU and falls back to the
342    /// CPU when no GPU can start; `gpu` and `cpu` use that one backend and
343    /// fail the launch when it cannot start. A static package links only the
344    /// backends this selects.
345    pub backend: RenderBackendChoice,
346}
347
348/// The value of `[render] backend`.
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
350#[serde(rename_all = "lowercase")]
351pub enum RenderBackendChoice {
352    /// Every backend the binary carries, GPU first.
353    #[default]
354    Auto,
355    /// The GPU backend only.
356    Gpu,
357    /// The CPU backend only.
358    Cpu,
359}
360
361impl RenderBackendChoice {
362    /// The registered backend name this choice asks for, or `None` for
363    /// `auto`, which asks for every backend in priority order.
364    pub fn backend_name(self) -> Option<&'static str> {
365        match self {
366            RenderBackendChoice::Auto => None,
367            RenderBackendChoice::Gpu => Some("gpu"),
368            RenderBackendChoice::Cpu => Some("cpu"),
369        }
370    }
371}
372
373/// `[perf]` block carrying per-cache memory caps.
374#[derive(Debug, Clone, Default, Deserialize)]
375#[serde(default, deny_unknown_fields)]
376pub struct PerfCfg {
377    /// Image content cache cap in MB; defaults to 64.
378    pub images_mb: Option<u32>,
379    /// Text shape-cache cap in entries; defaults to 512.
380    pub shape_entries: Option<u32>,
381    /// Vello scene-fragment cache cap in entries; defaults to 256.
382    pub scene_fragments: Option<u32>,
383}
384
385/// `[runtime]` block - per-app overrides for the startup subsystem-gating
386/// (measured startup quick-wins). Every field is `Option`: `None` keeps the
387/// automatic behaviour (run-mode gating for MCP /
388/// hot-reload; `min(cores, 4)` for threads), while `Some(v)` forces it.
389///
390/// ```toml
391/// [runtime]
392/// mcp = false         # force the MCP server off (or true = on)
393/// hot_reload = false  # force the source watcher off (or true = on)
394/// threads = 2         # bevy_ecs worker-thread budget
395/// ```
396#[derive(Debug, Clone, Default, Deserialize)]
397#[serde(default, deny_unknown_fields)]
398pub struct RuntimeCfg {
399    /// Force the MCP introspection server on/off. `None` = on for an
400    /// interactive windowed run, off for a headless / bounded run (unless
401    /// `[mcp] simulate = true`). Interacts with `[mcp] port` (a `port = 0`
402    /// still hard-disables).
403    pub mcp: Option<bool>,
404    /// Force the hot-reload source watcher on/off. `None` = on only for an
405    /// interactive run from source (off for headless / bounded / artifact /
406    /// in-memory).
407    pub hot_reload: Option<bool>,
408    /// bevy_ecs worker-thread budget. `None` = `min(available_parallelism, 4)`.
409    /// Also overridable at runtime by the `LUMEN_THREADS` env var, which
410    /// wins over this value.
411    pub threads: Option<usize>,
412}
413
414/// `[capabilities]` block: which optional subsystems a build of this app
415/// carries, keyed by capability name (`os-tray`, `http-fetch`, `mcp`, ...).
416///
417/// Read by the builds that trim: `lumenc package`, which relinks the shared
418/// engine a folder package ships from the release's engine kit, `lumenc
419/// package --static`, which replays a link kit into one executable, and
420/// `lumenc bundle --static`, which compiles a runtime from source and maps the
421/// names it knows to cargo features. An entry settles its capability outright;
422/// a capability the table leaves out follows its own rule against the app's
423/// sources. A package that cannot relink its engine carries the full one and
424/// refuses a table that turns anything off. `lumenc run` carries everything
425/// and ignores the table. Script hosts come from the script files' extensions
426/// (or `[script] engine`), not from here.
427///
428/// ```toml
429/// [capabilities]
430/// http-fetch = false
431/// os-tray = true
432/// ```
433#[derive(Debug, Clone, Default, Deserialize)]
434#[serde(transparent)]
435pub struct CapabilitiesCfg(pub std::collections::BTreeMap<String, bool>);
436
437impl CapabilitiesCfg {
438    /// What the table says about the capability called `name`: `Some(true)`
439    /// to carry it, `Some(false)` to leave it out, `None` to let the build
440    /// decide from the app's sources.
441    pub fn get(&self, name: &str) -> Option<bool> {
442        self.0.get(name).copied()
443    }
444
445    /// Settle the capability called `name`.
446    pub fn set(&mut self, name: &str, on: bool) {
447        self.0.insert(name.to_string(), on);
448    }
449}
450
451/// The app's markup, scripts, styles and config read into one haystack, for
452/// a build that asks whether the app uses a subsystem. Bounded, so a large
453/// asset tree cannot turn the question into a slow directory crawl.
454pub fn app_sources(dir: &Path) -> String {
455    crate::run::subsystems::scan_app_sources(dir)
456}
457
458/// The resolved per-app capability set for a static `--bundle`, produced by
459/// merging explicit `[capabilities]` / `[runtime]` config with a conservative
460/// source scan, then mapped to the cargo `--features` list the trimmed runtime
461/// seam is built with.
462///
463/// CONSERVATIVE CONTRACT (mirrors [`crate::run::subsystems::SubsystemUsage`]):
464/// a capability is inferred OFF only on a reliable *unused* signal; anything
465/// ambiguous (a marker present, an unreadable dir) forces it ON. Explicit
466/// config always overrides inference.
467#[derive(Debug, Clone, PartialEq, Eq)]
468pub struct BundleCapabilities {
469    /// MCP introspection server present.
470    pub mcp: bool,
471    /// Async (tokio) bridge present.
472    pub async_rt: bool,
473    /// Scripts' HTTP `fetch()` builtin present.
474    pub http_fetch: bool,
475    /// Runtime-module loader present. Follows `[dependencies]` and the app's
476    /// scripts: any declared module keeps it, and so does a script, since the
477    /// host that runs it is a module. A build without the loader would drop the
478    /// modules in silence rather than with the load banner.
479    pub modules: bool,
480}
481
482impl BundleCapabilities {
483    /// Resolve the capability set for the app rooted at `dir` from `cfg` plus a
484    /// bounded source scan. Explicit `[capabilities]` wins, then inference.
485    pub fn resolve(dir: &Path, cfg: &LumenToml) -> Self {
486        let hay = crate::run::subsystems::scan_app_sources(dir);
487
488        // MCP: a dev/introspection capability, never inferred ON for a release
489        // bundle; only an explicit toggle pulls it in.
490        let mcp = cfg.capabilities.get("mcp").unwrap_or(false);
491
492        // Async: an app that opens dialogs must keep the capability, since
493        // that is what they resolve on. Same marker scan the runtime startup
494        // gate uses.
495        let async_rt = cfg.capabilities.get("async").unwrap_or_else(|| {
496            lumen_script::FILE_DIALOG_BUILTINS
497                .iter()
498                .any(|marker| hay.contains(marker))
499        });
500
501        // HTTP fetch: explicit, else infer from a `fetch(` builtin marker.
502        let http_fetch = cfg
503            .capabilities
504            .get("http-fetch")
505            .unwrap_or_else(|| hay.contains("fetch("));
506
507        // Modules: any declared `[dependencies]` entry keeps the loader in,
508        // so a missing module banners at startup instead of vanishing. A
509        // bundle is a desktop build, so its own target table counts too. A
510        // script keeps it as well: the host that runs one is a module, even
511        // when the bundle compiles it in.
512        let modules = hay.contains("<script")
513            || !cfg
514                .dependencies_for(lumen_modules::Target::Desktop)
515                .0
516                .is_empty();
517
518        Self {
519            mcp,
520            async_rt,
521            http_fetch,
522            modules,
523        }
524    }
525
526    /// The cargo `--features` list to compile the trimmed runtime seam with,
527    /// passed after `--no-default-features`. `runtime-parse` is intentionally
528    /// omitted: a `--bundle` runs from a precompiled AOT artifact, so the
529    /// source parser is dropped too.
530    pub fn to_features(&self) -> Vec<String> {
531        let mut f: Vec<String> = Vec::new();
532        if self.mcp {
533            f.push("mcp".into());
534        }
535        if self.async_rt {
536            f.push("async".into());
537        }
538        if self.http_fetch {
539            f.push("http-fetch".into());
540        }
541        if self.modules {
542            f.push("modules".into());
543        }
544        f
545    }
546}
547
548/// `[web]` block - how `lumenc web` emits the app as a static site.
549///
550/// The rest of `lumen.toml` still describes the app: `[window] title` is the
551/// documents' title, `[app] id` / `entry` / `locale`, `[pages]`,
552/// `[asset_roots]` and `[script] engine` all apply. This block covers what
553/// only a site has: where it is served from, what a crawler is told, and
554/// which locales it is emitted in.
555///
556/// `[capabilities]` does not apply. A desktop build compiles a runtime per
557/// app; a site loads one prebuilt runtime that ships with the toolchain, so
558/// there is nothing per-app to trim.
559///
560/// ```toml
561/// [web]
562/// out_dir   = "dist/web"       # where the site is written
563/// base_path = "/"              # URL prefix the site is served under
564/// url = "https://example.com"  # an address; canonical link + sitemap need one
565/// description = "A Lumen app"  # used by any page without its own
566/// locales = ["en-US", "de-DE"] # one tree per locale
567/// host = "netlify"             # also write that host's deep-path rewrite file
568/// render = "ssr"               # a document per request
569/// runtime = false              # and no browser runtime in it
570///
571/// [web.seed]
572/// count = 3                    # signal values the pages are rendered with
573///
574/// [web.pages.settings]
575/// title = "Settings"
576/// index = false                # and keep it out of a search index
577/// ```
578#[derive(Debug, Clone, Default, Deserialize)]
579#[serde(default, deny_unknown_fields)]
580pub struct WebCfg {
581    /// Directory the site is written to, relative to the app dir unless
582    /// absolute. Defaults to `dist/web`.
583    pub out_dir: Option<String>,
584    /// URL prefix the site is served under, such as `/` or `/docs/`. Every
585    /// link and asset reference in the documents hangs off it.
586    pub base_path: Option<String>,
587    /// Absolute site URL, such as `https://example.com`. The canonical link,
588    /// the social metadata, the `hreflang` links and the sitemap need an
589    /// address; without this or [`Self::canonical`] they are left out.
590    pub url: Option<String>,
591    /// Description used by any page that does not set its own.
592    pub description: Option<String>,
593    /// Image for social previews, relative to the site root or absolute.
594    pub og_image: Option<String>,
595    /// Absolute URL the pages declare as canonical, for a site published at
596    /// more than one address. Defaults to [`Self::url`].
597    pub canonical: Option<String>,
598    /// Every locale the site is emitted in, one document tree each. Absent
599    /// emits the default locale alone.
600    pub locales: Option<Vec<String>>,
601    /// The locale whose tree sits at the site root; the others sit under
602    /// `/<tag>/`. Defaults to `[app] locale`, then `en-US`.
603    pub default_locale: Option<String>,
604    /// Skin the site is styled with. Defaults to `[skin] name`, then
605    /// `default`. `auto` is not read here: it means "the machine's OS", and
606    /// a site is served to every OS.
607    pub skin: Option<String>,
608    /// How the pages are styled.
609    pub css: WebCssMode,
610    /// Which shape a widget the parser built out of smaller elements is
611    /// emitted as.
612    pub widgets: WebWidgets,
613    /// Where a page's document comes from.
614    pub render: WebRender,
615    /// Whether the documents carry the browser runtime. Unset takes what
616    /// [`Self::render`] implies, which is the only thing `static` and `csr`
617    /// differ about; `render = "ssr"` is the one that leaves it open.
618    pub runtime: Option<bool>,
619    /// Where the state the pages are rendered with comes from.
620    pub prerender: WebPrerender,
621    /// Add a content hash to asset file names, so a cache can hold them
622    /// forever. A build does not apply this yet.
623    pub hash_assets: Option<bool>,
624    /// Write the extra `data-lm-*` attributes that name what an element came
625    /// from. A build does not write them yet.
626    pub debug_attrs: Option<bool>,
627    /// What an app menu bar becomes in a document.
628    pub menubar: WebMenubar,
629    /// Write `sitemap.xml`: every page in every locale, each entry dated and
630    /// cross-linked to its other languages. Needs an address to build
631    /// absolute URLs from, [`Self::canonical`] else [`Self::url`], and is
632    /// written whenever the site has one.
633    pub sitemap: Option<bool>,
634    /// Write `robots.txt`, naming the sitemap. Defaults to on when a sitemap
635    /// is written.
636    pub robots: Option<bool>,
637    /// Host the site is deployed to, which decides the rewrite file that
638    /// makes a deep path serve the app instead of the host's own 404.
639    pub host: WebHost,
640    /// How a link to another page of the same site is followed.
641    pub navigation: WebNavigation,
642    /// `[web.seed]` - signal values every page is rendered with.
643    pub seed: BTreeMap<String, WebSeedValue>,
644    /// `[web.pages.<key>]` - what each page says about itself. A key naming
645    /// a page the app does not have is accepted and reaches nothing.
646    pub pages: BTreeMap<String, WebPageCfg>,
647    /// `[web.ssr]` - what a render of the app may reach, when its pages are
648    /// rendered per request.
649    pub ssr: WebSsrCfg,
650}
651
652/// `[web.ssr]` block - what the app allows a render to reach.
653///
654/// This is policy the app owns, so it lives with the app and a build writes
655/// it into the file a server renders from. Where the server listens and how
656/// many processes it runs are the server's settings, not these.
657///
658/// ```toml
659/// [web.ssr]
660/// allow_hosts = ["api.example.com"]  # hosts a render may fetch from
661/// max_requests = 8                   # requests one render may make
662/// headers = ["authorization"]        # credential headers the app may read
663/// ```
664#[derive(Debug, Clone, Default, Deserialize)]
665#[serde(default, deny_unknown_fields)]
666pub struct WebSsrCfg {
667    /// Hosts a render may ask for data, by name, without scheme or port.
668    /// Empty lets a render reach nothing. An entry carrying a scheme, a port,
669    /// a path or user information is a parse error, because the allowlist
670    /// matches the host alone and such an entry would match nothing.
671    #[serde(deserialize_with = "de_allow_hosts")]
672    pub allow_hosts: Vec<String>,
673    /// How many requests one render may make. Unset takes the renderer's
674    /// default.
675    pub max_requests: Option<usize>,
676    /// Request headers the app may read beyond the ones every render allows,
677    /// such as `authorization` or `cookie`. A name that is not an HTTP header
678    /// name is a parse error.
679    #[serde(deserialize_with = "de_header_names")]
680    pub headers: Vec<String>,
681}
682
683/// Parse `[web.ssr] allow_hosts`, refusing an entry that is more than a host.
684fn de_allow_hosts<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
685where
686    D: serde::Deserializer<'de>,
687{
688    let hosts = Vec::<String>::deserialize(deserializer)?;
689    for host in &hosts {
690        check_allow_host(host).map_err(serde::de::Error::custom)?;
691    }
692    Ok(hosts)
693}
694
695/// Why `entry` cannot be an `allow_hosts` entry, if it cannot.
696///
697/// A render's request is allowed when its URL's host equals an entry, so an
698/// entry is a bare host name or IP address: an IPv6 address is written
699/// without its brackets.
700fn check_allow_host(entry: &str) -> Result<(), String> {
701    let refuse = |what: &str| {
702        Err(format!(
703            "`{entry}` in [web.ssr] allow_hosts {what}; a render is allowed a host by name \
704             alone, such as `api.example.com`"
705        ))
706    };
707    if entry.trim().is_empty() {
708        return refuse("is empty");
709    }
710    if entry.contains("://") {
711        return refuse("carries a scheme");
712    }
713    if entry.contains(['/', '?', '#']) {
714        return refuse("carries a path");
715    }
716    if entry.contains('@') {
717        return refuse("carries user information");
718    }
719    if entry.contains(['[', ']']) {
720        return refuse("is bracketed; write an IPv6 address without its brackets");
721    }
722    if entry.contains(':') && entry.parse::<std::net::Ipv6Addr>().is_err() {
723        return refuse("carries a port");
724    }
725    if entry.contains(char::is_whitespace) {
726        return refuse("holds a space");
727    }
728    Ok(())
729}
730
731/// Parse `[web.ssr] headers`, refusing a name that is not an HTTP header
732/// name.
733fn de_header_names<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
734where
735    D: serde::Deserializer<'de>,
736{
737    let names = Vec::<String>::deserialize(deserializer)?;
738    for name in &names {
739        // RFC 9110 token characters.
740        let valid = !name.is_empty()
741            && name
742                .chars()
743                .all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c));
744        if !valid {
745            return Err(serde::de::Error::custom(format!(
746                "`{name}` in [web.ssr] headers is not a header name; name a request header, \
747                 such as `authorization`"
748            )));
749        }
750    }
751    Ok(names)
752}
753
754/// `[web.pages.<key>]` block - what one page says about itself.
755#[derive(Debug, Clone, Default, Deserialize)]
756#[serde(default, deny_unknown_fields)]
757pub struct WebPageCfg {
758    /// Title for this page. Falls back to `[window] title`.
759    pub title: Option<String>,
760    /// Description for this page. Falls back to `[web] description`.
761    pub description: Option<String>,
762    /// Whether a crawler is invited to index this page. Defaults to true.
763    pub index: Option<bool>,
764}
765
766/// One `[web.seed]` value.
767#[derive(Debug, Clone, PartialEq, Deserialize)]
768#[serde(untagged)]
769pub enum WebSeedValue {
770    /// A boolean. Ahead of the number arms: TOML tells the two apart, and
771    /// serde tries untagged arms in order.
772    Bool(bool),
773    /// A whole number.
774    Int(i64),
775    /// A number with a fraction.
776    Float(f64),
777    /// Text.
778    Str(String),
779    /// The rows of an array signal, written as an array of tables. This is
780    /// what puts a list in a page that nothing has run yet: a `<for>` over
781    /// this name is emitted with these rows in it.
782    Rows(Vec<BTreeMap<String, String>>),
783}
784
785/// `[web] css` - how a page's styling reaches the browser.
786#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
787#[serde(rename_all = "lowercase")]
788pub enum WebCssMode {
789    /// As a stylesheet, with the selectors, states and media queries the app
790    /// was written with.
791    #[default]
792    Sheet,
793    /// As the values Lumen's own cascade resolved, written onto each element
794    /// as an inline style. Nothing is left to match on, which is what makes
795    /// it the thing to compare the stylesheet against.
796    Computed,
797}
798
799/// `[web] widgets` - which shape a composed widget is emitted as.
800#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
801#[serde(rename_all = "lowercase")]
802pub enum WebWidgets {
803    /// As the HTML element that carries the same meaning, so a screen reader
804    /// and a crawler read a dropdown as a dropdown.
805    #[default]
806    Semantic,
807    /// As the elements the parser built the widget out of.
808    Verbatim,
809}
810
811/// `[web] render` - where a page's document comes from.
812///
813/// Every mode writes the whole markup tree, so a reader and a crawler get the
814/// same document whichever one is set. What changes is where that document is
815/// produced and what runs once it is open. [`WebPrerender`] is the other
816/// half, and says which state the markup is written with.
817///
818/// `static` and `csr` differ only in whether the document carries the runtime,
819/// which is why [`WebCfg::runtime`] is refused alongside either of them when
820/// it says the opposite. `ssr` leaves that question open, so it is the one
821/// that key answers.
822#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
823#[serde(rename_all = "lowercase")]
824pub enum WebRender {
825    /// Files and nothing else: no runtime, no scripts, links followed as the
826    /// browser follows any link. A directory to upload and leave alone.
827    Static,
828    /// The pages load the runtime, which adopts the markup they arrived with
829    /// and runs the app from there.
830    #[default]
831    Csr,
832    /// Each document is produced for the request that asks for it, by running
833    /// the app for that request. The build writes what a server needs instead
834    /// of the pages: the compiled app, the stylesheet, the assets, and the
835    /// runtime when the pages carry one.
836    Ssr,
837}
838
839/// `[web] prerender` - where the state a page is rendered with comes from.
840#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
841#[serde(rename_all = "lowercase")]
842pub enum WebPrerender {
843    /// The values `[web.seed]` and the markup itself declare.
844    #[default]
845    Seeds,
846    /// Run the app at build time and write each page with the state it
847    /// settles into, on top of the declared values.
848    Run,
849    /// None: a branch is not taken and a list has no rows until the browser
850    /// runs the app.
851    None,
852}
853
854/// `[web] menubar` - what an app menu bar becomes in a document.
855#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
856#[serde(rename_all = "lowercase")]
857pub enum WebMenubar {
858    /// Left out. A desktop menu bar is the window's, and a page has no
859    /// window.
860    #[default]
861    Omit,
862    /// A `<nav>` holding the top-level items.
863    Nav,
864}
865
866/// `[web] host` - where the site is deployed, and so which rewrite file lets
867/// a deep path reach the app.
868#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
869#[serde(rename_all = "lowercase")]
870pub enum WebHost {
871    /// A plain file server, which serves `404.html` for a path it has no
872    /// file for. That is what the emitted `404.html` is there for.
873    #[default]
874    Static,
875    /// Netlify: also write `_redirects`.
876    Netlify,
877    /// Vercel: also write `vercel.json`.
878    Vercel,
879    /// Apache: also write `.htaccess`.
880    Apache,
881    /// nginx: also write `nginx.conf`, to include from a server block.
882    Nginx,
883}
884
885/// `[web] navigation` - how a link to another page of the same site is
886/// followed.
887#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
888#[serde(rename_all = "lowercase")]
889pub enum WebNavigation {
890    /// The runtime intercepts the click and swaps the page in place.
891    #[default]
892    Soft,
893    /// The browser loads the target document.
894    Hard,
895}
896
897/// One `[[hooks]]` entry: an app-declared build/setup command.
898///
899/// ```toml
900/// [[hooks]]
901/// when    = "prebuild"
902/// os      = "linux"
903/// run     = "cc -shared -fPIC -O2 -o libmd.so md.c"
904/// inputs  = ["md.c"]
905/// outputs = ["libmd.so"]
906/// ```
907///
908/// `when` and `run` are required; `os`, `inputs`, and `outputs` are optional
909/// and default empty/absent. Validated at parse time by the custom
910/// [`Deserialize`] impl below, so a bad `when` / `os` value or an empty `run`
911/// surfaces as a `lumen.toml` parse error naming the offending value and the
912/// accepted set, rather than failing later at hook-run time.
913///
914/// Hooks execute arbitrary shell commands read from a file inside the app
915/// directory - the same trust model as a Cargo build script. `lumenc run
916/// <dir>` on an app from an untrusted source runs that app's hooks; pass
917/// `--no-hooks` to skip them.
918#[derive(Debug, Clone, PartialEq, Eq)]
919pub struct HookCfg {
920    /// Trigger point: `prebuild` or `prerun`.
921    pub when: HookWhen,
922    /// Restrict the hook to one OS. `None` runs on every platform.
923    pub os: Option<HookOs>,
924    /// The command line, run via `sh -c` (unix) or `cmd /C` (windows) with
925    /// the app directory as the child's cwd.
926    pub run: String,
927    /// Files the command reads, relative to the app directory unless
928    /// absolute. Used only for the staleness check; never passed to `run`.
929    pub inputs: Vec<String>,
930    /// Files the command produces, relative to the app directory unless
931    /// absolute. Used only for the staleness check.
932    pub outputs: Vec<String>,
933}
934
935/// `[[hooks]]` `when` value: the trigger point a hook fires at.
936#[derive(Debug, Clone, Copy, PartialEq, Eq)]
937pub enum HookWhen {
938    /// Fires for `lumenc build`, `lumenc bundle`, and `lumenc run` (before
939    /// `prerun`). The place to produce native artifacts a build or run needs.
940    Prebuild,
941    /// Fires for `lumenc run` only, after every `prebuild` hook has run.
942    Prerun,
943}
944
945/// Bare-string `"prebuild"` / `"prerun"` decoder for [`HookWhen`].
946impl TryFrom<&str> for HookWhen {
947    type Error = String;
948
949    fn try_from(s: &str) -> Result<Self, Self::Error> {
950        match s {
951            "prebuild" => Ok(HookWhen::Prebuild),
952            "prerun" => Ok(HookWhen::Prerun),
953            other => Err(format!(
954                "unknown hook `when` value `{other}` (expected one of: prebuild, prerun)"
955            )),
956        }
957    }
958}
959
960/// `[[hooks]]` `os` value: restricts a hook to one platform, matched against
961/// [`std::env::consts::OS`] (`"linux"`, `"macos"`, `"windows"`).
962#[derive(Debug, Clone, Copy, PartialEq, Eq)]
963pub enum HookOs {
964    /// Matches `std::env::consts::OS == "linux"`.
965    Linux,
966    /// Matches `std::env::consts::OS == "macos"`.
967    Macos,
968    /// Matches `std::env::consts::OS == "windows"`.
969    Windows,
970}
971
972/// Bare-string `"linux"` / `"macos"` / `"windows"` decoder for [`HookOs`].
973impl TryFrom<&str> for HookOs {
974    type Error = String;
975
976    fn try_from(s: &str) -> Result<Self, Self::Error> {
977        match s {
978            "linux" => Ok(HookOs::Linux),
979            "macos" => Ok(HookOs::Macos),
980            "windows" => Ok(HookOs::Windows),
981            other => Err(format!(
982                "unknown hook `os` value `{other}` (expected one of: linux, macos, windows)"
983            )),
984        }
985    }
986}
987
988/// Raw `[[hooks]]` table shape, deserialized field-by-field so [`HookCfg`]
989/// can validate `when` / `os` / `run` and produce a clear error naming the
990/// offending value.
991#[derive(Debug, Deserialize)]
992#[serde(deny_unknown_fields)]
993struct RawHookCfg {
994    when: String,
995    os: Option<String>,
996    run: String,
997    #[serde(default)]
998    inputs: Vec<String>,
999    #[serde(default)]
1000    outputs: Vec<String>,
1001}
1002
1003impl<'de> Deserialize<'de> for HookCfg {
1004    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1005    where
1006        D: serde::Deserializer<'de>,
1007    {
1008        let raw = RawHookCfg::deserialize(deserializer)?;
1009        let when = HookWhen::try_from(raw.when.as_str()).map_err(serde::de::Error::custom)?;
1010        let os = raw
1011            .os
1012            .as_deref()
1013            .map(HookOs::try_from)
1014            .transpose()
1015            .map_err(serde::de::Error::custom)?;
1016        if raw.run.trim().is_empty() {
1017            return Err(serde::de::Error::custom(
1018                "hooks: `run` must not be empty or whitespace-only",
1019            ));
1020        }
1021        Ok(HookCfg {
1022            when,
1023            os,
1024            run: raw.run,
1025            inputs: raw.inputs,
1026            outputs: raw.outputs,
1027        })
1028    }
1029}
1030
1031/// `[signals]` table - typed signal schema for the `lumenc lint
1032/// --signals` mode. Each entry maps a signal name to its expected
1033/// [`SignalType`]. Authors hand-write this; the lint subcommand
1034/// uses it to flag untyped writes and schema mismatches.
1035///
1036/// ```toml
1037/// [signals]
1038/// count = "i64"
1039/// theme = "string"
1040/// user = { name = "string", email = "string" }
1041///
1042/// [signals.users]
1043/// type = "array"
1044/// fields = { id = "i64", name = "string", email = "string" }
1045/// ```
1046#[derive(Debug, Clone, Default)]
1047pub struct SignalsCfg {
1048    /// Declared signal name -> type. Missing entries are not errors.
1049    pub fields: HashMap<String, SignalType>,
1050}
1051
1052/// Custom deserializer - accept a `[signals]` table whose values are
1053/// either bare-string type tokens (`"i64"`), nested inline tables
1054/// (`{ name = "string" }` -> struct), or explicit `{ type = "array",
1055/// fields = { ... } }` records.
1056impl<'de> Deserialize<'de> for SignalsCfg {
1057    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1058    where
1059        D: serde::Deserializer<'de>,
1060    {
1061        let raw: HashMap<String, toml::Value> = HashMap::deserialize(deserializer)?;
1062        let mut fields = HashMap::with_capacity(raw.len());
1063        for (k, v) in raw {
1064            let ty = SignalType::try_from(&v).map_err(serde::de::Error::custom)?;
1065            fields.insert(k, ty);
1066        }
1067        Ok(SignalsCfg { fields })
1068    }
1069}
1070
1071/// Declared signal type for a [`SignalsCfg`] entry.
1072///
1073/// The scalar variants (`I64`, `F64`, `Bool`, `Str`, `Color`, `Vec2`)
1074/// mirror the four PropertyValue variants Lumen's PropertyStore models
1075/// natively plus `string` for unrestricted strings. `Object` and
1076/// `Array` carry their own field schemas so nested map/array signals
1077/// can be typed at the leaf.
1078#[derive(Debug, Clone, PartialEq)]
1079pub enum SignalType {
1080    /// `signal_set_int` target - `PropertyValue::I64`.
1081    I64,
1082    /// `signal_set_float` target - `PropertyValue::F64`.
1083    F64,
1084    /// `signal_set_bool` target - `PropertyValue::Bool`.
1085    Bool,
1086    /// `signal_set` target - `PropertyValue::Str`. `signal_set` has no
1087    /// dedicated `signal_set_string` variant; it is already the typed
1088    /// sink for a `string`-declared signal.
1089    Str,
1090    /// `signal_set_color` target - `#rrggbb` / `#rrggbbaa`.
1091    Color,
1092    /// `vec2(x, y)` typed signal.
1093    Vec2,
1094    /// Array of typed records.
1095    Array {
1096        /// Per-field types of each array element record.
1097        fields: HashMap<String, SignalType>,
1098    },
1099    /// Inline object / map record.
1100    Object {
1101        /// Per-field types of the record.
1102        fields: HashMap<String, SignalType>,
1103    },
1104}
1105
1106/// Bare-string `"i64"` / `"f64"` / `"bool"` / `"string"` / `"color"` /
1107/// `"vec2"` decoder for [`SignalType`].
1108impl TryFrom<&str> for SignalType {
1109    type Error = String;
1110
1111    fn try_from(s: &str) -> Result<Self, Self::Error> {
1112        match s {
1113            "i64" | "int" | "integer" => Ok(SignalType::I64),
1114            "f64" | "float" | "number" => Ok(SignalType::F64),
1115            "bool" | "boolean" => Ok(SignalType::Bool),
1116            "string" | "str" | "text" => Ok(SignalType::Str),
1117            "color" => Ok(SignalType::Color),
1118            "vec2" => Ok(SignalType::Vec2),
1119            "array" => Ok(SignalType::Array {
1120                fields: HashMap::new(),
1121            }),
1122            "object" | "map" => Ok(SignalType::Object {
1123                fields: HashMap::new(),
1124            }),
1125            other => Err(format!(
1126                "unknown signal type `{other}` (expected one of: i64, f64, bool, string, color, vec2, array, object)"
1127            )),
1128        }
1129    }
1130}
1131
1132/// Parse a `toml::Value` into a [`SignalType`]. Accepts:
1133/// - bare strings -> scalar variant
1134/// - inline tables with `type = "array"` + `fields = { ... }` -> `Array`
1135/// - inline tables with `type = "object"` + `fields = { ... }` -> `Object`
1136/// - inline tables without a `type` key -> `Object` (treat the whole
1137///   table as the field map).
1138impl TryFrom<&toml::Value> for SignalType {
1139    type Error = String;
1140
1141    fn try_from(v: &toml::Value) -> Result<Self, Self::Error> {
1142        match v {
1143            toml::Value::String(s) => SignalType::try_from(s.as_str()),
1144            toml::Value::Table(t) => {
1145                let ty = t.get("type").and_then(|x| x.as_str()).unwrap_or("object");
1146                let fields_map = t.get("fields").and_then(|x| x.as_table());
1147                let parse_fields =
1148                    |table: &toml::value::Table| -> Result<HashMap<String, SignalType>, String> {
1149                        let mut out = HashMap::with_capacity(table.len());
1150                        for (k, vv) in table {
1151                            out.insert(k.clone(), SignalType::try_from(vv)?);
1152                        }
1153                        Ok(out)
1154                    };
1155                match ty {
1156                    "array" => {
1157                        let fields = if let Some(f) = fields_map {
1158                            parse_fields(f)?
1159                        } else {
1160                            HashMap::new()
1161                        };
1162                        Ok(SignalType::Array { fields })
1163                    }
1164                    "object" | "map" => {
1165                        let fields = if let Some(f) = fields_map {
1166                            parse_fields(f)?
1167                        } else {
1168                            // No nested `fields` key -> treat all
1169                            // top-level entries (minus `type`) as the
1170                            // field map.
1171                            let mut tmp = toml::value::Table::new();
1172                            for (k, vv) in t {
1173                                if k == "type" {
1174                                    continue;
1175                                }
1176                                tmp.insert(k.clone(), vv.clone());
1177                            }
1178                            parse_fields(&tmp)?
1179                        };
1180                        Ok(SignalType::Object { fields })
1181                    }
1182                    other => Err(format!(
1183                        "unknown signal table type `{other}` (expected `array` or `object`)"
1184                    )),
1185                }
1186            }
1187            _ => Err(format!(
1188                "signal entry must be a type string or table, got {v:?}"
1189            )),
1190        }
1191    }
1192}
1193
1194/// Errors returned by [`LumenToml::load_or_default`].
1195#[derive(Debug, thiserror::Error)]
1196pub enum ConfigError {
1197    /// `lumen.toml` exists but could not be read.
1198    #[error("read {0}: {1}")]
1199    Read(PathBuf, std::io::Error),
1200    /// `lumen.toml` failed to parse as TOML.
1201    #[error("parse {0}: {1}")]
1202    Parse(PathBuf, toml::de::Error),
1203}
1204
1205impl LumenToml {
1206    /// Loads `<dir>/lumen.toml` when present and returns the parsed config; returns [`Self::default`] when the file is absent.
1207    pub fn load_or_default(dir: &Path) -> Result<Self, ConfigError> {
1208        let path = dir.join("lumen.toml");
1209        match std::fs::read_to_string(&path) {
1210            Ok(src) => {
1211                let mut cfg = toml::from_str::<Self>(&src)
1212                    .map_err(|e| ConfigError::Parse(path.clone(), e))?;
1213                cfg.raw = toml::from_str(&src).map_err(|e| ConfigError::Parse(path, e))?;
1214                Ok(cfg)
1215            }
1216            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
1217            Err(e) => Err(ConfigError::Read(path, e)),
1218        }
1219    }
1220
1221    /// The dependencies a build for `target` takes: `[dependencies]`, with
1222    /// `[target.<name>.dependencies]` laid over it entry by entry.
1223    pub fn dependencies_for(
1224        &self,
1225        target: lumen_modules::Target,
1226    ) -> lumen_modules::DependenciesCfg {
1227        self.target.dependencies_for(&self.dependencies, target)
1228    }
1229
1230    /// Returns `[asset_roots].paths` resolved against `dir`, leaving absolute entries unchanged and joining relative entries onto `dir`.
1231    pub fn resolved_asset_roots(&self, dir: &Path) -> Vec<PathBuf> {
1232        self.asset_roots
1233            .paths
1234            .as_ref()
1235            .map(|v| {
1236                v.iter()
1237                    .map(|p| {
1238                        let pp = Path::new(p);
1239                        if pp.is_absolute() {
1240                            pp.to_path_buf()
1241                        } else {
1242                            dir.join(pp)
1243                        }
1244                    })
1245                    .collect()
1246            })
1247            .unwrap_or_default()
1248    }
1249}
1250
1251#[cfg(test)]
1252mod tests {
1253    use super::*;
1254
1255    #[test]
1256    fn missing_file_is_default() {
1257        let tmp = std::env::temp_dir().join("lumenc-cfg-missing");
1258        std::fs::create_dir_all(&tmp).unwrap();
1259        let cfg = LumenToml::load_or_default(&tmp).unwrap();
1260        assert!(cfg.app.entry.is_none());
1261        assert!(cfg.window.size.is_none());
1262    }
1263
1264    #[test]
1265    fn signals_table_parses_scalars_and_nested() {
1266        let src = r#"
1267            [signals]
1268            count = "i64"
1269            theme = "string"
1270            ratio = "f64"
1271            dark  = "bool"
1272            tint  = "color"
1273            user  = { name = "string", email = "string" }
1274
1275            [signals.users]
1276            type = "array"
1277            fields = { id = "i64", name = "string", email = "string" }
1278        "#;
1279        let cfg: LumenToml = toml::from_str(src).unwrap();
1280        let sigs = &cfg.signals.fields;
1281        assert_eq!(sigs.get("count"), Some(&SignalType::I64));
1282        assert_eq!(sigs.get("theme"), Some(&SignalType::Str));
1283        assert_eq!(sigs.get("ratio"), Some(&SignalType::F64));
1284        assert_eq!(sigs.get("dark"), Some(&SignalType::Bool));
1285        assert_eq!(sigs.get("tint"), Some(&SignalType::Color));
1286        match sigs.get("user") {
1287            Some(SignalType::Object { fields }) => {
1288                assert_eq!(fields.get("name"), Some(&SignalType::Str));
1289                assert_eq!(fields.get("email"), Some(&SignalType::Str));
1290            }
1291            other => panic!("expected user=Object, got {other:?}"),
1292        }
1293        match sigs.get("users") {
1294            Some(SignalType::Array { fields }) => {
1295                assert_eq!(fields.get("id"), Some(&SignalType::I64));
1296                assert_eq!(fields.get("name"), Some(&SignalType::Str));
1297            }
1298            other => panic!("expected users=Array, got {other:?}"),
1299        }
1300    }
1301
1302    #[test]
1303    fn signals_table_rejects_unknown_scalar() {
1304        let src = r#"
1305            [signals]
1306            count = "nope"
1307        "#;
1308        let res: Result<LumenToml, _> = toml::from_str(src);
1309        assert!(res.is_err());
1310    }
1311
1312    #[test]
1313    fn the_render_backend_defaults_to_auto_and_rejects_unknown_names() {
1314        let absent: LumenToml = toml::from_str("").unwrap();
1315        assert_eq!(absent.render.backend, RenderBackendChoice::Auto);
1316        assert_eq!(absent.render.backend.backend_name(), None);
1317        let cpu: LumenToml = toml::from_str("[render]\nbackend = \"cpu\"\n").unwrap();
1318        assert_eq!(cpu.render.backend.backend_name(), Some("cpu"));
1319        let gpu: LumenToml = toml::from_str("[render]\nbackend = \"gpu\"\n").unwrap();
1320        assert_eq!(gpu.render.backend.backend_name(), Some("gpu"));
1321        assert!(toml::from_str::<LumenToml>("[render]\nbackend = \"vulkan\"\n").is_err());
1322        assert!(toml::from_str::<LumenToml>("[render]\nthreads = 4\n").is_err());
1323    }
1324
1325    #[test]
1326    fn the_script_engine_key_is_a_free_language_name() {
1327        let cfg: LumenToml = toml::from_str("").unwrap();
1328        assert_eq!(cfg.script.engine(), None);
1329        let cfg: LumenToml = toml::from_str("[script]\nengine = \" candela \"\n").unwrap();
1330        assert_eq!(cfg.script.engine(), Some("candela"));
1331        let cfg: LumenToml = toml::from_str("[script]\nengine = \"prolog\"\n").unwrap();
1332        assert_eq!(cfg.script.engine(), Some("prolog"));
1333    }
1334
1335    #[test]
1336    fn capabilities_infer_and_override() {
1337        let dir = std::env::temp_dir().join(format!(
1338            "lumen_caps_{}_{}",
1339            std::process::id(),
1340            std::time::SystemTime::now()
1341                .duration_since(std::time::UNIX_EPOCH)
1342                .unwrap()
1343                .as_nanos()
1344        ));
1345        // The app's code, and so every marker the scan reads, is under `src/`.
1346        let src = dir.join("src");
1347        std::fs::create_dir_all(&src).unwrap();
1348
1349        // Bare UI app: http-fetch inferred OFF; mcp/async default OFF; no
1350        // script and no dependency, so no module loader either.
1351        std::fs::write(
1352            src.join("main.lmn"),
1353            "<root><button id=\"inc\">+</button></root>",
1354        )
1355        .unwrap();
1356        let cfg = LumenToml::default();
1357        let caps = BundleCapabilities::resolve(&dir, &cfg);
1358        assert!(!caps.http_fetch && !caps.mcp && !caps.async_rt && !caps.modules);
1359        assert!(caps.to_features().is_empty());
1360
1361        // A script keeps the loader: its host is a module.
1362        std::fs::write(
1363            src.join("main.lmn"),
1364            "<root><button id=\"inc\">+</button><script src=\"app.cdl\"/></root>",
1365        )
1366        .unwrap();
1367        assert!(BundleCapabilities::resolve(&dir, &cfg).modules);
1368
1369        // A fetch marker flips inference ON.
1370        std::fs::write(
1371            src.join("app.cdl"),
1372            "fn f() { lumen::fetch(\"http://h\", \"t\"); }",
1373        )
1374        .unwrap();
1375        let caps = BundleCapabilities::resolve(&dir, &cfg);
1376        assert!(caps.http_fetch);
1377        let feats = caps.to_features();
1378        assert!(feats.contains(&"http-fetch".to_string()));
1379
1380        // Explicit [capabilities] http-fetch = false overrides the marker.
1381        let mut cfg2 = LumenToml::default();
1382        cfg2.capabilities.set("http-fetch", false);
1383        assert!(!BundleCapabilities::resolve(&dir, &cfg2).http_fetch);
1384
1385        // A file-dialog builtin keeps the async runtime in the bundle: on
1386        // macOS it is the only path that opens a dialog at all.
1387        std::fs::write(
1388            src.join("dialogs.cdl"),
1389            "fn f() { lumen::pick_file(\"import\"); }",
1390        )
1391        .unwrap();
1392        let caps = BundleCapabilities::resolve(&dir, &cfg);
1393        assert!(caps.async_rt);
1394        assert!(caps.to_features().contains(&"async".to_string()));
1395
1396        // Explicit [capabilities] async = false still overrides the marker.
1397        let mut cfg_no_async = LumenToml::default();
1398        cfg_no_async.capabilities.set("async", false);
1399        assert!(!BundleCapabilities::resolve(&dir, &cfg_no_async).async_rt);
1400        std::fs::remove_file(src.join("dialogs.cdl")).unwrap();
1401
1402        let _ = std::fs::remove_dir_all(&dir);
1403    }
1404
1405    #[test]
1406    fn a_target_table_is_laid_over_the_dependencies_for_its_builds() {
1407        use lumen_modules::Target;
1408        let src = r#"
1409            [dependencies]
1410            shared = { path = "a" }
1411
1412            [target.web.dependencies]
1413            chart = { path = "addons/chart" }
1414
1415            [target.desktop.dependencies]
1416            shapes = { path = "modules/shapes" }
1417        "#;
1418        let cfg: LumenToml = toml::from_str(src).unwrap();
1419        let names = |target| -> Vec<String> {
1420            cfg.dependencies_for(target)
1421                .0
1422                .into_iter()
1423                .map(|dep| dep.name)
1424                .collect()
1425        };
1426        assert_eq!(names(Target::Web), ["chart", "shared"]);
1427        assert_eq!(names(Target::Desktop), ["shapes", "shared"]);
1428        // A bundle is a desktop build, so the desktop table keeps the loader.
1429        let cfg: LumenToml =
1430            toml::from_str("[target.desktop.dependencies]\nshapes = { path = \"m\" }\n").unwrap();
1431        let dir = std::env::temp_dir().join(format!("lumen-cfg-target-{}", std::process::id()));
1432        std::fs::create_dir_all(&dir).unwrap();
1433        assert!(BundleCapabilities::resolve(&dir, &cfg).modules);
1434        let _ = std::fs::remove_dir_all(&dir);
1435    }
1436
1437    #[test]
1438    fn dependencies_table_parses_and_keeps_the_modules_capability() {
1439        use lumen_modules::ModuleSource;
1440        let src = r#"
1441            [dependencies]
1442            zeta = "1.2"
1443            alpha = { path = "modules/alpha", config = { units = "mm" } }
1444        "#;
1445        let cfg: LumenToml = toml::from_str(src).unwrap();
1446        // Sorted by name: the table is unordered, so sorted order is the
1447        // documented load order.
1448        assert_eq!(cfg.dependencies.0.len(), 2);
1449        assert_eq!(cfg.dependencies.0[0].name, "alpha");
1450        assert_eq!(
1451            cfg.dependencies.0[0].source,
1452            ModuleSource::Path("modules/alpha".into())
1453        );
1454        assert_eq!(cfg.dependencies.0[1].name, "zeta");
1455
1456        // Declared dependencies keep the loader in a bundle.
1457        let dir = std::env::temp_dir().join("lumen-deps-caps-test");
1458        std::fs::create_dir_all(&dir).unwrap();
1459        let caps = BundleCapabilities::resolve(&dir, &cfg);
1460        assert!(caps.modules);
1461        assert!(caps.to_features().contains(&"modules".to_string()));
1462
1463        // No dependencies, no loader.
1464        let caps = BundleCapabilities::resolve(&dir, &LumenToml::default());
1465        assert!(!caps.modules);
1466        assert!(!caps.to_features().contains(&"modules".to_string()));
1467    }
1468
1469    #[test]
1470    fn capabilities_table_parses() {
1471        let src = r#"
1472            [capabilities]
1473            http-fetch = true
1474            mcp = false
1475            async = true
1476        "#;
1477        let cfg: LumenToml = toml::from_str(src).unwrap();
1478        assert_eq!(cfg.capabilities.get("http-fetch"), Some(true));
1479        assert_eq!(cfg.capabilities.get("mcp"), Some(false));
1480        assert_eq!(cfg.capabilities.get("async"), Some(true));
1481    }
1482
1483    #[test]
1484    fn hooks_table_parses() {
1485        let src = r#"
1486            [[hooks]]
1487            when    = "prebuild"
1488            os      = "linux"
1489            run     = "cc -shared -fPIC -O2 -o libmd.so md.c"
1490            inputs  = ["md.c"]
1491            outputs = ["libmd.so"]
1492
1493            [[hooks]]
1494            when = "prerun"
1495            run  = "echo starting"
1496        "#;
1497        let cfg: LumenToml = toml::from_str(src).unwrap();
1498        assert_eq!(cfg.hooks.len(), 2);
1499        let first = &cfg.hooks[0];
1500        assert_eq!(first.when, HookWhen::Prebuild);
1501        assert_eq!(first.os, Some(HookOs::Linux));
1502        assert_eq!(first.run, "cc -shared -fPIC -O2 -o libmd.so md.c");
1503        assert_eq!(first.inputs, vec!["md.c".to_string()]);
1504        assert_eq!(first.outputs, vec!["libmd.so".to_string()]);
1505        let second = &cfg.hooks[1];
1506        assert_eq!(second.when, HookWhen::Prerun);
1507        assert_eq!(second.os, None);
1508        assert!(second.inputs.is_empty());
1509        assert!(second.outputs.is_empty());
1510    }
1511
1512    #[test]
1513    fn hooks_reject_unknown_when() {
1514        let src = r#"
1515            [[hooks]]
1516            when = "postbuild"
1517            run  = "true"
1518        "#;
1519        let err = toml::from_str::<LumenToml>(src).unwrap_err();
1520        assert!(err.to_string().contains("postbuild"));
1521    }
1522
1523    #[test]
1524    fn hooks_reject_unknown_os() {
1525        let src = r#"
1526            [[hooks]]
1527            when = "prebuild"
1528            os   = "plan9"
1529            run  = "true"
1530        "#;
1531        let err = toml::from_str::<LumenToml>(src).unwrap_err();
1532        assert!(err.to_string().contains("plan9"));
1533    }
1534
1535    #[test]
1536    fn hooks_reject_empty_run() {
1537        let src = r#"
1538            [[hooks]]
1539            when = "prebuild"
1540            run  = "   "
1541        "#;
1542        assert!(toml::from_str::<LumenToml>(src).is_err());
1543    }
1544
1545    #[test]
1546    fn app_kind_override_parses() {
1547        use crate::app_kind::AppKind;
1548        // Absent -> None (auto-detect decides at runtime).
1549        let cfg: LumenToml = toml::from_str("[app]\nentry = \"main.lmn\"\n").unwrap();
1550        assert_eq!(cfg.app.kind, None);
1551        // Explicit overrides, one per variant (serde lowercase rename).
1552        let cfg: LumenToml = toml::from_str("[app]\nkind = \"rust\"\n").unwrap();
1553        assert_eq!(cfg.app.kind, Some(AppKind::Rust));
1554        let cfg: LumenToml = toml::from_str("[app]\nkind = \"cpp\"\n").unwrap();
1555        assert_eq!(cfg.app.kind, Some(AppKind::Cpp));
1556        let cfg: LumenToml = toml::from_str("[app]\nkind = \"python\"\n").unwrap();
1557        assert_eq!(cfg.app.kind, Some(AppKind::Python));
1558        let cfg: LumenToml = toml::from_str("[app]\nkind = \"markup\"\n").unwrap();
1559        assert_eq!(cfg.app.kind, Some(AppKind::Markup));
1560        // Unknown value is rejected (deny_unknown_fields-style strictness on
1561        // the enum discriminant).
1562        assert!(toml::from_str::<LumenToml>("[app]\nkind = \"go\"\n").is_err());
1563    }
1564
1565    #[test]
1566    fn app_locale_parses_and_rejects_garbage() {
1567        // Absent -> follow the OS.
1568        let cfg: LumenToml = toml::from_str("[app]\nentry = \"main.lmn\"\n").unwrap();
1569        assert!(cfg.app.locale.is_none());
1570        // Valid BCP-47 lands parsed.
1571        let cfg: LumenToml = toml::from_str("[app]\nlocale = \"de-DE\"\n").unwrap();
1572        let locale = cfg.app.locale.expect("locale parsed");
1573        assert_eq!(locale.language.as_str(), "de");
1574        assert_eq!(
1575            locale.region.map(|r| r.as_str().to_string()),
1576            Some("DE".into())
1577        );
1578        // A typo names itself in the error.
1579        let err = toml::from_str::<LumenToml>("[app]\nlocale = \"not a tag\"\n").unwrap_err();
1580        assert!(err.to_string().contains("locale"), "{err}");
1581    }
1582
1583    #[test]
1584    fn app_fallback_locale_parses_and_names_itself_in_errors() {
1585        // Absent -> en-US, resolved where the plugin is built.
1586        let cfg: LumenToml = toml::from_str("[app]\nlocale = \"fr-FR\"\n").unwrap();
1587        assert!(cfg.app.fallback_locale.is_none());
1588        let cfg: LumenToml = toml::from_str("[app]\nfallback_locale = \"de-DE\"\n").unwrap();
1589        let fallback = cfg.app.fallback_locale.expect("fallback_locale parsed");
1590        assert_eq!(fallback.language.as_str(), "de");
1591        // The error names the key the author mistyped, not the other one.
1592        let err =
1593            toml::from_str::<LumenToml>("[app]\nfallback_locale = \"not a tag\"\n").unwrap_err();
1594        assert!(err.to_string().contains("fallback_locale"), "{err}");
1595    }
1596
1597    #[test]
1598    fn web_ssr_names_what_a_render_may_reach() {
1599        let cfg: LumenToml = toml::from_str(
1600            "[web.ssr]\nallow_hosts = [\"api.example.com\"]\nmax_requests = 3\n\
1601             headers = [\"authorization\"]\n",
1602        )
1603        .unwrap();
1604        assert_eq!(cfg.web.ssr.allow_hosts, ["api.example.com"]);
1605        assert_eq!(cfg.web.ssr.max_requests, Some(3));
1606        assert_eq!(cfg.web.ssr.headers, ["authorization"]);
1607        // Absent reaches nothing.
1608        let cfg: LumenToml = toml::from_str("").unwrap();
1609        assert!(cfg.web.ssr.allow_hosts.is_empty());
1610        assert!(toml::from_str::<LumenToml>("[web.ssr]\nport = 80\n").is_err());
1611    }
1612
1613    #[test]
1614    fn web_ssr_refuses_an_entry_the_policy_would_never_match() {
1615        for host in ["api.example.com", "10.0.0.2", "::1", "localhost"] {
1616            let src = format!("[web.ssr]\nallow_hosts = [\"{host}\"]\n");
1617            assert!(toml::from_str::<LumenToml>(&src).is_ok(), "{host}");
1618        }
1619        for (host, why) in [
1620            ("https://api.example.com", "scheme"),
1621            ("api.example.com:8443", "port"),
1622            ("api.example.com/v1", "path"),
1623            ("user@api.example.com", "user"),
1624            ("[::1]", "bracket"),
1625            ("", "empty"),
1626        ] {
1627            let src = format!("[web.ssr]\nallow_hosts = [\"{host}\"]\n");
1628            let err = toml::from_str::<LumenToml>(&src).unwrap_err();
1629            assert!(err.to_string().contains(why), "{host}: {err}");
1630        }
1631        assert!(toml::from_str::<LumenToml>("[web.ssr]\nheaders = [\"x-api-key\"]\n").is_ok());
1632        for name in ["", "x api key", "authorization:", "caf\u{e9}"] {
1633            let src = format!("[web.ssr]\nheaders = [\"{name}\"]\n");
1634            let err = toml::from_str::<LumenToml>(&src).unwrap_err();
1635            assert!(
1636                err.to_string().contains("not a header name"),
1637                "{name}: {err}"
1638            );
1639        }
1640    }
1641
1642    #[test]
1643    fn full_config_round_trips() {
1644        let src = r#"
1645            [app]
1646            entry = "alt.lmn"
1647
1648            [window]
1649            title = "Demo"
1650            size = [1280, 720]
1651
1652            [skin]
1653            name = "default"
1654
1655            [mcp]
1656            port = 9090
1657
1658            [profile]
1659            mode = "chrome"
1660
1661            [asset_roots]
1662            paths = ["icons", "../shared"]
1663        "#;
1664        let cfg: LumenToml = toml::from_str(src).unwrap();
1665        assert_eq!(cfg.app.entry.as_deref(), Some("alt.lmn"));
1666        assert_eq!(cfg.window.size, Some([1280, 720]));
1667        assert_eq!(cfg.skin.name.as_deref(), Some("default"));
1668        assert_eq!(cfg.mcp.port, Some(9090));
1669        assert_eq!(cfg.profile.mode.as_deref(), Some("chrome"));
1670        assert_eq!(cfg.asset_roots.paths.as_ref().unwrap().len(), 2);
1671    }
1672}