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}