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        };
465        // Merge every loaded module's PO catalogs into the shared store the request
466        // translator looks up against. A malformed catalog fails boot, like a bad
467        // descriptor key. Skipped and failed modules do not contribute.
468        let mut catalogs = laterite_core::CatalogStore::builder();
469        for module in active
470            .iter()
471            .filter(|m| !failed.contains_key(m.id().as_str()))
472        {
473            for (locale, po) in module.catalogs() {
474                catalogs = catalogs.po(locale, po).map_err(|e| {
475                    laterite_core::CoreError::Config(format!(
476                        "module {}: {locale} catalog: {e}",
477                        module.id().as_str()
478                    ))
479                })?;
480            }
481        }
482
483        // A renamed permission is repaired wherever an operator's own choices
484        // still hold the old code, before the roles below are rebuilt.
485        let renamed =
486            laterite_auth::store::rename_permissions(&db, &crate::permission_renames(&permissions))
487                .await?;
488        if renamed > 0 {
489            tracing::info!(rows = renamed, "rewrote renamed permission codes");
490        }
491
492        // The framework's own roles, rewritten from the registry now that every
493        // module has contributed: a new permission lands in its role at the boot
494        // that introduces it.
495        laterite_auth::store::sync_system_roles(&db, &crate::system_roles(&permissions)).await?;
496
497        let mut app = router(
498            auth,
499            db.clone(),
500            crate::Contributions {
501                resources,
502                settings,
503                permissions,
504                picker_sources,
505                persisters,
506                listeners,
507                field_types,
508                column_types,
509                icons,
510                screens,
511                public_routes: public,
512                // Whatever is left is plugin-defined: types the framework knows
513                // nothing about, kept so one module's route can read another's
514                // contributions.
515                plugin_defined: std::sync::Arc::new(contributions),
516            },
517            admin_config,
518            std::sync::Arc::new(catalogs.build()),
519        );
520
521        if let Some(extend) = self.extend {
522            let ctx = BootstrapCtx {
523                db,
524                capabilities,
525                translator: Translator::new(&config.app.locale),
526                events,
527            };
528            app = extend(app, &ctx);
529        }
530
531        let listener = bind(&config.server.listen).await?;
532        let admin_path = normalize_path(&config.backend.path);
533        println!("{} on {origin}{admin_path}", config.app.name);
534        axum::serve(
535            listener,
536            app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
537        )
538        .await?;
539        Ok(())
540    }
541}
542
543/// Binds the listener, preferring a `systemfd`-inherited socket (so the port
544/// survives reloads) and falling back to binding `listen`.
545/// One sweep of expired sessions and credentials, logged when it removed any.
546async fn purge_expired(auth: &AuthService) {
547    match auth.purge_expired().await {
548        Ok(purged) if purged.sessions + purged.remember_tokens > 0 => tracing::info!(
549            sessions = purged.sessions,
550            remember_tokens = purged.remember_tokens,
551            "purged expired sessions and credentials"
552        ),
553        Ok(_) => {}
554        Err(e) => tracing::error!(error = %e, "purging expired sessions failed"),
555    }
556}
557
558/// The hourly sweep, for as long as the process runs.
559async fn purge_hourly(auth: AuthService) {
560    let mut ticks = tokio::time::interval(std::time::Duration::from_secs(60 * 60));
561    // The first tick fires at once; the boot sweep has just run.
562    ticks.tick().await;
563    loop {
564        ticks.tick().await;
565        purge_expired(&auth).await;
566    }
567}
568
569async fn bind(listen: &str) -> anyhow::Result<tokio::net::TcpListener> {
570    use listenfd::ListenFd;
571    match ListenFd::from_env().take_tcp_listener(0)? {
572        Some(std_listener) => {
573            std_listener.set_nonblocking(true)?;
574            Ok(tokio::net::TcpListener::from_std(std_listener)?)
575        }
576        None => Ok(tokio::net::TcpListener::bind(listen).await?),
577    }
578}
579
580/// Given modules in dependency order (dependencies first) and a `seed` of ids
581/// already known to be off (disabled or quarantined) each with its reason,
582/// returns the full set to skip, each with a reason. One forward pass suffices
583/// because `ordered` places a module after everything it requires: a seeded
584/// module is skipped, and so is anything that requires an already-skipped one.
585fn skip_plan(ordered: &[&dyn Module], seed: &HashMap<String, String>) -> HashMap<String, String> {
586    let mut skipped: HashMap<String, String> = HashMap::new();
587    for module in ordered {
588        let id = module.id().as_str();
589        if let Some(reason) = seed.get(id) {
590            skipped.insert(id.to_string(), reason.clone());
591            continue;
592        }
593        if let Some(dep) = module
594            .requires()
595            .iter()
596            .find(|r| skipped.contains_key(r.as_str()))
597        {
598            skipped.insert(
599                id.to_string(),
600                format!("requires {}, which is off", dep.as_str()),
601            );
602        }
603    }
604    skipped
605}
606
607/// Prints one line per skipped plugin, in dependency order. Silent when every
608/// module loads, so a normal boot stays quiet.
609fn report_plugins(ordered: &[&dyn Module], skipped: &HashMap<String, String>) {
610    if skipped.is_empty() {
611        return;
612    }
613    for module in ordered {
614        if let Some(reason) = skipped.get(module.id().as_str()) {
615            println!("Plugin '{}' off: {reason}", module.id());
616        }
617    }
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use laterite_core::ModuleId;
624
625    #[test]
626    fn env_prefix_is_unset_until_overridden_in_code() {
627        assert_eq!(Bootstrap::new("config").env_prefix, None);
628        assert_eq!(
629            Bootstrap::new("config")
630                .env_prefix("ACME")
631                .env_prefix
632                .as_deref(),
633            Some("ACME")
634        );
635    }
636
637    #[test]
638    fn app_config_loads_named_sections_and_falls_back_to_defaults() {
639        let dir = tempfile::tempdir().unwrap();
640        std::fs::write(
641            dir.path().join("default.toml"),
642            "[app]\nname = \"Acme\"\n\n[server]\nlisten = \"0.0.0.0:9000\"\n\n\
643             [database]\nurl = \"postgres://x\"\n",
644        )
645        .unwrap();
646        let cfg: AppConfig = config::load(dir.path(), "LATERITE_BOOTSTRAP_TEST").unwrap();
647        assert_eq!(cfg.app.name, "Acme");
648        assert_eq!(cfg.server.listen, "0.0.0.0:9000");
649        assert_eq!(cfg.database.url, "postgres://x");
650        assert_eq!(cfg.backend.path, "/admin");
651        assert!(!cfg.backend.secure_cookie);
652        assert!(cfg.app.url.is_none());
653    }
654
655    struct M(&'static str, &'static [ModuleId]);
656    impl Module for M {
657        fn id(&self) -> ModuleId {
658            ModuleId::new(self.0)
659        }
660        fn requires(&self) -> &'static [ModuleId] {
661            self.1
662        }
663    }
664
665    #[test]
666    fn skip_plan_disables_config_ids_and_cascades_to_dependents() {
667        const NONE: [ModuleId; 0] = [];
668        const REQ_A: [ModuleId; 1] = [ModuleId::new("acme.a")];
669        const REQ_B: [ModuleId; 1] = [ModuleId::new("acme.b")];
670        // Dependency order (deps first): a, b (requires a), c (requires b), and an
671        // independent d.
672        let (a, b, c, d) = (
673            M("acme.a", &NONE),
674            M("acme.b", &REQ_A),
675            M("acme.c", &REQ_B),
676            M("acme.d", &NONE),
677        );
678        let ordered: Vec<&dyn Module> = vec![&a, &b, &c, &d];
679
680        let seed: HashMap<String, String> = [("acme.a".to_string(), "disabled".to_string())]
681            .into_iter()
682            .collect();
683        let skipped = skip_plan(&ordered, &seed);
684
685        // a is off by the seed; b then c cascade off; d is unaffected.
686        assert_eq!(skipped.get("acme.a").map(String::as_str), Some("disabled"));
687        assert!(skipped["acme.b"].contains("requires acme.a"));
688        assert!(skipped["acme.c"].contains("requires acme.b"));
689        assert!(!skipped.contains_key("acme.d"));
690    }
691}