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        // Every module has migrated and registered: now each boots, in the same
349        // order, seeing the whole registry. A plugin whose boot fails is
350        // quarantined like one whose migration failed; anything else aborts.
351        for module in &active {
352            let mid = module.id();
353            let id = mid.as_str();
354            if failed.contains_key(id) {
355                continue;
356            }
357            let cx = laterite_core::BootCx {
358                db: &db,
359                registry: &contributions,
360            };
361            match module.boot(&cx).await {
362                Ok(()) => {}
363                Err(e) if !plugin_ids.contains(id) => return Err(e.into()),
364                Err(e) => {
365                    let reason = format!("boot failed: {e}");
366                    eprintln!("Plugin '{id}' quarantined: {reason}");
367                    crate::plugins::quarantine(&db, id, &reason, &fingerprint).await?;
368                    contributions.purge_owner(mid);
369                    failed.insert(id.to_string(), reason);
370                }
371            }
372        }
373        let active: Vec<&dyn Module> = active
374            .into_iter()
375            .filter(|m| !failed.contains_key(m.id().as_str()))
376            .collect();
377        let bases: HashMap<String, Option<&'static str>> = active
378            .iter()
379            .map(|m| (m.id().to_string(), m.admin_base()))
380            .collect();
381        let mut resources = Vec::new();
382        for (owner, mut resource) in contributions.take_owned::<Resource>() {
383            let base = bases.get(owner.as_str()).copied().flatten();
384            crate::rebase_resource(
385                &crate::routemap::resolve(owner, &resource.base_path, base, &config.backend.paths),
386                &mut resource,
387            );
388            resources.push((owner, resource));
389        }
390        let mut screens = Vec::new();
391        for (owner, mut reg) in contributions.take_owned::<crate::routes::ScreenReg>() {
392            let base = bases.get(owner.as_str()).copied().flatten();
393            reg.base_path =
394                crate::routemap::resolve(owner, &reg.base_path, base, &config.backend.paths);
395            screens.push((owner, reg));
396        }
397
398        let public = contributions.take_owned::<crate::routes::PublicRouteReg>();
399
400        // Every admin path in one list, the framework's own included, so a module
401        // shadowing a built-in screen is caught as loudly as two modules clashing.
402        let mut claims: Vec<(String, String)> = crate::builtin_resources()
403            .iter()
404            .map(|r| (r.base_path.clone(), "the framework".to_string()))
405            .collect();
406        claims.extend(
407            resources
408                .iter()
409                .map(|(owner, r)| (r.base_path.clone(), owner.to_string())),
410        );
411        claims.extend(
412            screens
413                .iter()
414                .map(|(owner, s)| (s.base_path.clone(), owner.to_string())),
415        );
416        crate::routemap::check_segments(&claims);
417        crate::routemap::check_collisions(&claims);
418        let resources: Vec<Resource> = resources.into_iter().map(|(_, r)| r).collect();
419        let screens: Vec<crate::routes::ScreenReg> = screens.into_iter().map(|(_, s)| s).collect();
420        // Public paths are literal, so they get their own check: two claims on one
421        // path, or anything reaching inside the panel.
422        crate::routemap::check_public(
423            &public
424                .iter()
425                .map(|(owner, r)| (r.path.clone(), owner.to_string()))
426                .collect::<Vec<_>>(),
427            &crate::normalize_path(&config.backend.path),
428        );
429        let public: Vec<crate::routes::PublicRouteReg> =
430            public.into_iter().map(|(_, r)| r).collect();
431        // Settings screens resolve like every other contributed screen: through
432        // the owning module's namespace. Before this they used their storage key
433        // as a URL, which is both a dotted path and a screen the deployment
434        // could not move with [backend.paths].
435        let mut settings = Vec::new();
436        for (owner, mut item) in contributions.take_owned::<SettingsItem>() {
437            // The screen's own name within its module: the storage key with the
438            // module's prefix removed, since the namespace supplies that part.
439            let declared = item
440                .code
441                .strip_prefix(&format!("{}.", owner.as_str()))
442                .unwrap_or(&item.code)
443                .replace('.', "/");
444            let base = bases.get(owner.as_str()).copied().flatten();
445            item.mount_at(crate::routemap::resolve(
446                owner,
447                &format!("/{declared}"),
448                base,
449                &config.backend.paths,
450            ));
451            settings.push((owner, item));
452        }
453        // A settings screen claims a path like any other, so one shadowing a
454        // resource is caught at boot rather than silently winning.
455        let settings_claims: Vec<(String, String)> = settings
456            .iter()
457            .filter(|(_, i)| i.link.is_none())
458            .map(|(owner, i)| (i.route(), owner.to_string()))
459            .collect();
460        crate::routemap::check_segments(&settings_claims);
461        let settings: Vec<SettingsItem> = settings.into_iter().map(|(_, i)| i).collect();
462        let permissions = contributions.take::<Permission>();
463        let picker_sources = contributions.take::<crate::picker::PickerSourceReg>();
464        let persisters = contributions.take::<crate::persist::PersisterReg>();
465        let listeners = contributions.take::<laterite_core::ModelListenerReg>();
466        let field_types = contributions.take::<crate::field::FieldTypeReg>();
467        let column_types = contributions.take::<crate::list::ColumnTypeReg>();
468        let nav = contributions.take::<crate::nav::NavItem>();
469        let resource_hooks = contributions.take::<crate::ResourceHook>();
470        // Owned, so each icon is namespaced to the module that contributed it.
471        let icons = contributions.take_owned::<laterite_core::icons::IconReg>();
472
473        // One bus for the application, carrying every listener a module
474        // contributed, in dependency order.
475        let mut bus = laterite_core::Events::builder(db.clone());
476        for (owner, listener) in contributions.take_owned::<laterite_core::EventListenerReg>() {
477            bus = bus.registered(owner, listener);
478        }
479        let events = bus.build()?;
480
481        let auth = AuthService::new(db.clone(), config.auth.clone()).with_events(events.clone());
482        // Expired sessions and stay-signed-in credentials are swept hourly (the
483        // auth module's boot did the first sweep), so the tables hold only what
484        // a request could still use.
485        tokio::spawn(purge_hourly(auth.clone()));
486        let origin = config::base_url(config.app.url.as_deref(), &config.server.listen);
487        let admin_config = AdminConfig {
488            secure_cookie: config.backend.secure_cookie,
489            trusted_proxies: config.backend.trusted_proxies.clone(),
490            timezone: config.backend.timezone.clone(),
491            locale: config.app.locale.clone(),
492            app_name: config.app.name.clone(),
493            path: config.backend.path.clone(),
494            origin: origin.clone(),
495            storage_dir: config.backend.storage.clone(),
496        };
497        // Merge every loaded module's PO catalogs into the shared store the request
498        // translator looks up against. A malformed catalog fails boot, like a bad
499        // descriptor key. Skipped and failed modules do not contribute.
500        let mut catalogs = laterite_core::CatalogStore::builder();
501        for module in active
502            .iter()
503            .filter(|m| !failed.contains_key(m.id().as_str()))
504        {
505            for (locale, po) in module.catalogs() {
506                catalogs = catalogs.po(locale, po).map_err(|e| {
507                    laterite_core::CoreError::Config(format!(
508                        "module {}: {locale} catalog: {e}",
509                        module.id().as_str()
510                    ))
511                })?;
512            }
513        }
514
515        // A renamed permission is repaired wherever an operator's own choices
516        // still hold the old code, before the roles below are rebuilt.
517        let renamed =
518            laterite_auth::store::rename_permissions(&db, &crate::permission_renames(&permissions))
519                .await?;
520        if renamed > 0 {
521            tracing::info!(rows = renamed, "rewrote renamed permission codes");
522        }
523
524        // The framework's own roles, rewritten from the registry now that every
525        // module has contributed: a new permission lands in its role at the boot
526        // that introduces it.
527        laterite_auth::store::sync_system_roles(&db, &crate::system_roles(&permissions)).await?;
528
529        let mut app = router(
530            auth,
531            db.clone(),
532            crate::Contributions {
533                nav,
534                resource_hooks,
535                resources,
536                settings,
537                permissions,
538                picker_sources,
539                persisters,
540                listeners,
541                field_types,
542                column_types,
543                icons,
544                screens,
545                public_routes: public,
546                // Whatever is left is plugin-defined: types the framework knows
547                // nothing about, kept so one module's route can read another's
548                // contributions.
549                plugin_defined: std::sync::Arc::new(contributions),
550            },
551            admin_config,
552            std::sync::Arc::new(catalogs.build()),
553        );
554
555        if let Some(extend) = self.extend {
556            let ctx = BootstrapCtx {
557                db,
558                capabilities,
559                translator: Translator::new(&config.app.locale),
560                events,
561            };
562            app = extend(app, &ctx);
563        }
564
565        let listener = bind(&config.server.listen).await?;
566        let admin_path = normalize_path(&config.backend.path);
567        println!("{} on {origin}{admin_path}", config.app.name);
568        axum::serve(
569            listener,
570            app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
571        )
572        .await?;
573        Ok(())
574    }
575}
576
577/// Binds the listener, preferring a `systemfd`-inherited socket (so the port
578/// survives reloads) and falling back to binding `listen`.
579/// One sweep of expired sessions and credentials, logged when it removed any.
580async fn purge_expired(auth: &AuthService) {
581    match auth.purge_expired().await {
582        Ok(purged) if purged.sessions + purged.remember_tokens > 0 => tracing::info!(
583            sessions = purged.sessions,
584            remember_tokens = purged.remember_tokens,
585            "purged expired sessions and credentials"
586        ),
587        Ok(_) => {}
588        Err(e) => tracing::error!(error = %e, "purging expired sessions failed"),
589    }
590}
591
592/// The hourly sweep, for as long as the process runs.
593async fn purge_hourly(auth: AuthService) {
594    let mut ticks = tokio::time::interval(std::time::Duration::from_secs(60 * 60));
595    // The first tick fires at once; the boot sweep has just run.
596    ticks.tick().await;
597    loop {
598        ticks.tick().await;
599        purge_expired(&auth).await;
600    }
601}
602
603async fn bind(listen: &str) -> anyhow::Result<tokio::net::TcpListener> {
604    use listenfd::ListenFd;
605    match ListenFd::from_env().take_tcp_listener(0)? {
606        Some(std_listener) => {
607            std_listener.set_nonblocking(true)?;
608            Ok(tokio::net::TcpListener::from_std(std_listener)?)
609        }
610        None => Ok(tokio::net::TcpListener::bind(listen).await?),
611    }
612}
613
614/// Given modules in dependency order (dependencies first) and a `seed` of ids
615/// already known to be off (disabled or quarantined) each with its reason,
616/// returns the full set to skip, each with a reason. One forward pass suffices
617/// because `ordered` places a module after everything it requires: a seeded
618/// module is skipped, and so is anything that requires an already-skipped one.
619fn skip_plan(ordered: &[&dyn Module], seed: &HashMap<String, String>) -> HashMap<String, String> {
620    let mut skipped: HashMap<String, String> = HashMap::new();
621    for module in ordered {
622        let id = module.id().as_str();
623        if let Some(reason) = seed.get(id) {
624            skipped.insert(id.to_string(), reason.clone());
625            continue;
626        }
627        if let Some(dep) = module
628            .requires()
629            .iter()
630            .find(|r| skipped.contains_key(r.as_str()))
631        {
632            skipped.insert(
633                id.to_string(),
634                format!("requires {}, which is off", dep.as_str()),
635            );
636        }
637    }
638    skipped
639}
640
641/// Prints one line per skipped plugin, in dependency order. Silent when every
642/// module loads, so a normal boot stays quiet.
643fn report_plugins(ordered: &[&dyn Module], skipped: &HashMap<String, String>) {
644    if skipped.is_empty() {
645        return;
646    }
647    for module in ordered {
648        if let Some(reason) = skipped.get(module.id().as_str()) {
649            println!("Plugin '{}' off: {reason}", module.id());
650        }
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657    use laterite_core::ModuleId;
658
659    #[test]
660    fn env_prefix_is_unset_until_overridden_in_code() {
661        assert_eq!(Bootstrap::new("config").env_prefix, None);
662        assert_eq!(
663            Bootstrap::new("config")
664                .env_prefix("ACME")
665                .env_prefix
666                .as_deref(),
667            Some("ACME")
668        );
669    }
670
671    #[test]
672    fn app_config_loads_named_sections_and_falls_back_to_defaults() {
673        let dir = tempfile::tempdir().unwrap();
674        std::fs::write(
675            dir.path().join("default.toml"),
676            "[app]\nname = \"Acme\"\n\n[server]\nlisten = \"0.0.0.0:9000\"\n\n\
677             [database]\nurl = \"postgres://x\"\n",
678        )
679        .unwrap();
680        let cfg: AppConfig = config::load(dir.path(), "LATERITE_BOOTSTRAP_TEST").unwrap();
681        assert_eq!(cfg.app.name, "Acme");
682        assert_eq!(cfg.server.listen, "0.0.0.0:9000");
683        assert_eq!(cfg.database.url, "postgres://x");
684        assert_eq!(cfg.backend.path, "/admin");
685        assert!(!cfg.backend.secure_cookie);
686        assert!(cfg.app.url.is_none());
687    }
688
689    struct M(&'static str, &'static [ModuleId]);
690    impl Module for M {
691        fn id(&self) -> ModuleId {
692            ModuleId::new(self.0)
693        }
694        fn requires(&self) -> &'static [ModuleId] {
695            self.1
696        }
697    }
698
699    #[test]
700    fn skip_plan_disables_config_ids_and_cascades_to_dependents() {
701        const NONE: [ModuleId; 0] = [];
702        const REQ_A: [ModuleId; 1] = [ModuleId::new("acme.a")];
703        const REQ_B: [ModuleId; 1] = [ModuleId::new("acme.b")];
704        // Dependency order (deps first): a, b (requires a), c (requires b), and an
705        // independent d.
706        let (a, b, c, d) = (
707            M("acme.a", &NONE),
708            M("acme.b", &REQ_A),
709            M("acme.c", &REQ_B),
710            M("acme.d", &NONE),
711        );
712        let ordered: Vec<&dyn Module> = vec![&a, &b, &c, &d];
713
714        let seed: HashMap<String, String> = [("acme.a".to_string(), "disabled".to_string())]
715            .into_iter()
716            .collect();
717        let skipped = skip_plan(&ordered, &seed);
718
719        // a is off by the seed; b then c cascade off; d is unaffected.
720        assert_eq!(skipped.get("acme.a").map(String::as_str), Some("disabled"));
721        assert!(skipped["acme.b"].contains("requires acme.a"));
722        assert!(skipped["acme.c"].contains("requires acme.b"));
723        assert!(!skipped.contains_key("acme.d"));
724    }
725}