Skip to main content

entelix_core/
extensions.rs

1//! `Extensions` — type-keyed cross-cutting carrier rendered through
2//! [`crate::ExecutionContext`].
3//!
4//! ## Why a `TypeMap` slot
5//!
6//! The `ExecutionContext` first-class fields (`tenant_id`,
7//! `cancellation`, `deadline`, `run_id`, `thread_id`) cover the
8//! cross-cutting concerns the SDK itself reasons about. Operators
9//! who need to thread *their own* request-scope data — a workspace
10//! handle, a per-run cache, a tenant-specific rate-limiter, a custom
11//! telemetry tag — would otherwise reach for `thread_local!` or
12//! `Arc`-passing through bespoke parameters. `Extensions` gives them
13//! a typed slot on the same context the rest of the SDK already
14//! threads through `Runnable`, `Tool`, and codec layers.
15//!
16//! The pattern mirrors `http::Extensions`, `axum::Extensions`, and
17//! `tower::Service` request extensions — industry-standard cross-cutting
18//! carriers. `Extensions` differs only in being thread-safe by
19//! construction (entries are `Send + Sync`, the map is shared via
20//! `Arc`) so multi-tenant deployments can clone the context across
21//! parallel branches without lock juggling.
22//!
23//! ## Copy-on-write semantics
24//!
25//! `Extensions` is *immutable after construction*. The builder
26//! pattern on [`crate::ExecutionContext::add_extension`] returns a
27//! new `ExecutionContext` carrying a fresh `Arc<Extensions>`; the
28//! caller's original context is unchanged. That preserves the
29//! "context is cheap to clone" guarantee — combinators that fan out
30//! to parallel branches see consistent, non-mutating state.
31//!
32//! ## Invariant 10 (no tokens in tools)
33//!
34//! `Extensions` is *not* a credentials channel. Operators who stash
35//! a [`crate::auth::CredentialProvider`] handle here would surface
36//! the credential value to every `Tool::execute` site that touches
37//! the context — a structural violation. Credentials live in
38//! transports; the trait surface for this slot deliberately
39//! does not advertise an "auth" affordance.
40//!
41//! ## Type-erased storage
42//!
43//! Entries are stored as `Arc<dyn Any + Send + Sync>` keyed by
44//! [`std::any::TypeId`]. Insertions of the same `T` overwrite (one
45//! entry per type) — this matches the `http::Extensions` shape and
46//! avoids the "list of `T`" question that ad-hoc carriers struggle
47//! with.
48
49use std::any::{Any, TypeId};
50use std::collections::HashMap;
51use std::sync::Arc;
52
53/// Type-keyed cross-cutting state attached to an
54/// [`crate::ExecutionContext`]. Operators add their own values via
55/// [`crate::ExecutionContext::add_extension`] and read them back
56/// via [`crate::ExecutionContext::extension`].
57///
58/// Cloning is cheap — internally an `Arc` over the underlying map,
59/// so cloning a context that already carries extensions does not
60/// duplicate the entries.
61#[derive(Clone, Default)]
62pub struct Extensions {
63    inner: Arc<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>,
64}
65
66impl Extensions {
67    /// Create an empty `Extensions`. Equivalent to
68    /// [`Default::default`].
69    #[must_use]
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    /// Number of entries currently stored. Diagnostic helper —
75    /// production code rarely cares.
76    #[must_use]
77    pub fn len(&self) -> usize {
78        self.inner.len()
79    }
80
81    /// True when the carrier has no entries.
82    #[must_use]
83    pub fn is_empty(&self) -> bool {
84        self.inner.is_empty()
85    }
86
87    /// Look up the entry registered for `T`. Returns `None` when
88    /// no value of that type has been inserted.
89    ///
90    /// The returned `Arc<T>` is a fresh refcount bump — the caller
91    /// can hold it across awaits without keeping the
92    /// `ExecutionContext` alive.
93    #[must_use]
94    pub fn get<T>(&self) -> Option<Arc<T>>
95    where
96        T: Send + Sync + 'static,
97    {
98        self.inner
99            .get(&TypeId::of::<T>())
100            .and_then(|entry| Arc::clone(entry).downcast::<T>().ok())
101    }
102
103    /// True when the carrier has an entry for `T`.
104    #[must_use]
105    pub fn contains<T>(&self) -> bool
106    where
107        T: Send + Sync + 'static,
108    {
109        self.inner.contains_key(&TypeId::of::<T>())
110    }
111
112    /// Return a new `Extensions` with `value` inserted under the
113    /// type id of `T`. An existing entry for the same type is
114    /// replaced (one entry per type, mirroring `http::Extensions`).
115    ///
116    /// Internal helper — operators reach this through
117    /// [`crate::ExecutionContext::add_extension`].
118    #[must_use]
119    pub(crate) fn inserted<T>(&self, value: T) -> Self
120    where
121        T: Send + Sync + 'static,
122    {
123        let mut next: HashMap<TypeId, Arc<dyn Any + Send + Sync>> = (*self.inner).clone();
124        next.insert(TypeId::of::<T>(), Arc::new(value));
125        Self {
126            inner: Arc::new(next),
127        }
128    }
129}
130
131impl std::fmt::Debug for Extensions {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        // The values themselves are `dyn Any` so we can't print
134        // them — surfacing the cardinality keeps the debug output
135        // honest for log review and crash dumps.
136        f.debug_struct("Extensions")
137            .field("len", &self.inner.len())
138            .finish()
139    }
140}
141
142#[cfg(test)]
143#[allow(clippy::unwrap_used)]
144mod tests {
145    use super::*;
146
147    #[derive(Debug, PartialEq, Eq)]
148    struct Workspace(&'static str);
149
150    #[derive(Debug, PartialEq, Eq)]
151    struct RequestId(u64);
152
153    #[test]
154    fn empty_extensions_have_no_entries() {
155        let ext = Extensions::new();
156        assert!(ext.is_empty());
157        assert_eq!(ext.len(), 0);
158        assert!(ext.get::<Workspace>().is_none());
159        assert!(!ext.contains::<Workspace>());
160    }
161
162    #[test]
163    fn insert_and_get_round_trip() {
164        let ext = Extensions::new().inserted(Workspace("repo-a"));
165        assert_eq!(ext.len(), 1);
166        let got = ext.get::<Workspace>().unwrap();
167        assert_eq!(*got, Workspace("repo-a"));
168    }
169
170    #[test]
171    fn multiple_distinct_types_coexist() {
172        let ext = Extensions::new()
173            .inserted(Workspace("repo-a"))
174            .inserted(RequestId(42));
175        assert_eq!(ext.len(), 2);
176        assert_eq!(*ext.get::<Workspace>().unwrap(), Workspace("repo-a"));
177        assert_eq!(*ext.get::<RequestId>().unwrap(), RequestId(42));
178    }
179
180    #[test]
181    fn second_insert_of_same_type_replaces() {
182        let ext = Extensions::new()
183            .inserted(Workspace("repo-a"))
184            .inserted(Workspace("repo-b"));
185        assert_eq!(ext.len(), 1, "one entry per type");
186        assert_eq!(*ext.get::<Workspace>().unwrap(), Workspace("repo-b"));
187    }
188
189    #[test]
190    fn copy_on_write_does_not_mutate_original() {
191        let original = Extensions::new().inserted(Workspace("repo-a"));
192        let extended = original.inserted(RequestId(7));
193        // Original unchanged: still 1 entry, no RequestId.
194        assert_eq!(original.len(), 1);
195        assert!(original.get::<RequestId>().is_none());
196        // Extended carries both.
197        assert_eq!(extended.len(), 2);
198        assert!(extended.get::<RequestId>().is_some());
199    }
200
201    #[test]
202    fn absent_type_returns_none() {
203        let ext = Extensions::new().inserted(Workspace("repo-a"));
204        assert!(ext.get::<RequestId>().is_none());
205    }
206
207    #[test]
208    fn contains_reflects_insertion() {
209        let ext = Extensions::new();
210        assert!(!ext.contains::<Workspace>());
211        let ext = ext.inserted(Workspace("repo-a"));
212        assert!(ext.contains::<Workspace>());
213    }
214
215    #[test]
216    fn debug_surfaces_cardinality() {
217        let ext = Extensions::new().inserted(Workspace("repo-a"));
218        let debug_str = format!("{ext:?}");
219        assert!(debug_str.contains("len: 1"), "{debug_str}");
220    }
221
222    #[test]
223    fn arc_returned_from_get_outlives_extensions_clone() {
224        let ext = Extensions::new().inserted(Workspace("repo-a"));
225        let arc = ext.get::<Workspace>().unwrap();
226        drop(ext);
227        // The Arc is independent; can still be read after the
228        // owning Extensions are dropped.
229        assert_eq!(*arc, Workspace("repo-a"));
230    }
231}