Skip to main content

laterite_admin/
bootstrap.rs

1//! Application bootstrap: one call to load config, connect, migrate, build the
2//! admin router, and serve it.
3//!
4//! [`Bootstrap`] keeps an application's `main` small and stable: framework
5//! internals (config fields, the migration runner, router assembly) change here,
6//! not in every app. An app registers its own modules; each module supplies its
7//! migrations and admin surfaces (resources, permissions, settings) from its
8//! `register`, and the app adds extra routes via [`Bootstrap::extend`].
9
10use std::collections::{HashMap, HashSet};
11use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe};
12use std::path::PathBuf;
13
14use axum::Router;
15use laterite_auth::{AuthConfig, AuthService};
16use laterite_core::config::{self, AppMeta, BackendConfig, DatabaseConfig, ServerConfig};
17use laterite_core::{CapabilitySet, Db, Module, ModuleRegistry, Registry, Translator};
18use serde::Deserialize;
19
20use crate::settings::SettingsItem;
21use crate::{builtin_modules, normalize_path, router, AdminConfig, Permission, Resource};
22
23/// Default env-var prefix for config overrides (`LAT__SECTION__KEY`). An app
24/// declares its own as `app.env_prefix` in its config, which `Bootstrap` and the
25/// `lat` command both read; [`Bootstrap::env_prefix`] overrides even that.
26pub const DEFAULT_ENV_PREFIX: &str = "LAT";
27
28/// The env-var prefix to load config under: a code override, else the config's
29/// own `app.env_prefix`, else [`DEFAULT_ENV_PREFIX`].
30fn resolve_env_prefix(code: Option<String>, declared: Option<String>) -> String {
31    code.or(declared)
32        .unwrap_or_else(|| DEFAULT_ENV_PREFIX.to_string())
33}
34
35#[cfg(test)]
36mod env_prefix_tests {
37    use super::resolve_env_prefix;
38
39    #[test]
40    fn code_override_then_declaration_then_default() {
41        assert_eq!(resolve_env_prefix(None, None), "LAT");
42        assert_eq!(resolve_env_prefix(None, Some("ACME".into())), "ACME");
43        assert_eq!(
44            resolve_env_prefix(Some("CODE".into()), Some("ACME".into())),
45            "CODE"
46        );
47    }
48}
49
50/// The configuration [`Bootstrap`] loads. An app needing more loads its own with
51/// [`laterite_core::config::load`].
52#[derive(Debug, Clone, Deserialize)]
53pub struct AppConfig {
54    #[serde(default)]
55    pub app: AppMeta,
56    pub server: ServerConfig,
57    pub database: DatabaseConfig,
58    #[serde(default)]
59    pub auth: AuthConfig,
60    #[serde(default)]
61    pub backend: BackendConfig,
62    #[serde(default)]
63    pub plugins: PluginsConfig,
64}
65
66/// Deployment control over which plugins load. A disabled plugin's migrations do
67/// not run and it contributes nothing; any plugin that `requires` a disabled one
68/// is skipped in turn. Built-in modules cannot be disabled.
69#[derive(Debug, Clone, Deserialize, Default)]
70pub struct PluginsConfig {
71    /// Module ids to disable at boot, e.g. `disabled = ["rainmill.location"]`.
72    #[serde(default)]
73    pub disabled: Vec<String>,
74}
75
76/// Passed to [`Bootstrap::extend`] so an app's own routes can share the database
77/// and the detected database capabilities.
78pub struct BootstrapCtx {
79    db: Db,
80    capabilities: CapabilitySet,
81    translator: Translator,
82    events: laterite_core::Events,
83}
84
85impl BootstrapCtx {
86    /// The connected database, for an app's routes to build their state on.
87    pub fn db(&self) -> Db {
88        self.db.clone()
89    }
90
91    /// The database capabilities available on this deployment, for gating
92    /// optional features.
93    pub fn capabilities(&self) -> &CapabilitySet {
94        &self.capabilities
95    }
96
97    /// The UI-string translator for the configured locale, for an app's routes
98    /// to resolve their own strings.
99    pub fn translator(&self) -> &Translator {
100        &self.translator
101    }
102
103    /// The application's event bus, with every module's listeners on it, for an
104    /// app's routes to announce on.
105    pub fn events(&self) -> &laterite_core::Events {
106        &self.events
107    }
108}
109
110type ExtendFn = Box<dyn FnOnce(Router, &BootstrapCtx) -> Router>;
111
112/// Builds and serves a Laterite application.
113pub struct Bootstrap {
114    config_dir: PathBuf,
115    env_prefix: Option<String>,
116    app_modules: Vec<Box<dyn Module>>,
117    plugin_modules: Vec<Box<dyn Module>>,
118    extend: Option<ExtendFn>,
119}
120
121impl Bootstrap {
122    /// Loads config from `config_dir` (`default.toml`, the `APP_ENV` overlay, and
123    /// `local.toml`). Env vars override under the prefix the config declares as
124    /// `app.env_prefix`, else [`DEFAULT_ENV_PREFIX`]; [`Bootstrap::env_prefix`]
125    /// overrides both.
126    pub fn new(config_dir: impl Into<PathBuf>) -> Self {
127        Self {
128            config_dir: config_dir.into(),
129            env_prefix: None,
130            app_modules: Vec::new(),
131            plugin_modules: Vec::new(),
132            extend: None,
133        }
134    }
135
136    /// Overrides the config env-var prefix from code, above the config's own
137    /// `app.env_prefix` declaration and [`DEFAULT_ENV_PREFIX`].
138    pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
139        self.env_prefix = Some(prefix.into());
140        self
141    }
142
143    /// Registers one of the application's own modules, after the built-in ones. Its
144    /// migrations and contributions run after those of the modules it names in
145    /// `requires`. App modules are load-critical: a failure at boot aborts, rather
146    /// than being quarantined like a plugin registered with [`Bootstrap::modules`].
147    pub fn module(mut self, module: impl Module) -> Self {
148        self.app_modules.push(Box::new(module));
149        self
150    }
151
152    /// Registers a set of plugins, typically the generated `plugins-manifest`.
153    /// Plugins are isolated at boot: one that fails to migrate or register is
154    /// quarantined (logged and skipped, taking its dependents with it) rather than
155    /// aborting the whole application.
156    pub fn modules(mut self, modules: Vec<Box<dyn Module>>) -> Self {
157        self.plugin_modules.extend(modules);
158        self
159    }
160
161    /// Merges the app's own routes onto the admin router before serving. The
162    /// closure gets the assembled router and a [`BootstrapCtx`].
163    pub fn extend(mut self, f: impl FnOnce(Router, &BootstrapCtx) -> Router + 'static) -> Self {
164        self.extend = Some(Box::new(f));
165        self
166    }
167
168    /// Loads config, connects, migrates, builds the router, and serves. Uses a
169    /// `systemfd`-inherited socket in development, else binds the configured
170    /// address.
171    pub async fn serve(self) -> anyhow::Result<()> {
172        // Log subscriber (RUST_LOG, default below); try_init yields to an app's own.
173        let _ = tracing_subscriber::fmt()
174            .with_env_filter(
175                tracing_subscriber::EnvFilter::try_from_default_env()
176                    .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,sqlx=warn")),
177            )
178            .try_init();
179
180        let env_prefix = resolve_env_prefix(
181            self.env_prefix.clone(),
182            config::declared_env_prefix(&self.config_dir)?,
183        );
184        let config: AppConfig = config::load(&self.config_dir, &env_prefix)?;
185        // Debug gates the cause on error pages; the log records it regardless.
186        laterite_core::config::set_debug(config.app.debug);
187
188        let db = laterite_core::db::connect(&config.database).await?;
189
190        // Plugins (registered via `modules`) are quarantine-eligible; built-ins and
191        // the app's own modules are load-critical. Capture the plugin ids before the
192        // modules move into the registry.
193        let plugin_ids: HashSet<String> = self
194            .plugin_modules
195            .iter()
196            .map(|m| m.id().to_string())
197            .collect();
198
199        // Create the plugin-registry table, record the compiled plugins in it, and
200        // read the operator's disabled set, all before deciding what loads. The
201        // built-in PluginsModule carries this same migration, applied idempotently in
202        // the load loop below.
203        laterite_core::migration::run(
204            &db.pool,
205            db.backend,
206            &[crate::plugins::PluginsModule.migrations()],
207        )
208        .await?;
209        crate::plugins::record_roster(&db, plugin_ids.iter().map(String::as_str)).await?;
210
211        // Crash-loop journal: reconcile the previous boot's load markers against
212        // this binary's fingerprint. A plugin whose init took the whole process
213        // down under this same binary is auto-quarantined so it is not retried
214        // into the same crash; a quarantine recorded under a different binary is
215        // lifted, giving a fresh build another chance.
216        let fingerprint = crate::plugins::binary_fingerprint();
217        let reconcile = crate::plugins::reconcile_boot(&db, &fingerprint).await?;
218        for id in &reconcile.crashed {
219            eprintln!(
220                "Plugin '{id}' quarantined: {}.",
221                crate::plugins::CRASH_REASON
222            );
223        }
224        for id in &reconcile.cleared {
225            println!("Plugin '{id}' quarantine lifted: the binary changed since it failed.");
226        }
227
228        let db_disabled = crate::plugins::disabled_ids(&db).await?;
229        let quarantined = crate::plugins::quarantined(&db).await?;
230
231        let mut registry = ModuleRegistry::new();
232        for module in builtin_modules() {
233            registry.register_boxed(module);
234        }
235        for module in self.app_modules {
236            registry.register_boxed(module);
237        }
238        for module in self.plugin_modules {
239            registry.register_boxed(module);
240        }
241        let capabilities =
242            laterite_core::capabilities::check(&db.pool, db.backend, &registry).await?;
243
244        // The modules in dependency order, driving both migrations and
245        // registration so a module's tables and contributions follow its
246        // requirements'.
247        let ordered = registry.ordered()?;
248
249        // Narrow to the modules that will actually load. Config can disable a
250        // plugin, and any plugin that requires a disabled one is skipped in turn.
251        // The registry itself is untouched, so ordered()'s dependency checks still
252        // hold; this only decides what migrates and registers.
253        let builtins: HashSet<String> = builtin_modules()
254            .iter()
255            .map(|m| m.id().to_string())
256            .collect();
257        let mut seed: HashMap<String, String> = HashMap::new();
258        for id in config.plugins.disabled.iter().chain(db_disabled.iter()) {
259            if builtins.contains(id) {
260                eprintln!("Ignoring request to disable built-in module '{id}'.");
261            } else {
262                seed.insert(id.clone(), "disabled".to_string());
263            }
264        }
265        // Quarantined plugins are skipped like disabled ones, but keep the
266        // system's reason. A built-in never carries a quarantine, but guard anyway.
267        for (id, reason) in &quarantined {
268            if !builtins.contains(id) {
269                seed.insert(id.clone(), format!("quarantined: {reason}"));
270            }
271        }
272        let skipped = skip_plan(&ordered, &seed);
273        report_plugins(&ordered, &skipped);
274        let active: Vec<&dyn Module> = ordered
275            .iter()
276            .copied()
277            .filter(|m| !skipped.contains_key(m.id().as_str()))
278            .collect();
279
280        // Load each active module in dependency order: migrate its tables, then
281        // register its contributions. A plugin that fails either step is quarantined
282        // (its partial contributions rolled back) and its dependents skipped; a
283        // built-in or app module failing is fatal.
284        let mut contributions = Registry::new();
285        let mut failed: HashMap<String, String> = HashMap::new();
286        for module in &active {
287            let mid = module.id();
288            let id = mid.as_str();
289            let eligible = plugin_ids.contains(id);
290
291            if let Some(dep) = module
292                .requires()
293                .iter()
294                .find(|r| failed.contains_key(r.as_str()))
295            {
296                let reason = format!("requires {}, which failed to load", dep.as_str());
297                println!("Plugin '{id}' off: {reason}");
298                failed.insert(id.to_string(), reason);
299                continue;
300            }
301
302            // Journal the attempt before any of the plugin's own code runs, so an
303            // uncatchable crash during migrate or register leaves a marker the next
304            // boot will find. Only plugins are journaled: a built-in or app module
305            // crashing is a fatal bug to fix, not something to quarantine.
306            if eligible {
307                crate::plugins::mark_loading(&db, id, &fingerprint).await?;
308            }
309
310            match laterite_core::migration::run(&db.pool, db.backend, &[module.migrations()]).await
311            {
312                Ok(()) => {}
313                Err(e) if !eligible => return Err(e.into()),
314                Err(e) => {
315                    let reason = format!("migration failed: {e}");
316                    eprintln!("Plugin '{id}' quarantined: {reason}");
317                    crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
318                    failed.insert(id.to_string(), reason);
319                    continue;
320                }
321            }
322
323            contributions.set_owner(mid);
324            let registered = catch_unwind(AssertUnwindSafe(|| module.register(&mut contributions)));
325            match registered {
326                // Init succeeded: clear the marker so this plugin is not blamed for
327                // a crash later in the boot.
328                Ok(()) => {
329                    if eligible {
330                        crate::plugins::clear_loading(&db, id).await?;
331                    }
332                }
333                Err(panic) => {
334                    contributions.purge_owner(mid);
335                    if !eligible {
336                        resume_unwind(panic);
337                    }
338                    let reason = "register panicked".to_string();
339                    eprintln!("Plugin '{id}' quarantined: {reason}");
340                    crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
341                    failed.insert(id.to_string(), reason);
342                }
343            }
344        }
345        // Resolve where each module's screens mount before any route is built,
346        // so a namespace, a declared base and a deployment override all land in
347        // one place and a clash is caught below.
348        let bases: HashMap<String, Option<&'static str>> = active
349            .iter()
350            .map(|m| (m.id().to_string(), m.admin_base()))
351            .collect();
352        let mut resources = Vec::new();
353        for (owner, mut resource) in contributions.take_owned::<Resource>() {
354            let base = bases.get(owner.as_str()).copied().flatten();
355            crate::rebase_resource(
356                &crate::routemap::resolve(owner, &resource.base_path, base, &config.backend.paths),
357                &mut resource,
358            );
359            resources.push((owner, resource));
360        }
361        let mut screens = Vec::new();
362        for (owner, mut reg) in contributions.take_owned::<crate::routes::ScreenReg>() {
363            let base = bases.get(owner.as_str()).copied().flatten();
364            reg.base_path =
365                crate::routemap::resolve(owner, &reg.base_path, base, &config.backend.paths);
366            screens.push((owner, reg));
367        }
368
369        let public = contributions.take_owned::<crate::routes::PublicRouteReg>();
370
371        // Every admin path in one list, the framework's own included, so a module
372        // shadowing a built-in screen is caught as loudly as two modules clashing.
373        let mut claims: Vec<(String, String)> = crate::builtin_resources()
374            .iter()
375            .map(|r| (r.base_path.clone(), "the framework".to_string()))
376            .collect();
377        claims.extend(
378            resources
379                .iter()
380                .map(|(owner, r)| (r.base_path.clone(), owner.to_string())),
381        );
382        claims.extend(
383            screens
384                .iter()
385                .map(|(owner, s)| (s.base_path.clone(), owner.to_string())),
386        );
387        crate::routemap::check_segments(&claims);
388        crate::routemap::check_collisions(&claims);
389        let resources: Vec<Resource> = resources.into_iter().map(|(_, r)| r).collect();
390        let screens: Vec<crate::routes::ScreenReg> = screens.into_iter().map(|(_, s)| s).collect();
391        // Public paths are literal, so they get their own check: two claims on one
392        // path, or anything reaching inside the panel.
393        crate::routemap::check_public(
394            &public
395                .iter()
396                .map(|(owner, r)| (r.path.clone(), owner.to_string()))
397                .collect::<Vec<_>>(),
398            &crate::normalize_path(&config.backend.path),
399        );
400        let public: Vec<crate::routes::PublicRouteReg> =
401            public.into_iter().map(|(_, r)| r).collect();
402        // Settings screens resolve like every other contributed screen: through
403        // the owning module's namespace. Before this they used their storage key
404        // as a URL, which is both a dotted path and a screen the deployment
405        // could not move with [backend.paths].
406        let mut settings = Vec::new();
407        for (owner, mut item) in contributions.take_owned::<SettingsItem>() {
408            // The screen's own name within its module: the storage key with the
409            // module's prefix removed, since the namespace supplies that part.
410            let declared = item
411                .code
412                .strip_prefix(&format!("{}.", owner.as_str()))
413                .unwrap_or(&item.code)
414                .replace('.', "/");
415            let base = bases.get(owner.as_str()).copied().flatten();
416            item.mount_at(crate::routemap::resolve(
417                owner,
418                &format!("/{declared}"),
419                base,
420                &config.backend.paths,
421            ));
422            settings.push((owner, item));
423        }
424        // A settings screen claims a path like any other, so one shadowing a
425        // resource is caught at boot rather than silently winning.
426        let settings_claims: Vec<(String, String)> = settings
427            .iter()
428            .filter(|(_, i)| i.link.is_none())
429            .map(|(owner, i)| (i.route(), owner.to_string()))
430            .collect();
431        crate::routemap::check_segments(&settings_claims);
432        let settings: Vec<SettingsItem> = settings.into_iter().map(|(_, i)| i).collect();
433        let permissions = contributions.take::<Permission>();
434        let picker_sources = contributions.take::<crate::picker::PickerSourceReg>();
435        let persisters = contributions.take::<crate::persist::PersisterReg>();
436        let listeners = contributions.take::<laterite_core::ModelListenerReg>();
437        let field_types = contributions.take::<crate::field::FieldTypeReg>();
438        let column_types = contributions.take::<crate::list::ColumnTypeReg>();
439        // Owned, so each icon is namespaced to the module that contributed it.
440        let icons = contributions.take_owned::<laterite_core::icons::IconReg>();
441
442        // One bus for the application, carrying every listener a module
443        // contributed, in dependency order.
444        let mut bus = laterite_core::Events::builder(db.clone());
445        for (owner, listener) in contributions.take_owned::<laterite_core::EventListenerReg>() {
446            bus = bus.registered(owner, listener);
447        }
448        let events = bus.build()?;
449
450        let auth = AuthService::new(db.clone(), config.auth.clone()).with_events(events.clone());
451        // Expired sessions and stay-signed-in credentials are swept at boot and
452        // then hourly, so the tables hold only what a request could still use.
453        purge_expired(&auth).await;
454        tokio::spawn(purge_hourly(auth.clone()));
455        let origin = config::base_url(config.app.url.as_deref(), &config.server.listen);
456        let admin_config = AdminConfig {
457            secure_cookie: config.backend.secure_cookie,
458            trusted_proxies: config.backend.trusted_proxies.clone(),
459            timezone: config.backend.timezone.clone(),
460            locale: config.app.locale.clone(),
461            app_name: config.app.name.clone(),
462            path: config.backend.path.clone(),
463            origin: origin.clone(),
464            storage_dir: config.backend.storage.clone(),
465        };
466        // Merge every loaded module's PO catalogs into the shared store the request
467        // translator looks up against. A malformed catalog fails boot, like a bad
468        // descriptor key. Skipped and failed modules do not contribute.
469        let mut catalogs = laterite_core::CatalogStore::builder();
470        for module in active
471            .iter()
472            .filter(|m| !failed.contains_key(m.id().as_str()))
473        {
474            for (locale, po) in module.catalogs() {
475                catalogs = catalogs.po(locale, po).map_err(|e| {
476                    laterite_core::CoreError::Config(format!(
477                        "module {}: {locale} catalog: {e}",
478                        module.id().as_str()
479                    ))
480                })?;
481            }
482        }
483
484        // A renamed permission is repaired wherever an operator's own choices
485        // still hold the old code, before the roles below are rebuilt.
486        let renamed =
487            laterite_auth::store::rename_permissions(&db, &crate::permission_renames(&permissions))
488                .await?;
489        if renamed > 0 {
490            tracing::info!(rows = renamed, "rewrote renamed permission codes");
491        }
492
493        // The framework's own roles, rewritten from the registry now that every
494        // module has contributed: a new permission lands in its role at the boot
495        // that introduces it.
496        laterite_auth::store::sync_system_roles(&db, &crate::system_roles(&permissions)).await?;
497
498        let mut app = router(
499            auth,
500            db.clone(),
501            crate::Contributions {
502                resources,
503                settings,
504                permissions,
505                picker_sources,
506                persisters,
507                listeners,
508                field_types,
509                column_types,
510                icons,
511                screens,
512                public_routes: public,
513                // Whatever is left is plugin-defined: types the framework knows
514                // nothing about, kept so one module's route can read another's
515                // contributions.
516                plugin_defined: std::sync::Arc::new(contributions),
517            },
518            admin_config,
519            std::sync::Arc::new(catalogs.build()),
520        );
521
522        if let Some(extend) = self.extend {
523            let ctx = BootstrapCtx {
524                db,
525                capabilities,
526                translator: Translator::new(&config.app.locale),
527                events,
528            };
529            app = extend(app, &ctx);
530        }
531
532        let listener = bind(&config.server.listen).await?;
533        let admin_path = normalize_path(&config.backend.path);
534        println!("{} on {origin}{admin_path}", config.app.name);
535        axum::serve(
536            listener,
537            app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
538        )
539        .await?;
540        Ok(())
541    }
542}
543
544/// Binds the listener, preferring a `systemfd`-inherited socket (so the port
545/// survives reloads) and falling back to binding `listen`.
546/// One sweep of expired sessions and credentials, logged when it removed any.
547async fn purge_expired(auth: &AuthService) {
548    match auth.purge_expired().await {
549        Ok(purged) if purged.sessions + purged.remember_tokens > 0 => tracing::info!(
550            sessions = purged.sessions,
551            remember_tokens = purged.remember_tokens,
552            "purged expired sessions and credentials"
553        ),
554        Ok(_) => {}
555        Err(e) => tracing::error!(error = %e, "purging expired sessions failed"),
556    }
557}
558
559/// The hourly sweep, for as long as the process runs.
560async fn purge_hourly(auth: AuthService) {
561    let mut ticks = tokio::time::interval(std::time::Duration::from_secs(60 * 60));
562    // The first tick fires at once; the boot sweep has just run.
563    ticks.tick().await;
564    loop {
565        ticks.tick().await;
566        purge_expired(&auth).await;
567    }
568}
569
570async fn bind(listen: &str) -> anyhow::Result<tokio::net::TcpListener> {
571    use listenfd::ListenFd;
572    match ListenFd::from_env().take_tcp_listener(0)? {
573        Some(std_listener) => {
574            std_listener.set_nonblocking(true)?;
575            Ok(tokio::net::TcpListener::from_std(std_listener)?)
576        }
577        None => Ok(tokio::net::TcpListener::bind(listen).await?),
578    }
579}
580
581/// Given modules in dependency order (dependencies first) and a `seed` of ids
582/// already known to be off (disabled or quarantined) each with its reason,
583/// returns the full set to skip, each with a reason. One forward pass suffices
584/// because `ordered` places a module after everything it requires: a seeded
585/// module is skipped, and so is anything that requires an already-skipped one.
586fn skip_plan(ordered: &[&dyn Module], seed: &HashMap<String, String>) -> HashMap<String, String> {
587    let mut skipped: HashMap<String, String> = HashMap::new();
588    for module in ordered {
589        let id = module.id().as_str();
590        if let Some(reason) = seed.get(id) {
591            skipped.insert(id.to_string(), reason.clone());
592            continue;
593        }
594        if let Some(dep) = module
595            .requires()
596            .iter()
597            .find(|r| skipped.contains_key(r.as_str()))
598        {
599            skipped.insert(
600                id.to_string(),
601                format!("requires {}, which is off", dep.as_str()),
602            );
603        }
604    }
605    skipped
606}
607
608/// Prints one line per skipped plugin, in dependency order. Silent when every
609/// module loads, so a normal boot stays quiet.
610fn report_plugins(ordered: &[&dyn Module], skipped: &HashMap<String, String>) {
611    if skipped.is_empty() {
612        return;
613    }
614    for module in ordered {
615        if let Some(reason) = skipped.get(module.id().as_str()) {
616            println!("Plugin '{}' off: {reason}", module.id());
617        }
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use laterite_core::ModuleId;
625
626    #[test]
627    fn env_prefix_is_unset_until_overridden_in_code() {
628        assert_eq!(Bootstrap::new("config").env_prefix, None);
629        assert_eq!(
630            Bootstrap::new("config")
631                .env_prefix("ACME")
632                .env_prefix
633                .as_deref(),
634            Some("ACME")
635        );
636    }
637
638    #[test]
639    fn app_config_loads_named_sections_and_falls_back_to_defaults() {
640        let dir = tempfile::tempdir().unwrap();
641        std::fs::write(
642            dir.path().join("default.toml"),
643            "[app]\nname = \"Acme\"\n\n[server]\nlisten = \"0.0.0.0:9000\"\n\n\
644             [database]\nurl = \"postgres://x\"\n",
645        )
646        .unwrap();
647        let cfg: AppConfig = config::load(dir.path(), "LATERITE_BOOTSTRAP_TEST").unwrap();
648        assert_eq!(cfg.app.name, "Acme");
649        assert_eq!(cfg.server.listen, "0.0.0.0:9000");
650        assert_eq!(cfg.database.url, "postgres://x");
651        assert_eq!(cfg.backend.path, "/admin");
652        assert!(!cfg.backend.secure_cookie);
653        assert!(cfg.app.url.is_none());
654    }
655
656    struct M(&'static str, &'static [ModuleId]);
657    impl Module for M {
658        fn id(&self) -> ModuleId {
659            ModuleId::new(self.0)
660        }
661        fn requires(&self) -> &'static [ModuleId] {
662            self.1
663        }
664    }
665
666    #[test]
667    fn skip_plan_disables_config_ids_and_cascades_to_dependents() {
668        const NONE: [ModuleId; 0] = [];
669        const REQ_A: [ModuleId; 1] = [ModuleId::new("acme.a")];
670        const REQ_B: [ModuleId; 1] = [ModuleId::new("acme.b")];
671        // Dependency order (deps first): a, b (requires a), c (requires b), and an
672        // independent d.
673        let (a, b, c, d) = (
674            M("acme.a", &NONE),
675            M("acme.b", &REQ_A),
676            M("acme.c", &REQ_B),
677            M("acme.d", &NONE),
678        );
679        let ordered: Vec<&dyn Module> = vec![&a, &b, &c, &d];
680
681        let seed: HashMap<String, String> = [("acme.a".to_string(), "disabled".to_string())]
682            .into_iter()
683            .collect();
684        let skipped = skip_plan(&ordered, &seed);
685
686        // a is off by the seed; b then c cascade off; d is unaffected.
687        assert_eq!(skipped.get("acme.a").map(String::as_str), Some("disabled"));
688        assert!(skipped["acme.b"].contains("requires acme.a"));
689        assert!(skipped["acme.c"].contains("requires acme.b"));
690        assert!(!skipped.contains_key("acme.d"));
691    }
692}