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}