Skip to main content

loco_rs/
app.rs

1//! This module contains the core components and traits for building a web
2//! server application.
3#[cfg(feature = "with-db")]
4use {sea_orm::DatabaseConnection, std::path::Path};
5
6use std::{
7    any::{Any, TypeId},
8    net::SocketAddr,
9    sync::Arc,
10};
11
12use async_trait::async_trait;
13use axum::extract::FromRef;
14use axum::Router as AxumRouter;
15use dashmap::DashMap;
16
17use crate::{
18    bgworker::{self, Queue},
19    boot::{shutdown_signal, BootResult, ServeParams, StartMode},
20    cache::{self},
21    config::Config,
22    controller::{
23        middleware::{self, MiddlewareLayer},
24        AppRoutes,
25    },
26    environment::Environment,
27    mailer::EmailSender,
28    storage::{self, Storage},
29    task::Tasks,
30    Result,
31};
32
33/// Type-safe heterogeneous storage for arbitrary application data
34#[derive(Default, Debug)]
35pub struct SharedStore {
36    // Use DashMap for concurrent access with fine-grained locking
37    storage: DashMap<TypeId, Box<dyn Any + Send + Sync>>,
38}
39
40impl SharedStore {
41    /// Insert a value of type T into the shared store
42    ///
43    /// # Example
44    /// ```
45    /// # use loco_rs::app::SharedStore;
46    /// let shared_store = SharedStore::default();
47    ///
48    /// #[derive(Debug)]
49    /// struct TestService {
50    ///     name: String,
51    ///     value: i32,
52    /// }
53    ///
54    /// let service = TestService {
55    ///     name: "test".to_string(),
56    ///     value: 100,
57    /// };
58    ///
59    /// shared_store.insert(service);
60    /// assert!(shared_store.contains::<TestService>());
61    /// ```
62    pub fn insert<T: 'static + Send + Sync>(&self, val: T) {
63        self.storage.insert(TypeId::of::<T>(), Box::new(val));
64    }
65
66    /// Remove a value of type T from the shared store
67    ///
68    /// Returns `Some(T)` if the value was present and removed, `None` otherwise.
69    ///
70    /// # Example
71    /// ```
72    /// # use loco_rs::app::SharedStore;
73    /// let shared_store = SharedStore::default();
74    ///
75    /// struct TestService {
76    ///     name: String,
77    ///     value: i32,
78    /// }
79    ///
80    /// let service = TestService {
81    ///     name: "test".to_string(),
82    ///     value: 100,
83    /// };
84    ///
85    /// shared_store.insert(service);
86    /// assert!(shared_store.contains::<TestService>());
87    ///
88    /// // Remove and get the value
89    /// let removed_service_opt = shared_store.remove::<TestService>();
90    /// assert!(removed_service_opt.is_some(), "Service should be present");
91    /// // Assert fields individually instead of comparing the whole struct
92    /// if let Some(removed_service) = removed_service_opt {
93    ///      assert_eq!(removed_service.name, "test");
94    ///      assert_eq!(removed_service.value, 100);
95    /// }
96    /// // Ensure it's gone
97    /// assert!(!shared_store.contains::<TestService>());
98    ///
99    /// // Trying to remove again returns None
100    /// assert!(shared_store.remove::<TestService>().is_none());
101    /// ```
102    #[must_use]
103    pub fn remove<T: 'static + Send + Sync>(&self) -> Option<T> {
104        self.storage
105            .remove(&TypeId::of::<T>())
106            .map(|(_, v)| v) // Extract the Box<dyn Any>
107            .and_then(|any| any.downcast::<T>().ok()) // Downcast to Box<T>
108            .map(|boxed| *boxed) // Dereference the Box<T> to get T
109    }
110
111    /// Get a reference to a value of type T from the shared store.
112    ///
113    /// Returns `None` if the value doesn't exist.
114    /// The reference is valid for as long as the returned `RefGuard` is held.
115    /// If you need to clone the value, you can do so directly from the
116    /// returned reference, or use the `get` method instead.
117    ///
118    /// # Example
119    /// ```
120    /// # use loco_rs::app::SharedStore;
121    /// let shared_store = SharedStore::default();
122    ///
123    /// #[derive(Clone)]
124    /// struct TestService {
125    ///     name: String,
126    ///     value: i32,
127    /// }
128    ///
129    /// let service = TestService {
130    ///     name: "test".to_string(),
131    ///     value: 100,
132    /// };
133    ///
134    /// shared_store.insert(service);
135    ///
136    /// // Get a reference to the service
137    /// let service_ref = shared_store.get_ref::<TestService>().expect("Service not found");
138    /// // Access fields directly
139    /// assert_eq!(service_ref.name, "test");
140    /// assert_eq!(service_ref.value, 100);
141    ///
142    /// // Clone if needed (the field itself)
143    /// let name_clone = service_ref.name.clone();
144    /// assert_eq!(name_clone, "test");
145    ///
146    /// // Compute values from the reference
147    /// let name_len = service_ref.name.len();
148    /// assert_eq!(name_len, 4);
149    /// ```
150    #[must_use]
151    pub fn get_ref<T: 'static + Send + Sync>(&self) -> Option<RefGuard<'_, T>> {
152        let type_id = TypeId::of::<T>();
153        self.storage.get(&type_id).map(|r| RefGuard::<T> {
154            inner: r,
155            _phantom: std::marker::PhantomData,
156        })
157    }
158
159    /// Get a clone of a value of type T from the shared store.
160    /// Requires T to implement Clone.
161    ///
162    /// Returns `None` if the value doesn't exist.
163    /// This method clones the stored value.
164    /// If cloning is not desired or T does not implement Clone,
165    /// use `get_ref` instead.
166    ///
167    /// # Example
168    /// ```
169    /// # use loco_rs::app::SharedStore;
170    /// let shared_store = SharedStore::default();
171    ///
172    /// #[derive(Clone)]
173    /// struct TestService {
174    ///     name: String,
175    ///     value: i32,
176    /// }
177    ///
178    /// let service = TestService {
179    ///     name: "test".to_string(),
180    ///     value: 100,
181    /// };
182    ///
183    /// shared_store.insert(service);
184    ///
185    /// // Get a clone of the service
186    /// let service_clone_opt = shared_store.get::<TestService>();
187    /// assert!(service_clone_opt.is_some(), "Service not found");
188    /// // Assert fields individually
189    /// if let Some(ref service_clone) = service_clone_opt {
190    ///     assert_eq!(service_clone.name, "test");
191    ///     assert_eq!(service_clone.value, 100);
192    /// }
193    /// ```
194    #[must_use]
195    pub fn get<T: 'static + Send + Sync + Clone>(&self) -> Option<T> {
196        self.get_ref::<T>().map(|guard| (*guard).clone())
197    }
198
199    /// Check if the shared store contains a value of type T
200    ///
201    /// # Example
202    /// ```
203    /// # use loco_rs::app::SharedStore;
204    /// let shared_store = SharedStore::default();
205    ///
206    /// struct TestService {
207    ///     name: String,
208    ///     value: i32,
209    /// }
210    ///
211    /// let service = TestService {
212    ///     name: "test".to_string(),
213    ///     value: 100,
214    /// };
215    ///
216    /// shared_store.insert(service);
217    /// assert!(shared_store.contains::<TestService>());
218    /// assert!(!shared_store.contains::<String>());
219    /// ```
220    #[must_use]
221    pub fn contains<T: 'static + Send + Sync>(&self) -> bool {
222        self.storage.contains_key(&TypeId::of::<T>())
223    }
224}
225
226// A wrapper around DashMap's Ref type that erases the exact type
227// but provides deref to the target type
228pub struct RefGuard<'a, T: 'static + Send + Sync> {
229    inner: dashmap::mapref::one::Ref<'a, TypeId, Box<dyn Any + Send + Sync>>,
230    _phantom: std::marker::PhantomData<&'a T>,
231}
232
233impl<T: 'static + Send + Sync> std::ops::Deref for RefGuard<'_, T> {
234    type Target = T;
235
236    fn deref(&self) -> &Self::Target {
237        // This is safe because we only create a RefGuard for a specific type
238        // after looking it up by its TypeId
239        #[allow(clippy::coerce_container_to_any)]
240        self.inner
241            .value()
242            .downcast_ref::<T>()
243            .expect("Type mismatch in RefGuard")
244    }
245}
246
247/// Represents the application context for a web server.
248///
249/// This struct encapsulates various components and configurations required by
250/// the web server to operate. It is typically used to store and manage shared
251/// resources and settings that are accessible throughout the application's
252/// lifetime.
253#[derive(Clone, FromRef)]
254#[allow(clippy::module_name_repetitions)]
255#[non_exhaustive]
256pub struct AppContext {
257    /// The environment in which the application is running.
258    pub environment: Environment,
259    #[cfg(feature = "with-db")]
260    /// A database connection used by the application.
261    pub db: DatabaseConnection,
262    /// Queue provider
263    pub queue_provider: Option<Arc<bgworker::Queue>>,
264    /// Configuration settings for the application
265    pub config: Config,
266    /// An optional email sender component that can be used to send email.
267    pub mailer: Option<EmailSender>,
268    // An optional storage instance for the application
269    pub storage: Arc<Storage>,
270    // Cache instance for the application
271    pub cache: Arc<cache::Cache>,
272    /// Shared store for arbitrary application data
273    pub shared_store: Arc<SharedStore>,
274}
275
276/// Builder for [`AppContext`].
277///
278/// Because `AppContext` is `#[non_exhaustive]`,
279/// external crates must construct it through this builder (or the framework's
280/// boot path) rather than a struct literal — so new fields added in future
281/// releases are non-breaking. Required components are constructor arguments;
282/// optional components default to no-op providers unless set.
283#[must_use]
284pub struct AppContextBuilder {
285    environment: Environment,
286    #[cfg(feature = "with-db")]
287    db: DatabaseConnection,
288    config: Config,
289    queue_provider: Option<Arc<bgworker::Queue>>,
290    mailer: Option<EmailSender>,
291    storage: Option<Arc<Storage>>,
292    cache: Option<Arc<cache::Cache>>,
293    shared_store: Option<Arc<SharedStore>>,
294}
295
296impl AppContext {
297    /// Start building an [`AppContext`]. (with-db)
298    #[cfg(feature = "with-db")]
299    pub fn builder(
300        environment: Environment,
301        db: DatabaseConnection,
302        config: Config,
303    ) -> AppContextBuilder {
304        AppContextBuilder {
305            environment,
306            db,
307            config,
308            queue_provider: None,
309            mailer: None,
310            storage: None,
311            cache: None,
312            shared_store: None,
313        }
314    }
315
316    /// Start building an [`AppContext`]. (no-db)
317    #[cfg(not(feature = "with-db"))]
318    pub fn builder(environment: Environment, config: Config) -> AppContextBuilder {
319        AppContextBuilder {
320            environment,
321            config,
322            queue_provider: None,
323            mailer: None,
324            storage: None,
325            cache: None,
326            shared_store: None,
327        }
328    }
329
330    /// Turn an existing context back into a builder, carrying **every**
331    /// component over.
332    ///
333    /// This is the escape hatch for [`Hooks::after_context`]. Because
334    /// `AppContext` is `#[non_exhaustive]`, functional-update syntax —
335    /// `AppContext { storage, ..ctx }`, the idiom that hook was documented
336    /// with — does not compile outside this crate. Starting over from
337    /// [`AppContext::builder`] does compile, but silently discards whatever
338    /// the boot sequence already placed on the context: the mailer, the queue
339    /// provider, the cache, the shared store. Round-tripping through the
340    /// builder replaces one component and keeps the rest.
341    ///
342    /// ```rust,ignore
343    /// async fn after_context(ctx: AppContext) -> Result<AppContext> {
344    ///     Ok(ctx
345    ///         .into_builder()
346    ///         .storage(Storage::single(storage::drivers::local::new()).into())
347    ///         .build())
348    /// }
349    /// ```
350    pub fn into_builder(self) -> AppContextBuilder {
351        AppContextBuilder {
352            environment: self.environment,
353            #[cfg(feature = "with-db")]
354            db: self.db,
355            config: self.config,
356            queue_provider: self.queue_provider,
357            mailer: self.mailer,
358            storage: Some(self.storage),
359            cache: Some(self.cache),
360            shared_store: Some(self.shared_store),
361        }
362    }
363}
364
365impl AppContextBuilder {
366    /// Set the background-queue provider (default: none).
367    pub fn queue_provider(mut self, queue_provider: Arc<bgworker::Queue>) -> Self {
368        self.queue_provider = Some(queue_provider);
369        self
370    }
371    /// Set the email sender (default: none).
372    pub fn mailer(mut self, mailer: EmailSender) -> Self {
373        self.mailer = Some(mailer);
374        self
375    }
376    /// Set the storage (default: single null driver).
377    pub fn storage(mut self, storage: Arc<Storage>) -> Self {
378        self.storage = Some(storage);
379        self
380    }
381    /// Set the cache (default: null cache).
382    pub fn cache(mut self, cache: Arc<cache::Cache>) -> Self {
383        self.cache = Some(cache);
384        self
385    }
386    /// Set the shared store (default: empty).
387    pub fn shared_store(mut self, shared_store: Arc<SharedStore>) -> Self {
388        self.shared_store = Some(shared_store);
389        self
390    }
391    /// Finalize the [`AppContext`], filling any unset optional component with a
392    /// no-op default.
393    #[must_use]
394    pub fn build(self) -> AppContext {
395        AppContext {
396            environment: self.environment,
397            #[cfg(feature = "with-db")]
398            db: self.db,
399            queue_provider: self.queue_provider,
400            config: self.config,
401            mailer: self.mailer,
402            storage: self
403                .storage
404                .unwrap_or_else(|| Storage::single(storage::drivers::null::new()).into()),
405            cache: self
406                .cache
407                .unwrap_or_else(|| cache::Cache::new(cache::drivers::null::new()).into()),
408            shared_store: self
409                .shared_store
410                .unwrap_or_else(|| Arc::new(SharedStore::default())),
411        }
412    }
413}
414
415/// A trait that defines hooks for customizing and extending the behavior of a
416/// web server application.
417///
418/// Users of the web server application should implement this trait to customize
419/// the application's routing, worker connections, task registration, and
420/// database actions according to their specific requirements and use cases.
421#[async_trait]
422pub trait Hooks: Send {
423    /// Defines the composite app version
424    #[must_use]
425    fn app_version() -> String {
426        "dev".to_string()
427    }
428    /// Defines the crate name
429    ///
430    /// Example
431    /// ```rust
432    /// fn app_name() -> &'static str {
433    ///     env!("CARGO_CRATE_NAME")
434    /// }
435    /// ```
436    fn app_name() -> &'static str;
437
438    /// Initializes and boots the application based on the specified mode and
439    /// environment.
440    ///
441    /// The boot initialization process may vary depending on whether a DB
442    /// migrator is used or not.
443    ///
444    /// # Examples
445    ///
446    /// With DB:
447    /// ```rust,ignore
448    /// async fn boot(mode: StartMode, environment: &Environment, config: Config) -> Result<BootResult> {
449    ///     create_app::<Self, Migrator>(mode, environment, config).await
450    /// }
451    /// ````
452    ///
453    /// Without DB:
454    /// ```rust,ignore
455    /// async fn boot(mode: StartMode, environment: &Environment, config: Config) -> Result<BootResult> {
456    ///     create_app::<Self>(mode, environment, config).await
457    /// }
458    /// ````
459    ///
460    ///
461    /// # Errors
462    /// Could not boot the application
463    async fn boot(mode: StartMode, environment: &Environment, config: Config)
464        -> Result<BootResult>;
465
466    /// Start serving the Axum web application on the specified address and
467    /// port.
468    ///
469    /// # Returns
470    /// A Result indicating success () or an error if the server fails to start.
471    async fn serve(app: AxumRouter, ctx: &AppContext, serve_params: &ServeParams) -> Result<()> {
472        let listener = tokio::net::TcpListener::bind(&format!(
473            "{}:{}",
474            serve_params.binding, serve_params.port
475        ))
476        .await?;
477
478        let cloned_ctx = ctx.clone();
479        axum::serve(
480            listener,
481            app.into_make_service_with_connect_info::<SocketAddr>(),
482        )
483        .with_graceful_shutdown(async move {
484            shutdown_signal().await;
485            tracing::info!("shutting down...");
486            Self::on_shutdown(&cloned_ctx).await;
487        })
488        .await?;
489
490        Ok(())
491    }
492
493    /// Override and return `Ok(true)` to provide an alternative logging and
494    /// tracing stack of your own.
495    /// When returning `Ok(true)`, Loco will *not* initialize its own logger,
496    /// so you should set up a complete tracing and logging stack.
497    ///
498    /// # Errors
499    /// If fails returns an error
500    fn init_logger(_ctx: &AppContext) -> Result<bool> {
501        Ok(false)
502    }
503
504    /// Loads the configuration settings for the application based on the given environment.
505    ///
506    /// This function is responsible for retrieving the configuration for the application
507    /// based on the current environment.
508    async fn load_config(env: &Environment) -> Result<Config> {
509        env.load()
510    }
511
512    /// Returns the initial Axum router for the application, allowing the user
513    /// to control the construction of the Axum router. This is where a fallback
514    /// handler can be installed before middleware or other routes are added.
515    ///
516    /// # Errors
517    /// Return an [`Result`] when the router could not be created
518    async fn before_routes(_ctx: &AppContext) -> Result<AxumRouter<AppContext>> {
519        Ok(AxumRouter::new())
520    }
521
522    /// Invoke this function after the Loco routers have been constructed. This
523    /// function enables you to configure custom Axum logics, such as layers,
524    /// that are compatible with Axum.
525    ///
526    /// # Errors
527    /// Axum router error
528    async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
529        Ok(router)
530    }
531
532    /// Provide a list of initializers
533    /// An initializer can be used to seamlessly add functionality to your app
534    /// or to initialize some aspects of it.
535    async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
536        Ok(vec![])
537    }
538
539    /// Provide a list of middlewares
540    #[must_use]
541    fn middlewares(ctx: &AppContext) -> Vec<Box<dyn MiddlewareLayer>> {
542        middleware::default_middleware_stack(ctx)
543    }
544
545    /// Calling the function before run the app
546    /// You can now code some custom loading of resources or other things before
547    /// the app runs
548    async fn before_run(_app_context: &AppContext) -> Result<()> {
549        Ok(())
550    }
551
552    /// Defines the application's routing configuration.
553    fn routes(_ctx: &AppContext) -> AppRoutes;
554
555    // Provides the options to change Loco [`AppContext`] after initialization.
556    async fn after_context(ctx: AppContext) -> Result<AppContext> {
557        Ok(ctx)
558    }
559
560    /// Connects custom workers to the application using the provided
561    /// [`Queue`] and [`AppContext`].
562    async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()>;
563
564    /// Registers custom tasks with the provided [`Tasks`] object.
565    fn register_tasks(tasks: &mut Tasks);
566
567    /// Truncates the database as required. Users should implement this
568    /// function. The truncate controlled from the [`crate::config::Database`]
569    /// by changing `dangerously_truncate` to true (default false).
570    /// Truncate can be useful when you want to truncate the database before any
571    /// test.
572    #[cfg(feature = "with-db")]
573    async fn truncate(_ctx: &AppContext) -> Result<()>;
574
575    /// Seeds the database with initial data.
576    #[cfg(feature = "with-db")]
577    async fn seed(_ctx: &AppContext, path: &Path) -> Result<()>;
578
579    /// Dumps database tables to YAML fixtures under `base`, the counterpart to
580    /// [`Hooks::seed`] used by `cargo loco db seed --dump`.
581    ///
582    /// The default implementation dumps every table via schema introspection
583    /// ([`crate::db::dump_tables`]). Override it to dump specific entities with
584    /// the typed, streaming [`crate::db::dump`] for full type fidelity and
585    /// bounded memory:
586    ///
587    /// ```ignore
588    /// async fn dump(ctx: &AppContext, base: &Path) -> Result<()> {
589    ///     db::dump::<users::ActiveModel>(&ctx.db, &base.join("users.yaml").to_string_lossy()).await?;
590    ///     Ok(())
591    /// }
592    /// ```
593    #[cfg(feature = "with-db")]
594    async fn dump(ctx: &AppContext, base: &Path) -> Result<()> {
595        crate::db::dump_tables(&ctx.db, base, None).await
596    }
597
598    /// Called when the application is shutting down.
599    /// This function allows users to perform any necessary cleanup or final
600    /// actions before the application stops completely.
601    async fn on_shutdown(_ctx: &AppContext) {}
602}
603
604/// An initializer.
605/// Initializers should be kept in `src/initializers/`
606///
607/// Initializers can provide health checks by implementing the `check` method.
608/// These checks will be run during the `cargo loco doctor` command to validate
609/// the initializer's configuration and test its connections.
610#[async_trait]
611// <snip id="initializers-trait">
612pub trait Initializer: Sync + Send {
613    /// The initializer name or identifier
614    fn name(&self) -> String;
615
616    /// Occurs after the app's `before_run`.
617    /// Use this to for one-time initializations, load caches, perform web
618    /// hooks, etc.
619    async fn before_run(&self, _app_context: &AppContext) -> Result<()> {
620        Ok(())
621    }
622
623    /// Occurs after the app's `after_routes`.
624    /// Use this to compose additional functionality and wire it into an Axum
625    /// Router
626    async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
627        Ok(router)
628    }
629
630    /// Perform health checks for this initializer.
631    /// This method is called during the doctor command to validate the initializer's configuration.
632    /// Return `None` if no check is needed, or `Some(Check)` if a check should be performed.
633    async fn check(&self, _app_context: &AppContext) -> Result<Option<crate::doctor::Check>> {
634        Ok(None)
635    }
636}
637// </snip>
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642    use crate::tests_cfg::app::get_app_context;
643
644    struct TestService {
645        name: String,
646        value: i32,
647    }
648
649    #[derive(Clone)]
650    struct CloneableTestService {
651        name: String,
652        value: i32,
653    }
654
655    #[test]
656    fn test_extensions_insert_and_get() {
657        // Setup
658        let shared_store = SharedStore::default();
659
660        shared_store.insert(42i32);
661        assert_eq!(shared_store.get::<i32>().expect("Value should exist"), 42);
662
663        let service = TestService {
664            name: "test".to_string(),
665            value: 100,
666        };
667
668        shared_store.insert(service);
669
670        let service_ref_opt = shared_store.get_ref::<TestService>();
671        assert!(service_ref_opt.is_some(), "Service ref should exist");
672        if let Some(service_ref) = service_ref_opt {
673            assert_eq!(service_ref.name, "test");
674            assert_eq!(service_ref.value, 100);
675            let name_clone = service_ref.name.clone();
676            assert_eq!(name_clone, "test");
677        } else {
678            panic!("Should have gotten Some(service_ref)");
679        }
680    }
681
682    #[test]
683    fn test_extensions_get_without_clone() {
684        let shared_store = SharedStore::default();
685
686        let service = TestService {
687            name: "test_direct".to_string(),
688            value: 100,
689        };
690        shared_store.insert(service);
691
692        let service_ref_opt = shared_store.get_ref::<TestService>();
693        assert!(service_ref_opt.is_some(), "Service ref should exist");
694        if let Some(service_ref) = service_ref_opt {
695            assert_eq!(service_ref.name, "test_direct");
696            assert_eq!(service_ref.value, 100);
697        } else {
698            panic!("Should have gotten Some(service_ref)");
699        }
700
701        let name_len_opt = shared_store.get_ref::<TestService>().map(|r| r.name.len());
702        assert!(
703            name_len_opt.is_some(),
704            "Service ref should exist for len check"
705        );
706        assert_eq!(name_len_opt.unwrap(), 11);
707
708        let value_opt = shared_store.get_ref::<TestService>().map(|r| r.value);
709        assert!(
710            value_opt.is_some(),
711            "Service ref should exist for value check"
712        );
713        assert_eq!(value_opt.unwrap(), 100);
714    }
715
716    #[test]
717    fn test_extensions_remove() {
718        let shared_store = SharedStore::default();
719
720        shared_store.insert(42i32);
721        assert!(shared_store.contains::<i32>());
722        assert_eq!(shared_store.remove::<i32>(), Some(42));
723        assert!(!shared_store.contains::<i32>());
724        assert_eq!(shared_store.remove::<i32>(), None);
725
726        let service = TestService {
727            name: "rem".to_string(),
728            value: 50,
729        };
730        shared_store.insert(service);
731        assert!(shared_store.contains::<TestService>());
732        let removed_opt = shared_store.remove::<TestService>();
733        assert!(removed_opt.is_some());
734        if let Some(removed) = removed_opt {
735            assert_eq!(removed.name, "rem");
736            assert_eq!(removed.value, 50);
737        } else {
738            panic!("Removed option should be Some");
739        }
740        assert!(!shared_store.contains::<TestService>());
741        assert!(shared_store.remove::<TestService>().is_none());
742    }
743
744    #[test]
745    fn test_extensions_contains() {
746        let shared_store = SharedStore::default();
747
748        shared_store.insert(42i32);
749        shared_store.insert(TestService {
750            name: "contains".to_string(),
751            value: 1,
752        });
753
754        assert!(shared_store.contains::<i32>());
755        assert!(shared_store.contains::<TestService>());
756        assert!(!shared_store.contains::<String>());
757        assert!(!shared_store.contains::<CloneableTestService>());
758    }
759
760    #[test]
761    fn test_extensions_get_cloned() {
762        let shared_store = SharedStore::default();
763
764        shared_store.insert(42i32);
765        assert_eq!(shared_store.get::<i32>(), Some(42));
766        assert!(shared_store.contains::<i32>());
767
768        let service = CloneableTestService {
769            name: "cloned_test".to_string(),
770            value: 200,
771        };
772        shared_store.insert(service.clone());
773
774        let service_clone_opt = shared_store.get::<CloneableTestService>();
775        assert!(service_clone_opt.is_some(), "Cloned service should exist");
776        if let Some(ref service_clone) = service_clone_opt {
777            assert_eq!(service_clone.name, "cloned_test");
778            assert_eq!(service_clone.value, 200);
779        } else {
780            panic!("Should have gotten Some(service_clone)");
781        }
782
783        assert!(shared_store.contains::<CloneableTestService>());
784        let original_ref_opt = shared_store.get_ref::<CloneableTestService>();
785        assert!(original_ref_opt.is_some(), "Original ref should exist");
786        if let Some(original_ref) = original_ref_opt {
787            assert_eq!(original_ref.name, "cloned_test");
788            assert_eq!(original_ref.value, 200);
789        } else {
790            panic!("Should have gotten Some(original_ref)");
791        }
792
793        assert_eq!(shared_store.get::<String>(), None);
794        assert!(shared_store.get::<CloneableTestService>().is_some());
795        // The following line correctly fails to compile because TestService doesn't impl Clone,
796        // which is required by the `get` method.
797        // let non_existent_clone = shared_store.get::<TestService>();
798    }
799
800    #[tokio::test]
801    async fn test_app_context_extensions() {
802        let ctx = get_app_context().await;
803
804        let service_cloneable = CloneableTestService {
805            name: "app_context_test_cloneable".to_string(),
806            value: 42,
807        };
808        ctx.shared_store.insert(service_cloneable.clone());
809
810        let ref_opt = ctx.shared_store.get_ref::<CloneableTestService>();
811        assert!(ref_opt.is_some(), "Cloneable service ref should exist");
812        if let Some(service_ref) = ref_opt {
813            assert_eq!(service_ref.name, "app_context_test_cloneable");
814            assert_eq!(service_ref.value, 42);
815        } else {
816            panic!("Should have gotten Some(service_ref)");
817        }
818
819        let clone_opt = ctx.shared_store.get::<CloneableTestService>();
820        assert!(clone_opt.is_some(), "Should get cloned service");
821        if let Some(service_clone) = clone_opt {
822            assert_eq!(service_clone.name, "app_context_test_cloneable");
823            assert_eq!(service_clone.value, 42);
824        } else {
825            panic!("Should have gotten Some(service_clone)");
826        }
827
828        assert!(ctx.shared_store.contains::<CloneableTestService>());
829        assert!(!ctx.shared_store.contains::<String>());
830
831        let removed_cloneable_opt = ctx.shared_store.remove::<CloneableTestService>();
832        assert!(removed_cloneable_opt.is_some());
833        if let Some(removed) = removed_cloneable_opt {
834            assert_eq!(removed.name, "app_context_test_cloneable");
835            assert_eq!(removed.value, 42);
836        } else {
837            panic!("Removed cloneable option should be Some");
838        }
839        assert!(!ctx.shared_store.contains::<CloneableTestService>());
840
841        let service_non_cloneable = TestService {
842            name: "app_context_test_non_cloneable".to_string(),
843            value: 99,
844        };
845        ctx.shared_store.insert(service_non_cloneable);
846
847        let non_clone_ref_opt = ctx.shared_store.get_ref::<TestService>();
848        assert!(
849            non_clone_ref_opt.is_some(),
850            "Non-cloneable service ref should exist"
851        );
852        if let Some(service_ref) = non_clone_ref_opt {
853            assert_eq!(service_ref.name, "app_context_test_non_cloneable");
854            assert_eq!(service_ref.value, 99);
855        } else {
856            panic!("Should have gotten Some(service_ref)");
857        }
858
859        assert!(ctx.shared_store.contains::<TestService>());
860
861        let removed_non_cloneable_opt = ctx.shared_store.remove::<TestService>();
862        assert!(removed_non_cloneable_opt.is_some());
863        if let Some(removed) = removed_non_cloneable_opt {
864            assert_eq!(removed.name, "app_context_test_non_cloneable");
865            assert_eq!(removed.value, 99);
866        } else {
867            panic!("Removed non-cloneable option should be Some");
868        }
869        assert!(!ctx.shared_store.contains::<TestService>());
870    }
871
872    /// `after_context` is documented as the way to swap a component — storage,
873    /// most often — on the booted context. Since `AppContext` was sealed
874    /// `#[non_exhaustive]`, `AppContext { storage, ..ctx }` no longer compiles
875    /// in a user's app, and rebuilding from `AppContext::builder` silently
876    /// drops everything boot already attached. This asserts the round trip
877    /// keeps every component, so replacing one is not a way to lose the rest.
878    #[tokio::test]
879    async fn into_builder_carries_every_component_over() {
880        let mut ctx = get_app_context().await;
881        ctx.mailer = Some(crate::mailer::EmailSender::stub());
882        ctx.shared_store.insert(TestService {
883            name: "attached-at-boot".to_string(),
884            value: 7,
885        });
886
887        let before = ctx.clone();
888        let after = ctx
889            .into_builder()
890            .storage(Storage::single(storage::drivers::null::new()).into())
891            .build();
892
893        // The one component we asked to replace:
894        assert!(
895            !Arc::ptr_eq(&before.storage, &after.storage),
896            "storage should be the one we set"
897        );
898
899        // Everything else survives, by identity — not merely by being non-None.
900        assert!(
901            Arc::ptr_eq(&before.cache, &after.cache),
902            "cache was dropped"
903        );
904        assert!(
905            Arc::ptr_eq(&before.shared_store, &after.shared_store),
906            "shared store was dropped"
907        );
908        assert!(after.mailer.is_some(), "mailer was dropped");
909        assert_eq!(after.environment, before.environment);
910        assert!(
911            after
912                .shared_store
913                .get_ref::<TestService>()
914                .is_some_and(|service| service.value == 7),
915            "shared store contents were dropped"
916        );
917    }
918}