Skip to main content

cratefield_runtime_native/
runtime.rs

1//! The `Native` runtime builder (issue #19): the counterpart of
2//! `cratefield_runtime_cloudflare::Cloudflare` for a self-hosted binary.
3//!
4//! ```ignore
5//! # use std::sync::Arc;
6//! # fn db() -> Arc<dyn cratefield_core::Database> { unimplemented!() }
7//! # fn rate_limiter() -> Arc<dyn cratefield_core::RateLimiter> { unimplemented!() }
8//! # fn kv() -> Arc<dyn cratefield_core::KeyValue> { unimplemented!() }
9//! # fn mailer() -> Arc<dyn cratefield_core::Mailer> { unimplemented!() }
10//! use cratefield_runtime_native::Native;
11//!
12//! let runtime = Native::new()
13//!     .db_arc(db())                       // Postgres (adapter-postgres) or Sqlite
14//!     .rate_limiter_arc(rate_limiter())   // RedisRateLimiter, optional
15//!     .kv_arc(kv())                       // RedisKv, optional
16//!     .mailer_arc(mailer());              // Resend::from_env(..), as on Workers
17//! # let _runtime: Native = runtime;
18//! ```
19
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, Ordering};
22
23use cratefield_core::{
24    Auth, Blob, BoundedHttpClient, Captcha, Classifier, Clock, CustomHostnames, Database, Embedder,
25    HarnessConfig, KeyValue, Mailer, Payments, Port, Ports, Push, RateLimiter, Realtime, Runtime,
26    TextModel, Tracker, UlidIdGen, VectorIndex,
27};
28
29use crate::config::EnvConfig;
30use crate::ports::{ReqwestClient, SpawnDefer, TokioClock};
31
32static WARNED_SIGNER: AtomicBool = AtomicBool::new(false);
33
34fn warn_once(flag: &AtomicBool, message: &str) {
35    if !flag.load(Ordering::Relaxed) {
36        flag.store(true, Ordering::Relaxed);
37        tracing::warn!("{message}");
38    }
39}
40
41/// The native runtime. Adapters are passed in as instances (the same
42/// pattern as the Cloudflare runtime's `.mailer(..)`/`.captcha(..)`):
43/// a Postgres or SQLite [`Database`](cratefield_core::Database) from the
44/// matching adapter crate, a [`RedisRateLimiter`](crate::RedisRateLimiter)
45/// and [`RedisKv`](crate::RedisKv) over a shared Redis connection
46/// manager, `Resend`/`Turnstile` unchanged from the Workers path.
47///
48/// Config — including `HARNESS_SECRET` for the `Signer` port — is read
49/// from `std::env` through the same [`cratefield_core::Config`] trait
50/// ([`EnvConfig`]), so module keys behave identically on both runtimes.
51#[derive(Clone, Default)]
52pub struct Native {
53    db: Option<Arc<dyn Database>>,
54    rate_limiter: Option<Arc<dyn RateLimiter>>,
55    kv: Option<Arc<dyn KeyValue>>,
56    blob: Option<Arc<dyn Blob>>,
57    push: Option<Arc<dyn Push>>,
58    payments: Option<Arc<dyn Payments>>,
59    tracker: Option<Arc<dyn Tracker>>,
60    realtime: Option<Arc<dyn Realtime>>,
61    /// The `TextModel` port (issue #429): like `mailer`, the adapter is
62    /// built from the venture's vendor keys and passed in — there is no
63    /// vendor-neutral binding to sniff out of the environment.
64    text_model: Option<Arc<dyn TextModel>>,
65    /// The `Classifier` port (issue #456), the sibling of `text_model`:
66    /// the adapter is passed in the same way. An adapter that reaches a
67    /// Workers AI binding still arrives here as an instance, so the two
68    /// runtimes wire this port identically.
69    classifier: Option<Arc<dyn Classifier>>,
70    /// The `VectorIndex` port (issue #561): in-process
71    /// [`ExactVectorIndex`](cratefield_core::ExactVectorIndex) or a hosted
72    /// index's adapter, passed in like every other adapter here.
73    vector_index: Option<Arc<dyn VectorIndex>>,
74    /// The `Embedder` port (issue #561), the input side of `vector_index`.
75    embedder: Option<Arc<dyn Embedder>>,
76    /// The `CustomHostnames` port (issue #590): like `mailer`, the adapter
77    /// is passed in — the Cloudflare for `SaaS` adapter
78    /// (`cratefield-adapter-cloudflare-saas`) is portable over the
79    /// `HttpClient` port and runs here unchanged.
80    custom_hostnames: Option<Arc<dyn CustomHostnames>>,
81    mailer: Option<Arc<dyn Mailer>>,
82    captcha: Option<Arc<dyn Captcha>>,
83    auth: Option<Arc<dyn Auth>>,
84    /// Whether to assemble the `Auth` port from the environment.
85    auth_from_env: bool,
86    /// Whether to assemble the `Push` port from `std::env` (issue #191).
87    /// Built lazily in `ports()` and memoised for the process, so a venture
88    /// may call `push_from_env()` before a tokio runtime exists.
89    #[cfg(feature = "push")]
90    push_from_env: bool,
91    #[cfg(feature = "push")]
92    assembled_push: Arc<std::sync::OnceLock<(Arc<dyn Push>, cratefield_push_wiring::PushWiring)>>,
93}
94
95impl Native {
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    /// The `Database` port: `Postgres` (`cratefield-adapter-postgres`) or
101    /// `SqliteDatabase` (`cratefield-adapter-sqlite`) — any `Database`
102    /// implementation.
103    #[must_use]
104    pub fn db(mut self, db: impl Database + 'static) -> Self {
105        self.db = Some(Arc::new(db));
106        self
107    }
108
109    /// `db` for an already-shared database.
110    #[must_use]
111    pub fn db_arc(mut self, db: Arc<dyn Database>) -> Self {
112        self.db = Some(db);
113        self
114    }
115
116    /// The `RateLimiter` port: [`RedisRateLimiter`](crate::RedisRateLimiter) in this crate, or a
117    /// test fake.
118    #[must_use]
119    pub fn rate_limiter(mut self, limiter: impl RateLimiter + 'static) -> Self {
120        self.rate_limiter = Some(Arc::new(limiter));
121        self
122    }
123
124    /// `rate_limiter` for an already-shared limiter.
125    #[must_use]
126    pub fn rate_limiter_arc(mut self, limiter: Arc<dyn RateLimiter>) -> Self {
127        self.rate_limiter = Some(limiter);
128        self
129    }
130
131    /// The `KeyValue` port: [`RedisKv`](crate::RedisKv) in this crate, or a test fake.
132    #[must_use]
133    pub fn kv(mut self, kv: impl KeyValue + 'static) -> Self {
134        self.kv = Some(Arc::new(kv));
135        self
136    }
137
138    /// `kv` for an already-shared store.
139    #[must_use]
140    pub fn kv_arc(mut self, kv: Arc<dyn KeyValue>) -> Self {
141        self.kv = Some(kv);
142        self
143    }
144
145    /// The `Blob` port: a directory store ([`crate::DirBlob`]) or an
146    /// S3-compatible adapter.
147    #[must_use]
148    pub fn blob_arc(mut self, blob: Arc<dyn Blob>) -> Self {
149        self.blob = Some(blob);
150        self
151    }
152
153    /// The `Push` port: an APNs adapter (`cratefield-adapter-apns`) or a test
154    /// fake. Notifications reach a device over the runtime's `HttpClient`.
155    #[must_use]
156    pub fn push_arc(mut self, push: Arc<dyn Push>) -> Self {
157        self.push = Some(push);
158        self
159    }
160
161    /// Assembles the `Push` port from the process environment instead of
162    /// taking an adapter (issue #191): one `RoutingPush` over whichever of
163    /// APNs, FCM and Web Push the deployment configured, built by the same
164    /// `cratefield_push_wiring::build_push` the Workers runtime, `fz push`
165    /// and `fz doctor` call, so the four cannot drift apart on a variable
166    /// name.
167    ///
168    /// The port is then always provided, even with nothing configured: the
169    /// router answers `NotConfigured` for every recipient, exactly as an
170    /// unconfigured adapter does. `serve_on` logs the resulting
171    /// [`PushWiring`](cratefield_push_wiring::PushWiring) report once at cold
172    /// start, and an explicit `.push_arc(..)` still wins.
173    ///
174    /// # This is a declaration, and `fz doctor` is what checks it
175    ///
176    /// Declaring the port rather than gating it on what `std::env` holds at
177    /// build time is deliberate, and it keeps this runtime's answer the same
178    /// as the Workers one's: there a Worker's `Env` does not exist when
179    /// `Harness::build` runs, so a venture that built here and refused there
180    /// (or the reverse) is the divergence issue #143 was about.
181    ///
182    /// `fz doctor` is the gate that used to be the build's. It runs where
183    /// the deployment's environment is known, and refuses a production
184    /// venture whose modules require push and whose environment routes no
185    /// transport at all (issue #191).
186    #[cfg(feature = "push")]
187    #[must_use]
188    pub fn push_from_env(mut self) -> Self {
189        self.push_from_env = true;
190        self
191    }
192
193    /// The environment-assembled `Push` port and its report, built once per
194    /// process.
195    ///
196    /// `None` when the venture did not ask for it — **and** when it passed
197    /// an explicit adapter, which wins: assembling a stack of adapters
198    /// nothing will serve with costs a `.p8` parse, an RSA parse and a VAPID
199    /// scalar, and then reports on transports the venture deliberately
200    /// overrode.
201    #[cfg(feature = "push")]
202    fn env_push(&self) -> Option<&(Arc<dyn Push>, cratefield_push_wiring::PushWiring)> {
203        if !self.push_from_env || self.push.is_some() {
204            return None;
205        }
206        Some(self.assembled_push.get_or_init(|| {
207            let clock: Arc<dyn Clock> = Arc::new(TokioClock);
208            let http: Arc<dyn cratefield_core::HttpClient> = Arc::new(BoundedHttpClient::new(
209                Arc::new(ReqwestClient::new()),
210                Arc::clone(&clock),
211            ));
212            cratefield_push_wiring::build_push(&EnvConfig, &http, &clock)
213        }))
214    }
215
216    /// Which push transports this deployment configured. `serve_on` logs
217    /// this at cold start; nothing else needs it.
218    ///
219    /// `None` when the venture did not call
220    /// [`push_from_env`](Self::push_from_env), or passed an explicit adapter
221    /// that wins over it — there is nothing to report about an environment
222    /// nothing reads.
223    ///
224    /// Call it *after* `ports()`, as `serve_on` does: `ports()` has then
225    /// already assembled and memoised the adapters, and this is the getter
226    /// it looks like.
227    #[cfg(feature = "push")]
228    #[must_use]
229    pub fn push_wiring(&self) -> Option<&cratefield_push_wiring::PushWiring> {
230        self.env_push().map(|(_, wiring)| wiring)
231    }
232
233    /// The `Payments` port: a Stripe adapter (`cratefield-adapter-stripe`) or a
234    /// test fake, over the runtime's `HttpClient`.
235    #[must_use]
236    pub fn payments_arc(mut self, payments: Arc<dyn Payments>) -> Self {
237        self.payments = Some(payments);
238        self
239    }
240
241    /// The `Tracker` port: a [`cratefield_core::RoutingTracker`] over the
242    /// per-tracker adapters the venture configured, or a test fake.
243    ///
244    /// Unlike every other adapter on this harness, the adapter holds no
245    /// credential: which tracker to file into and under whose token are
246    /// tenant data, supplied per call by the module that files — see
247    /// [`cratefield_core::Tracker`] for why that differs from the
248    /// Resend/Stripe shape.
249    #[must_use]
250    pub fn tracker_arc(mut self, tracker: Arc<dyn Tracker>) -> Self {
251        self.tracker = Some(tracker);
252        self
253    }
254
255    /// The `Realtime` port: the in-process room registry
256    /// ([`crate::InProcessRealtime`]) built from the module's `RoomHandler`.
257    #[must_use]
258    pub fn realtime_arc(mut self, realtime: Arc<dyn Realtime>) -> Self {
259        self.realtime = Some(realtime);
260        self
261    }
262
263    /// The `TextModel` port (issue #429): an adapter over the venture's
264    /// vendor of choice for each [`ModelTier`](cratefield_core::ModelTier),
265    /// or one `RoutingTextModel` over both tiers. The same pattern as
266    /// `mailer`.
267    #[must_use]
268    pub fn text_model(mut self, text_model: impl TextModel + 'static) -> Self {
269        self.text_model = Some(Arc::new(text_model));
270        self
271    }
272
273    /// `text_model` for an already-shared adapter.
274    #[must_use]
275    pub fn text_model_arc(mut self, text_model: Arc<dyn TextModel>) -> Self {
276        self.text_model = Some(text_model);
277        self
278    }
279
280    /// The `Classifier` port (issue #456): a typed, calibrated decision —
281    /// "which of these is it, and how sure are you" — from a purpose-built
282    /// classifier, a Workers AI binding (on the Workers twin) or an LLM
283    /// behind the `TextModel` port. The same pattern as `text_model`.
284    #[must_use]
285    pub fn classifier(mut self, classifier: impl Classifier + 'static) -> Self {
286        self.classifier = Some(Arc::new(classifier));
287        self
288    }
289
290    /// `classifier` for an already-shared adapter.
291    #[must_use]
292    pub fn classifier_arc(mut self, classifier: Arc<dyn Classifier>) -> Self {
293        self.classifier = Some(classifier);
294        self
295    }
296
297    /// The `VectorIndex` port (issue #561): nearest-neighbour search over
298    /// the namespaces a module asks for — in-process
299    /// [`ExactVectorIndex`](cratefield_core::ExactVectorIndex) or a hosted
300    /// index's adapter.
301    #[must_use]
302    pub fn vector_index_arc(mut self, index: Arc<dyn VectorIndex>) -> Self {
303        self.vector_index = Some(index);
304        self
305    }
306
307    /// The `Embedder` port (issue #561): text into embedding vectors, as
308    /// wide as the `vector_index` wired alongside it expects.
309    #[must_use]
310    pub fn embedder_arc(mut self, embedder: Arc<dyn Embedder>) -> Self {
311        self.embedder = Some(embedder);
312        self
313    }
314
315    /// The `CustomHostnames` port (issue #590): a customer's own hostname
316    /// served by a venture. Like `mailer`, the adapter is built from the
317    /// venture's vendor keys and passed in — the Cloudflare for `SaaS`
318    /// adapter is `cratefield-adapter-cloudflare-saas`, portable over the
319    /// `HttpClient` port.
320    #[must_use]
321    pub fn custom_hostnames(mut self, custom_hostnames: impl CustomHostnames + 'static) -> Self {
322        self.custom_hostnames = Some(Arc::new(custom_hostnames));
323        self
324    }
325
326    /// `custom_hostnames` for an already-shared adapter.
327    #[must_use]
328    pub fn custom_hostnames_arc(mut self, custom_hostnames: Arc<dyn CustomHostnames>) -> Self {
329        self.custom_hostnames = Some(custom_hostnames);
330        self
331    }
332
333    #[must_use]
334    pub fn mailer(mut self, mailer: impl Mailer + 'static) -> Self {
335        self.mailer = Some(Arc::new(mailer));
336        self
337    }
338
339    #[must_use]
340    pub fn mailer_arc(mut self, mailer: Arc<dyn Mailer>) -> Self {
341        self.mailer = Some(mailer);
342        self
343    }
344
345    #[must_use]
346    pub fn captcha(mut self, captcha: impl Captcha + 'static) -> Self {
347        self.captcha = Some(Arc::new(captcha));
348        self
349    }
350
351    #[must_use]
352    pub fn captcha_arc(mut self, captcha: Arc<dyn Captcha>) -> Self {
353        self.captcha = Some(captcha);
354        self
355    }
356
357    /// Wires who a request's credentials speak for (issue #153).
358    ///
359    /// Without one the deployment cannot identify a caller, and a module
360    /// that declares [`Port::Auth`] is refused composition rather than
361    /// mounted and left guessing.
362    #[must_use]
363    pub fn auth(mut self, auth: impl Auth + 'static) -> Self {
364        self.auth = Some(Arc::new(auth));
365        self
366    }
367
368    #[must_use]
369    pub fn auth_arc(mut self, auth: Arc<dyn Auth>) -> Self {
370        self.auth = Some(auth);
371        self
372    }
373
374    /// Assembles the `Auth` port from `AUTH_ISSUER` and `AUTH_CLIENT_ID`.
375    ///
376    /// The port is provided either way, matching the Cloudflare runtime:
377    /// a venture generated by `fz build` wires this whenever one of its
378    /// tables is not `public-read`, and the two runtimes answering
379    /// differently for the same venture would be a difference between
380    /// two deployments of one manifest.
381    #[must_use]
382    pub fn auth_from_env(mut self) -> Self {
383        self.auth_from_env = true;
384        self
385    }
386
387    /// Resolves the port bundle once per process from the builder and
388    /// `std::env`: config, `Signer` (from `HARNESS_SECRET` — warn once
389    /// and leave unset when missing, exactly like the Cloudflare
390    /// runtime), `HttpClient` over reqwest, `Clock` over the system
391    /// clock, `IdGen`, and `Defer` over `tokio::spawn`.
392    ///
393    /// Unlike the Workers runtime — which resolves bindings per event —
394    /// a native process builds one bundle and shares it: every adapter
395    /// here is an `Arc`, so `clone_ports` snapshots cheaply.
396    #[must_use]
397    pub fn ports(&self) -> Ports {
398        let config: Arc<dyn cratefield_core::Config> = Arc::new(EnvConfig);
399        let mut ports = Ports::with_config(Arc::clone(&config));
400
401        ports.db.clone_from(&self.db);
402        ports.rate_limiter.clone_from(&self.rate_limiter);
403        ports.kv.clone_from(&self.kv);
404        ports.blob.clone_from(&self.blob);
405        ports.push.clone_from(&self.push);
406        ports.payments.clone_from(&self.payments);
407        ports.tracker.clone_from(&self.tracker);
408        ports.realtime.clone_from(&self.realtime);
409        ports.text_model.clone_from(&self.text_model);
410        ports.classifier.clone_from(&self.classifier);
411        ports.vector_index.clone_from(&self.vector_index);
412        ports.embedder.clone_from(&self.embedder);
413        ports.custom_hostnames.clone_from(&self.custom_hostnames);
414        ports.mailer.clone_from(&self.mailer);
415        ports.captcha.clone_from(&self.captcha);
416        ports.auth.clone_from(&self.auth);
417
418        // An explicit adapter wins; otherwise assemble one from the
419        // environment when the venture asked for it (issue #191).
420        #[cfg(feature = "push")]
421        if ports.push.is_none()
422            && let Some((push, _)) = self.env_push()
423        {
424            ports.push = Some(Arc::clone(push));
425        }
426
427        match HarnessConfig::from_config(&*config) {
428            Ok(parsed) => {
429                // Pseudonymise logged emails with a key derived from the
430                // harness secret, not a bare (reversible) hash — issue #135.
431                cratefield_core::set_log_pseudonym_key(parsed.harness_secret.as_bytes());
432                ports.signer = Some(Arc::new(parsed.signer()));
433            }
434            Err(err) => warn_once(&WARNED_SIGNER, &format!("Signer port not provided: {err}")),
435        }
436
437        let clock: Arc<dyn Clock> = Arc::new(TokioClock);
438        ports.clock = Some(Arc::clone(&clock));
439        let http: Arc<dyn cratefield_core::HttpClient> = Arc::new(BoundedHttpClient::new(
440            Arc::new(ReqwestClient::new()),
441            Arc::clone(&clock),
442        ));
443        ports.http = Some(Arc::clone(&http));
444        // After the clock and the HTTP client, which the verifier needs
445        // to fetch the issuer's key set.
446        if ports.auth.is_none() && self.auth_from_env {
447            ports.auth = Some(cratefield_auth_client::from_config(
448                ports.config.as_ref(),
449                http,
450                clock,
451            ));
452        }
453        ports.id_gen = Some(Arc::new(UlidIdGen));
454        ports.defer = Some(Arc::new(SpawnDefer));
455        ports
456    }
457}
458
459impl Runtime for Native {
460    /// The static set used by `Harness::build`. `Signer` is included:
461    /// its presence is validated at `ports()` time (and by `fz doctor`),
462    /// mirroring the Cloudflare runtime.
463    fn provides(&self) -> Vec<Port> {
464        let mut provided = vec![
465            Port::Signer,
466            Port::HttpClient,
467            Port::Clock,
468            Port::IdGen,
469            Port::Defer,
470        ];
471        if self.db.is_some() {
472            provided.push(Port::Db);
473        }
474        // Provided whatever the environment holds — see `auth_from_env`.
475        if self.auth.is_some() || self.auth_from_env {
476            provided.push(Port::Auth);
477        }
478        if self.rate_limiter.is_some() {
479            provided.push(Port::RateLimiter);
480        }
481        if self.kv.is_some() {
482            provided.push(Port::KeyValue);
483        }
484        if self.blob.is_some() {
485            provided.push(Port::Blob);
486        }
487        // `push_from_env` provides the port whatever the environment holds:
488        // with nothing configured the router answers `NotConfigured` for
489        // every recipient, which is a provided port that sends nothing, not
490        // an absent one (issue #191).
491        //
492        // `std::env` is readable here, unlike on Workers — and consulting it
493        // is still the wrong move: `provides()` would then answer one thing
494        // in CI and another on the box, and a venture would build here and
495        // refuse to build on its Workers twin, which is the divergence
496        // issue #143 was about. `fz doctor` carries the refusal instead, on
497        // both runtimes, where the deployment's environment is known.
498        #[cfg(feature = "push")]
499        let push_provided = self.push.is_some() || self.push_from_env;
500        #[cfg(not(feature = "push"))]
501        let push_provided = self.push.is_some();
502        if push_provided {
503            provided.push(Port::Push);
504        }
505        if self.payments.is_some() {
506            provided.push(Port::Payments);
507        }
508        if self.tracker.is_some() {
509            provided.push(Port::Tracker);
510        }
511        if self.realtime.is_some() {
512            provided.push(Port::Realtime);
513        }
514        if self.text_model.is_some() {
515            provided.push(Port::TextModel);
516        }
517        if self.classifier.is_some() {
518            provided.push(Port::Classifier);
519        }
520        if self.vector_index.is_some() {
521            provided.push(Port::VectorIndex);
522        }
523        if self.embedder.is_some() {
524            provided.push(Port::Embedder);
525        }
526        if self.custom_hostnames.is_some() {
527            provided.push(Port::CustomHostnames);
528        }
529        if self.mailer.is_some() {
530            provided.push(Port::Mailer);
531        }
532        if self.captcha.is_some() {
533            provided.push(Port::Captcha);
534        }
535        provided
536    }
537
538    /// The same production gate the Cloudflare runtime applies
539    /// (issue #133), which this runtime never did (issue #143): a
540    /// Turnstile adapter that is not hostname-bound, or left fail-open,
541    /// cannot protect a production `HumanForm` route, so the port is
542    /// present but not usable. Without this a self-hosted venture booted
543    /// past a check its Workers twin refuses. Adapters that do not report
544    /// (`binding() == None`) count as effective when present: presence is
545    /// all the runtime can know about them.
546    fn effectively_configured(&self, port: Port) -> bool {
547        match port {
548            Port::Captcha => self.captcha.as_ref().is_some_and(|captcha| {
549                captcha
550                    .binding()
551                    .is_none_or(|binding| binding.hostname_bound && !binding.fail_open)
552            }),
553            _ => self.provides().contains(&port),
554        }
555    }
556}
557
558/// Snapshots a [`Ports`] bundle by cloning the `Arc` of every [`Port`]
559/// and the config (core's `Ports` is not `Clone`; every field is a shared
560/// handle, so this is the whole job). `dispatcher` and `tenants` are not
561/// ports and are left `None`. Used to give the cron scheduler the same
562/// adapters the HTTP router serves with.
563pub(crate) fn clone_ports(ports: &Ports) -> Ports {
564    let mut snapshot = Ports::with_config(Arc::clone(&ports.config));
565    snapshot.db.clone_from(&ports.db);
566    snapshot.auth.clone_from(&ports.auth);
567    snapshot.mailer.clone_from(&ports.mailer);
568    snapshot.captcha.clone_from(&ports.captcha);
569    snapshot.rate_limiter.clone_from(&ports.rate_limiter);
570    snapshot.signer.clone_from(&ports.signer);
571    snapshot.kv.clone_from(&ports.kv);
572    snapshot.blob.clone_from(&ports.blob);
573    snapshot.push.clone_from(&ports.push);
574    snapshot.payments.clone_from(&ports.payments);
575    snapshot.tracker.clone_from(&ports.tracker);
576    snapshot.realtime.clone_from(&ports.realtime);
577    snapshot.text_model.clone_from(&ports.text_model);
578    snapshot.classifier.clone_from(&ports.classifier);
579    snapshot.vector_index.clone_from(&ports.vector_index);
580    snapshot.embedder.clone_from(&ports.embedder);
581    snapshot
582        .custom_hostnames
583        .clone_from(&ports.custom_hostnames);
584    snapshot.http.clone_from(&ports.http);
585    snapshot.clock.clone_from(&ports.clock);
586    snapshot.id_gen.clone_from(&ports.id_gen);
587    snapshot.defer.clone_from(&ports.defer);
588    snapshot.actors.clone_from(&ports.actors);
589    snapshot
590}
591
592#[cfg(test)]
593mod tests {
594    use super::clone_ports;
595    use cratefield_core::{Port, Runtime};
596    use std::sync::Arc;
597
598    #[test]
599    fn clone_ports_carries_every_port() {
600        // Walks `Port::ALL`, so a port added to the enum and not copied
601        // here fails this — the way `auth` was dropped (issue #449).
602        let source = cratefield_testing::full_fake_ports();
603        assert!(
604            Port::ALL.iter().all(|port| source.has(*port)),
605            "the source bundle must wire every port for this to prove anything"
606        );
607        let snapshot = clone_ports(&source);
608        for port in Port::ALL {
609            assert!(snapshot.has(*port), "clone_ports drops {}", port.name());
610        }
611    }
612
613    #[test]
614    fn the_vector_ports_are_provided_only_when_wired() {
615        // `ExactVectorIndex` and the kit's `FakeEmbedder` are real ports.
616        let wired = super::Native::new()
617            .vector_index_arc(Arc::new(cratefield_core::ExactVectorIndex::new(3)))
618            .embedder_arc(Arc::new(cratefield_testing::FakeEmbedder));
619        assert!(wired.provides().contains(&Port::VectorIndex));
620        assert!(wired.provides().contains(&Port::Embedder));
621        let bare = super::Native::new();
622        assert!(!bare.provides().contains(&Port::VectorIndex));
623        assert!(!bare.provides().contains(&Port::Embedder));
624    }
625
626    #[test]
627    fn custom_hostnames_is_provided_only_when_wired() {
628        let wired = super::Native::new().custom_hostnames(
629            cratefield_testing::FakeCustomHostnames::new("cratefield.app"),
630        );
631        assert!(wired.provides().contains(&Port::CustomHostnames));
632        let bare = super::Native::new();
633        assert!(!bare.provides().contains(&Port::CustomHostnames));
634    }
635}