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}