Skip to main content

aimdb_core/
connector.rs

1//! Connector infrastructure for external protocol integration
2//!
3//! Provides the `.link_to()` / `.link_from()` builder API for ergonomic
4//! connector setup with automatic client lifecycle management. Connectors
5//! bridge AimDB records to external systems (MQTT, KNX, WebSocket, …).
6//!
7//! # Design Philosophy
8//!
9//! - **User Extensions**: Connector implementations are provided by users
10//! - **Shared Clients**: Single client instance shared across tasks (Arc or static)
11//! - **No Buffering**: Direct access to protocol clients, no intermediate queues
12//! - **Type Safety**: Compile-time guarantees via typed handlers
13//!
14//! # Example
15//!
16//! ```no_run
17//! # use aimdb_core::AimDbBuilder;
18//! # #[derive(Clone, Debug)] struct WeatherAlert { level: u8 }
19//! # fn wire(builder: &mut AimDbBuilder) {
20//! builder.configure::<WeatherAlert>("weather.alert", |reg| {
21//!     // .buffer(BufferCfg::SingleLatest) — via your runtime adapter's ext trait
22//!     reg.link_to("mqtt://alerts/weather")
23//!         .with_serializer(|_ctx, alert: &WeatherAlert| Ok(vec![alert.level]))
24//!         .finish();
25//! });
26//! # }
27//! ```
28
29use core::fmt::{self, Debug};
30use core::future::Future;
31use core::pin::Pin;
32
33use alloc::{
34    boxed::Box,
35    string::{String, ToString},
36    sync::Arc,
37    vec::Vec,
38};
39
40use alloc::format;
41
42use crate::{builder::AimDb, DbResult};
43
44/// Error shared by outbound record serialization operations.
45///
46/// Deliberately separate from the session `CodecError`, which describes
47/// failures in an envelope codec (AimX, WebSocket, and similar framed
48/// protocols). A link codec sits one layer higher: it turns a typed record into
49/// the opaque payload passed to a connector.
50///
51/// Uses an enum instead of `String` for predictable `no_std` behavior and
52/// `defmt` logging support on Embassy targets.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum SerializeError {
55    /// Output buffer is too small for the serialized data
56    BufferTooSmall,
57
58    /// Invalid data that cannot be serialized
59    InvalidData,
60}
61
62#[cfg(feature = "defmt")]
63impl defmt::Format for SerializeError {
64    fn format(&self, f: defmt::Formatter) {
65        match self {
66            Self::BufferTooSmall => defmt::write!(f, "BufferTooSmall"),
67            Self::InvalidData => defmt::write!(f, "InvalidData"),
68        }
69    }
70}
71
72#[cfg(feature = "std")]
73impl std::fmt::Display for SerializeError {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::BufferTooSmall => write!(f, "Output buffer too small"),
77            Self::InvalidData => write!(f, "Invalid data for serialization"),
78        }
79    }
80}
81
82#[cfg(feature = "std")]
83impl std::error::Error for SerializeError {}
84
85/// One serialized record update, produced by a fused [`SerializedReader`]
86///
87/// Carries the wire payload plus the destination resolved by the link's
88/// [`TopicProvider`] while the typed value was still in hand — the last
89/// erasure crossing the old `topic_any(&dyn Any)` path required.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct SerializedValue {
92    /// Dynamic destination resolved by the link's `TopicProvider<T>`;
93    /// `None` means use the route's default topic (from the URL).
94    pub dest: Option<String>,
95    /// Wire payload from the link's serializer.
96    ///
97    /// `Vec<u8>` requires heap allocation; works on `std` and
98    /// `no_std + alloc` (not bare-metal without an allocator).
99    pub payload: Vec<u8>,
100}
101
102/// Location of a payload produced by [`SerializedReader::recv_into`].
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum SerializedPayload {
105    /// The initialized prefix of the caller-provided scratch buffer.
106    ///
107    /// A custom reader returning this variant must write the prefix during the
108    /// same `recv_into` call and its source must advertise a sufficient
109    /// [`SerializedSource::serializer_scratch_capacity`]. The pump validates
110    /// the length before publishing.
111    Scratch {
112        /// Number of initialized payload bytes in the scratch buffer.
113        len: usize,
114    },
115    /// Compatibility fallback from the existing `Vec` serializer.
116    Owned(Vec<u8>),
117}
118
119/// One serialized record produced into caller-owned scratch storage or an owned fallback.
120///
121/// No reference escapes the async reader call. The pump validates `len`, then
122/// borrows its own scratch buffer only for the subsequent `publish().await`.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct SerializedValueInto {
125    /// Dynamic destination resolved while the typed record was available.
126    pub dest: Option<String>,
127    /// Scratch prefix metadata or an owned compatibility payload.
128    pub payload: SerializedPayload,
129}
130
131/// Type alias for the future returned by [`SerializedReader::recv`]
132///
133/// Manual boxed future for object safety — same pattern as the rest of this
134/// module (`#[async_trait]` would drag in `std`).
135pub type RecvSerializedFuture<'a> =
136    Pin<Box<dyn Future<Output = DbResult<SerializedValue>> + Send + 'a>>;
137
138/// Future returned by [`SerializedReader::recv_into`].
139pub type RecvSerializedIntoFuture<'a> =
140    Pin<Box<dyn Future<Output = DbResult<SerializedValueInto>> + Send + 'a>>;
141
142/// A subscription to one record, fused with destination resolution and
143/// serialization at registration time — no `dyn Any` crosses this boundary
144///.
145pub trait SerializedReader: Send {
146    /// Yield the next successfully serialized value.
147    ///
148    /// `ctx` is threaded per call (not captured) for context-aware
149    /// serializers. Buffer errors propagate unchanged:
150    /// `DbError::BufferLagged` means values were skipped but the reader
151    /// recovered; any other error means the buffer is gone. Serialization
152    /// failures are logged and skipped inside the reader.
153    fn recv<'a>(&'a mut self, ctx: &'a crate::RuntimeContext) -> RecvSerializedFuture<'a>;
154
155    /// Yield the next serialized value into caller-owned scratch storage.
156    ///
157    /// Third-party readers remain source-compatible: the default adapter calls
158    /// [`recv`](Self::recv) and returns its `Vec<u8>` as
159    /// [`SerializedPayload::Owned`]. AimDB's fused reader overrides this method
160    /// when an into-slice serializer was registered.
161    fn recv_into<'a>(
162        &'a mut self,
163        ctx: &'a crate::RuntimeContext,
164        _scratch: &'a mut [u8],
165    ) -> RecvSerializedIntoFuture<'a> {
166        Box::pin(async move {
167            let value = self.recv(ctx).await?;
168            Ok(SerializedValueInto {
169                dest: value.dest,
170                payload: SerializedPayload::Owned(value.payload),
171            })
172        })
173    }
174}
175
176/// A record's outbound wire interface, built where the record type `T` is
177/// known (`OutboundConnectorBuilder::finish`) and consumed by the pumps as
178/// bytes. Replaces the erased `ConsumerTrait` + serializer + topic-provider
179/// triple.
180pub trait SerializedSource: Send + Sync {
181    /// Scratch capacity requested by this source's into-slice serializer.
182    ///
183    /// `None` means the source only supports the existing owned serializer. A
184    /// source whose reader can return [`SerializedPayload::Scratch`] must
185    /// return `Some(capacity)` here so the route pump provides that storage.
186    fn serializer_scratch_capacity(&self) -> Option<usize> {
187        None
188    }
189
190    /// Subscribe to the record's updates.
191    ///
192    /// Synchronous and infallible — the buffer handle is pre-resolved at
193    /// construction.
194    fn subscribe(&self) -> Box<dyn SerializedReader>;
195}
196
197/// Type alias for source factory callback (alloc feature)
198///
199/// Takes the live [`AimDb`] and returns the fused [`SerializedSource`].
200/// This allows capturing the record type T at link_to() time while storing
201/// the factory in a type-erased ConnectorLink. The factory runs once at
202/// route-collection time, not per message.
203///
204/// Available in both `std` and `no_std + alloc` environments.
205pub type SourceFactoryFn = Arc<dyn Fn(&AimDb) -> Box<dyn SerializedSource> + Send + Sync>;
206
207// ============================================================================
208// TopicProvider - Dynamic topic/destination routing
209// ============================================================================
210
211/// Trait for dynamic topic providers (outbound only)
212///
213/// Implement this trait to dynamically determine MQTT topics (or KNX group addresses)
214/// based on the data being published. This enables reusable routing logic that
215/// can be shared across multiple record types.
216///
217/// # Type Safety
218///
219/// The trait is generic over `T`, providing compile-time type safety
220/// at the implementation site. The provider stays typed end-to-end: it is
221/// fused into the link's [`SerializedSource`] at registration time and
222/// called with `&T` while the value is in hand.
223///
224/// # no_std Compatibility
225///
226/// Works in both `std` and `no_std + alloc` environments.
227///
228/// # Example
229///
230/// ```rust
231/// use aimdb_core::connector::TopicProvider;
232/// # #[derive(Clone, Debug)] struct Temperature { sensor_id: u32 }
233///
234/// struct SensorTopicProvider;
235///
236/// impl TopicProvider<Temperature> for SensorTopicProvider {
237///     fn topic(&self, value: &Temperature) -> Option<String> {
238///         Some(format!("sensors/temp/{}", value.sensor_id))
239///     }
240/// }
241/// ```
242pub trait TopicProvider<T>: Send + Sync {
243    /// Determine the topic/destination for a given value
244    ///
245    /// Returns `Some(topic)` to use a dynamic topic, or `None` to fall back
246    /// to the static topic from the `link_to()` URL.
247    fn topic(&self, value: &T) -> Option<String>;
248}
249
250/// Address of a record link: `scheme://resource` (e.g. `mqtt://sensors/temp`).
251///
252/// A link address names a topic/resource on a connector's already-configured
253/// endpoint — it is *not* a URL: there is no host, port, or credential
254/// component (the connector constructor takes the endpoint
255/// [`ConnectorUrl`]). The scheme selects which registered connector serves
256/// the link; the resource is passed to it verbatim (e.g. an MQTT topic, a
257/// KNX group address).
258///
259/// A trailing `?key=value` query is accepted and ignored; per-link options
260/// are passed via `.with_config()` / `.with_timeout_ms()`.
261#[derive(Clone, Debug, PartialEq)]
262pub struct LinkAddress {
263    scheme: String,
264    resource: String,
265}
266
267impl LinkAddress {
268    /// Parses a `scheme://resource` link address.
269    ///
270    /// # Example
271    ///
272    /// ```rust
273    /// use aimdb_core::connector::LinkAddress;
274    ///
275    /// let addr = LinkAddress::parse("mqtt://sensors/temp").unwrap();
276    /// assert_eq!(addr.scheme(), "mqtt");
277    /// assert_eq!(addr.resource_id(), "sensors/temp");
278    /// ```
279    pub fn parse(s: &str) -> DbResult<Self> {
280        use crate::DbError;
281
282        let (scheme, rest) = s
283            .split_once("://")
284            .ok_or_else(|| DbError::InvalidOperation {
285                operation: "LinkAddress::parse".into(),
286                reason: alloc::format!("link address '{s}' missing '://' separator"),
287            })?;
288
289        // Per-link options travel via .with_config(); a query here is legal
290        // input but carries no meaning — strip it.
291        let resource = rest.split_once('?').map(|(r, _)| r).unwrap_or(rest);
292        let resource = resource.trim_start_matches('/');
293
294        if scheme.is_empty() || resource.is_empty() {
295            return Err(DbError::InvalidOperation {
296                operation: "LinkAddress::parse".into(),
297                reason: alloc::format!("link address '{s}' needs both a scheme and a resource"),
298            });
299        }
300
301        Ok(Self {
302            scheme: scheme.to_string(),
303            resource: resource.to_string(),
304        })
305    }
306
307    /// Returns the scheme (selects the registered connector, e.g. `"mqtt"`).
308    pub fn scheme(&self) -> &str {
309        &self.scheme
310    }
311
312    /// Returns the resource identifier the connector addresses (topic, group
313    /// address, path — passed to the connector verbatim).
314    pub fn resource_id(&self) -> &str {
315        &self.resource
316    }
317}
318
319impl fmt::Display for LinkAddress {
320    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321        write!(f, "{}://{}", self.scheme, self.resource)
322    }
323}
324
325/// Parsed connector *endpoint* URL with protocol, host, port, and credentials
326///
327/// This is the URL a connector is constructed with (broker/gateway/server
328/// endpoint) — record links use the far simpler [`LinkAddress`] instead.
329/// The parser is scheme-agnostic: any `scheme://…` URL parses. Connectors in
330/// this workspace use e.g.:
331/// - MQTT: `mqtt://host:port`, `mqtts://host:port`
332/// - KNX: `knx://gateway:3671`
333/// - WebSocket: `ws://host:port/path`, `wss://host:port/path`
334#[derive(Clone, Debug, PartialEq)]
335pub struct ConnectorUrl {
336    /// Protocol scheme (e.g. mqtt, mqtts, knx, ws, wss, uds, serial)
337    pub scheme: String,
338
339    /// Host, or a comma-separated host list (preserved verbatim)
340    pub host: String,
341
342    /// Port number (optional, protocol-specific defaults)
343    pub port: Option<u16>,
344
345    /// Path component (optional)
346    pub path: Option<String>,
347
348    /// Username for authentication (optional)
349    pub username: Option<String>,
350
351    /// Password for authentication (optional)
352    pub password: Option<String>,
353
354    /// Query parameters (optional, parsed from URL)
355    pub query_params: Vec<(String, String)>,
356}
357
358impl ConnectorUrl {
359    /// Parses a connector URL string
360    ///
361    /// # Supported Formats
362    ///
363    /// - `mqtt://host:port`
364    /// - `mqtt://user:pass@host:port`
365    /// - `mqtts://host:port` (TLS)
366    /// - `knx://gateway:3671`
367    /// - `ws://host:port/path?key=value` (WebSocket)
368    /// - `wss://host:port/path` (WebSocket Secure)
369    ///
370    /// Any other `scheme://…` parses the same way; comma-separated host
371    /// lists are preserved verbatim in [`host`](ConnectorUrl::host).
372    ///
373    /// # Example
374    ///
375    /// ```rust
376    /// use aimdb_core::connector::ConnectorUrl;
377    ///
378    /// let url = ConnectorUrl::parse("mqtt://user:pass@broker.example.com:1883").unwrap();
379    /// assert_eq!(url.scheme, "mqtt");
380    /// assert_eq!(url.host, "broker.example.com");
381    /// assert_eq!(url.port, Some(1883));
382    /// assert_eq!(url.username, Some("user".to_string()));
383    /// ```
384    pub fn parse(url: &str) -> DbResult<Self> {
385        parse_connector_url(url)
386    }
387
388    /// Returns the default port for this protocol scheme
389    pub fn default_port(&self) -> Option<u16> {
390        match self.scheme.as_str() {
391            "mqtt" | "ws" => Some(1883),
392            "mqtts" | "wss" => Some(8883),
393            _ => None,
394        }
395    }
396
397    /// Returns the effective port (explicit or default)
398    pub fn effective_port(&self) -> Option<u16> {
399        self.port.or_else(|| self.default_port())
400    }
401
402    /// Returns true if this is a secure connection (TLS)
403    pub fn is_secure(&self) -> bool {
404        matches!(self.scheme.as_str(), "mqtts" | "https" | "wss")
405    }
406
407    /// Returns the URL scheme (protocol)
408    pub fn scheme(&self) -> &str {
409        &self.scheme
410    }
411
412    /// Returns the path component, or "/" if not specified
413    pub fn path(&self) -> &str {
414        self.path.as_deref().unwrap_or("/")
415    }
416}
417
418impl fmt::Display for ConnectorUrl {
419    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420        write!(f, "{}://", self.scheme)?;
421
422        if let Some(ref username) = self.username {
423            write!(f, "{}", username)?;
424            if self.password.is_some() {
425                write!(f, ":****")?; // Don't expose password in Display
426            }
427            write!(f, "@")?;
428        }
429
430        write!(f, "{}", self.host)?;
431
432        if let Some(port) = self.port {
433            write!(f, ":{}", port)?;
434        }
435
436        if let Some(ref path) = self.path {
437            if !path.starts_with('/') {
438                write!(f, "/")?;
439            }
440            write!(f, "{}", path)?;
441        }
442
443        Ok(())
444    }
445}
446
447/// Configuration for a connector link
448///
449/// Stores the parsed URL, configuration, and the fused source factory until
450/// the record is built. The actual client creation and handler spawning
451/// happens during the build phase.
452#[derive(Clone)]
453pub struct ConnectorLink {
454    /// Parsed link address (`scheme://resource`)
455    pub url: LinkAddress,
456
457    /// Additional configuration options (protocol-specific)
458    pub config: Vec<(String, String)>,
459
460    /// Fused source factory (alloc feature)
461    ///
462    /// Takes the live [`AimDb`] and returns the [`SerializedSource`] whose
463    /// readers yield destination + payload directly (subscribe → recv →
464    /// resolve topic → serialize, all typed inside). Captures the record
465    /// type T at link_to() configuration time — `finish()` validates the
466    /// serializer is present before registering the link, so the factory is
467    /// always set.
468    ///
469    /// Available in both `std` and `no_std + alloc` environments.
470    pub source_factory: SourceFactoryFn,
471}
472
473impl Debug for ConnectorLink {
474    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
475        f.debug_struct("ConnectorLink")
476            .field("url", &self.url)
477            .field("config", &self.config)
478            .field("source_factory", &"<factory>")
479            .finish()
480    }
481}
482
483impl ConnectorLink {
484    /// Creates a new connector link from a link address and source factory
485    pub fn new(url: LinkAddress, source_factory: SourceFactoryFn) -> Self {
486        Self {
487            url,
488            config: Vec::new(),
489            source_factory,
490        }
491    }
492
493    /// Creates the fused serialized source using the stored factory.
494    ///
495    /// Runs once at route-collection time; the readers it hands out are the
496    /// per-message path (no `Box<dyn Any>`).
497    ///
498    /// Available in both `std` and `no_std + alloc` environments.
499    pub fn create_source(&self, db: &AimDb) -> Box<dyn SerializedSource> {
500        (self.source_factory)(db)
501    }
502}
503
504/// Fused inbound ingest callback: deserialize + produce in one typed closure
505///
506/// Built where the record type `T` is known (`InboundConnectorBuilder::finish`),
507/// so no `Box<dyn Any>` crosses the connector boundary per message. The closure
508/// captures the typed producer and deserializer; callers only see bytes.
509///
510/// Synchronous by design: `Producer<T>::produce` is sync and infallible
511///, so the only failure is the user
512/// deserializer's — reported as the same `String` the deserializer API uses.
513///
514/// The [`RuntimeContext`](crate::RuntimeContext) is threaded per call (not
515/// captured) for context-aware deserializers.
516pub type IngestFn = Arc<dyn Fn(&crate::RuntimeContext, &[u8]) -> Result<(), String> + Send + Sync>;
517
518/// Type alias for ingest factory callback (alloc feature)
519///
520/// Takes the live [`AimDb`] and returns the fused [`IngestFn`]. This allows
521/// capturing the record type T at link_from() time while storing the factory
522/// in a type-erased InboundConnectorLink. The factory runs once at
523/// route-collection time, not per message.
524///
525/// Available in both `std` and `no_std + alloc` environments.
526pub type IngestFactoryFn = Arc<dyn Fn(&AimDb) -> IngestFn + Send + Sync>;
527
528/// Topic resolver function for inbound connections (late-binding)
529///
530/// Called once at connector startup to resolve the subscription topic.
531/// Returns `Some(topic)` to use a dynamic topic, or `None` to fall back
532/// to the static topic from the `link_from()` URL.
533///
534/// # Use Cases
535///
536/// - Topics determined from smart contracts at runtime
537/// - Service discovery integration
538/// - Environment-specific topic configuration
539/// - Topics read from configuration files or databases
540///
541/// # no_std Compatibility
542///
543/// Works in both `std` and `no_std + alloc` environments.
544pub type TopicResolverFn = Arc<dyn Fn() -> Option<String> + Send + Sync>;
545
546/// Configuration for an inbound connector link (External → AimDB)
547///
548/// Stores the parsed URL, configuration, and the fused ingest factory. The
549/// factory captures the type T at creation time, allowing type-safe
550/// deserialize+produce later without needing PhantomData or type parameters.
551#[derive(Clone)]
552pub struct InboundConnectorLink {
553    /// Parsed link address (`scheme://resource`)
554    pub url: LinkAddress,
555
556    /// Additional configuration options (protocol-specific)
557    pub config: Vec<(String, String)>,
558
559    /// Fused ingest factory (alloc feature)
560    ///
561    /// Takes the live [`AimDb`] and returns the [`IngestFn`] that
562    /// deserializes bytes and produces into the record's buffer in one typed
563    /// closure. Captures the record type T at link_from() call time —
564    /// `finish()` validates the deserializer is present before registering
565    /// the link, so the factory is always set.
566    ///
567    /// Available in both `std` and `no_std + alloc` environments.
568    pub ingest_factory: IngestFactoryFn,
569
570    /// Optional dynamic topic resolver (late-binding)
571    ///
572    /// Called once at connector startup to determine the subscription topic.
573    /// If the resolver returns `None`, the static topic from the URL is used.
574    ///
575    /// Available in both `std` and `no_std + alloc` environments.
576    pub topic_resolver: Option<TopicResolverFn>,
577}
578
579impl Debug for InboundConnectorLink {
580    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
581        f.debug_struct("InboundConnectorLink")
582            .field("url", &self.url)
583            .field("config", &self.config)
584            .field("ingest_factory", &"<factory>")
585            .field(
586                "topic_resolver",
587                &self.topic_resolver.as_ref().map(|_| "<function>"),
588            )
589            .finish()
590    }
591}
592
593impl InboundConnectorLink {
594    /// Creates a new inbound connector link from a link address and ingest factory
595    pub fn new(url: LinkAddress, ingest_factory: IngestFactoryFn) -> Self {
596        Self {
597            url,
598            config: Vec::new(),
599            ingest_factory,
600            topic_resolver: None,
601        }
602    }
603
604    /// Creates the fused ingest callback using the stored factory.
605    ///
606    /// Runs once at route-collection time; the returned [`IngestFn`] is the
607    /// per-message path (deserialize + produce, no erasure crossing).
608    ///
609    /// Available in both `std` and `no_std + alloc` environments.
610    pub fn create_ingest(&self, db: &AimDb) -> IngestFn {
611        (self.ingest_factory)(db)
612    }
613
614    /// Resolves the subscription topic for this link
615    ///
616    /// If a topic resolver is configured, calls it to determine the topic.
617    /// Otherwise, returns the static topic from the URL.
618    ///
619    /// This is called once at connector startup.
620    pub fn resolve_topic(&self) -> String {
621        self.topic_resolver
622            .as_ref()
623            .and_then(|resolver| resolver())
624            .unwrap_or_else(|| self.url.resource_id().to_string())
625    }
626}
627
628/// Parses a connector URL string into structured components
629///
630/// This is a simple parser that handles the most common URL formats.
631/// For production use, consider using the `url` crate with feature flags.
632fn parse_connector_url(url: &str) -> DbResult<ConnectorUrl> {
633    use crate::DbError;
634
635    // Split scheme from rest
636    let (scheme, rest) = url
637        .split_once("://")
638        .ok_or_else(|| DbError::InvalidOperation {
639            operation: "parse_connector_url".into(),
640            reason: format!("Missing scheme in URL: {}", url),
641        })?;
642
643    // Extract credentials if present (user:pass@host)
644    let (credentials, host_part) = if let Some(at_idx) = rest.find('@') {
645        let creds = &rest[..at_idx];
646        let host = &rest[at_idx + 1..];
647        (Some(creds), host)
648    } else {
649        (None, rest)
650    };
651
652    let (username, password) = if let Some(creds) = credentials {
653        if let Some((user, pass)) = creds.split_once(':') {
654            (Some(user.to_string()), Some(pass.to_string()))
655        } else {
656            (Some(creds.to_string()), None)
657        }
658    } else {
659        (None, None)
660    };
661
662    // Split path and query from host:port
663    let (host_port, path, query_params) = if let Some(slash_idx) = host_part.find('/') {
664        let hp = &host_part[..slash_idx];
665        let path_query = &host_part[slash_idx..];
666
667        // Split query parameters
668        let (path_part, query_part) = if let Some(q_idx) = path_query.find('?') {
669            (&path_query[..q_idx], Some(&path_query[q_idx + 1..]))
670        } else {
671            (path_query, None)
672        };
673
674        // Parse query parameters
675        let params = if let Some(query) = query_part {
676            query
677                .split('&')
678                .filter_map(|pair| {
679                    let (k, v) = pair.split_once('=')?;
680                    Some((k.to_string(), v.to_string()))
681                })
682                .collect()
683        } else {
684            Vec::new()
685        };
686
687        (hp, Some(path_part.to_string()), params)
688    } else {
689        (host_part, None, Vec::new())
690    };
691
692    // Split host and port
693    let (host, port) = if let Some(colon_idx) = host_port.rfind(':') {
694        let h = &host_port[..colon_idx];
695        let p = &host_port[colon_idx + 1..];
696        let port_num = p.parse::<u16>().ok();
697        (h.to_string(), port_num)
698    } else {
699        (host_port.to_string(), None)
700    };
701
702    Ok(ConnectorUrl {
703        scheme: scheme.to_string(),
704        host,
705        port,
706        path,
707        username,
708        password,
709        query_params,
710    })
711}
712
713/// Trait for building connectors after the database is constructed
714///
715/// Connectors that need to collect routes from the database (for inbound routing)
716/// implement this trait. The builder pattern allows connectors to be constructed
717/// in two phases:
718///
719/// 1. Configuration phase: User provides broker URLs and settings
720/// 2. Build phase: Connector collects routes from the database and initializes
721///
722/// # Example
723///
724/// Illustrative sketch of a connector author's `build()` (not compiled: the
725/// client types are fictional — see `aimdb-mqtt-connector` for a real one):
726///
727/// ```rust,ignore
728/// pub struct MqttConnectorBuilder {
729///     broker_url: String,
730/// }
731///
732/// impl ConnectorBuilder for MqttConnectorBuilder {
733///     fn build<'a>(
734///         &'a self,
735///         db: &'a AimDb,
736///     ) -> Pin<Box<dyn Future<Output = DbResult<Vec<BoxFuture>>> + Send + 'a>> {
737///         Box::pin(async move {
738///             let routes = db.collect_inbound_routes(self.scheme());
739///             let router = RouterBuilder::from_routes(routes).build();
740///             let connector = MqttConnector::new(&self.broker_url, router).await?;
741///             Ok(connector.futures())
742///         })
743///     }
744///
745///     fn scheme(&self) -> &str {
746///         "mqtt"
747///     }
748/// }
749/// ```
750pub trait ConnectorBuilder: Send + Sync {
751    /// Build the connector and return its driving futures.
752    ///
753    /// Called during `AimDbBuilder::build()` after the database has been
754    /// constructed. The returned futures (infrastructure loops + per-route
755    /// publishers) are appended to the builder's accumulator and driven by
756    /// `AimDbRunner::run()`.
757    ///
758    /// # Arguments
759    /// * `db` - The constructed database instance
760    ///
761    /// # Returns
762    /// All `BoxFuture`s the connector needs to operate. Empty if the connector
763    /// has no work to drive.
764    #[allow(clippy::type_complexity)]
765    fn build<'a>(
766        &'a self,
767        db: &'a AimDb,
768    ) -> Pin<
769        Box<
770            dyn Future<Output = DbResult<Vec<Pin<Box<dyn Future<Output = ()> + Send + 'static>>>>>
771                + Send
772                + 'a,
773        >,
774    >;
775
776    /// The URL scheme this connector handles
777    ///
778    /// Returns the scheme (e.g., "mqtt", "knx", "uds") that this connector
779    /// will be registered under. Used for routing `.link_from()` and `.link_to()`
780    /// declarations to the appropriate connector.
781    fn scheme(&self) -> &str;
782
783    /// Whether registering a second connector under this scheme is an error.
784    ///
785    /// Say `true` when [`build`](Self::build) claims every route for its
786    /// scheme — [`collect_inbound_routes`](crate::AimDb::collect_inbound_routes),
787    /// [`collect_outbound_routes`](crate::AimDb::collect_outbound_routes),
788    /// and `crate::session`'s `pump_source`, `pump_sink` and `pump_client`
789    /// (left unlinked: that module is behind `connector-session`, and this
790    /// trait is not) all filter by scheme alone, so two such connectors each
791    /// collect *all* of it: every `link_to` gets two publishers, and the routes
792    /// cannot be divided between the two endpoints because nothing in a route
793    /// names which connector it belongs to. That misconfiguration is otherwise
794    /// silent, and it fails as duplicated or misdirected traffic at runtime
795    /// rather than at build.
796    ///
797    /// Leave it `false` — the default — for a connector that only serves what
798    /// it is given, such as a session *server*: it binds its own listener and
799    /// collects no routes, so two of them under one scheme are two endpoints
800    /// onto the same dispatch, which is useful rather than broken.
801    fn owns_scheme(&self) -> bool {
802        false
803    }
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809    use alloc::format;
810
811    #[test]
812    fn test_parse_simple_mqtt() {
813        let url = ConnectorUrl::parse("mqtt://broker.example.com:1883").unwrap();
814        assert_eq!(url.scheme, "mqtt");
815        assert_eq!(url.host, "broker.example.com");
816        assert_eq!(url.port, Some(1883));
817        assert_eq!(url.username, None);
818        assert_eq!(url.password, None);
819    }
820
821    #[test]
822    fn test_parse_mqtt_with_credentials() {
823        let url = ConnectorUrl::parse("mqtt://user:pass@broker.example.com:1883").unwrap();
824        assert_eq!(url.scheme, "mqtt");
825        assert_eq!(url.host, "broker.example.com");
826        assert_eq!(url.port, Some(1883));
827        assert_eq!(url.username, Some("user".to_string()));
828        assert_eq!(url.password, Some("pass".to_string()));
829    }
830
831    #[test]
832    fn test_parse_https_with_path() {
833        let url = ConnectorUrl::parse("https://api.example.com:8443/events").unwrap();
834        assert_eq!(url.scheme, "https");
835        assert_eq!(url.host, "api.example.com");
836        assert_eq!(url.port, Some(8443));
837        assert_eq!(url.path, Some("/events".to_string()));
838    }
839
840    #[test]
841    fn test_parse_with_query_params() {
842        let url = ConnectorUrl::parse("http://api.example.com/data?key=value&foo=bar").unwrap();
843        assert_eq!(url.scheme, "http");
844        assert_eq!(url.host, "api.example.com");
845        assert_eq!(url.path, Some("/data".to_string()));
846        assert_eq!(url.query_params.len(), 2);
847        assert_eq!(
848            url.query_params[0],
849            ("key".to_string(), "value".to_string())
850        );
851        assert_eq!(url.query_params[1], ("foo".to_string(), "bar".to_string()));
852    }
853
854    #[test]
855    fn test_default_ports() {
856        let mqtt = ConnectorUrl::parse("mqtt://broker.local").unwrap();
857        assert_eq!(mqtt.default_port(), Some(1883));
858        assert_eq!(mqtt.effective_port(), Some(1883));
859
860        let mqtts = ConnectorUrl::parse("mqtts://broker.local").unwrap();
861        assert_eq!(mqtts.default_port(), Some(8883));
862
863        // No connector, no default: unknown schemes carry no port opinion.
864        let knx = ConnectorUrl::parse("knx://gateway.local").unwrap();
865        assert_eq!(knx.default_port(), None);
866    }
867
868    #[test]
869    fn test_is_secure() {
870        assert!(ConnectorUrl::parse("mqtts://broker.local")
871            .unwrap()
872            .is_secure());
873        assert!(ConnectorUrl::parse("https://api.example.com")
874            .unwrap()
875            .is_secure());
876        assert!(ConnectorUrl::parse("wss://ws.example.com")
877            .unwrap()
878            .is_secure());
879
880        assert!(!ConnectorUrl::parse("mqtt://broker.local")
881            .unwrap()
882            .is_secure());
883        assert!(!ConnectorUrl::parse("http://api.example.com")
884            .unwrap()
885            .is_secure());
886        assert!(!ConnectorUrl::parse("ws://ws.example.com")
887            .unwrap()
888            .is_secure());
889    }
890
891    #[test]
892    fn test_display_hides_password() {
893        let url = ConnectorUrl::parse("mqtt://user:secret@broker.local:1883").unwrap();
894        let display = format!("{}", url);
895        assert!(display.contains("user:****"));
896        assert!(!display.contains("secret"));
897    }
898
899    #[test]
900    fn test_parse_kafka_style() {
901        let url =
902            ConnectorUrl::parse("kafka://broker1.local:9092,broker2.local:9092/my-topic").unwrap();
903        assert_eq!(url.scheme, "kafka");
904        // Note: Our simple parser doesn't handle the second port in comma-separated hosts perfectly
905        // It parses "broker1.local:9092,broker2.local" as the host and "9092" as the port
906        // This is acceptable for now - production connectors can handle this in their client factories
907        assert!(url.host.contains("broker1.local"));
908        assert!(url.host.contains("broker2.local"));
909        assert_eq!(url.path, Some("/my-topic".to_string()));
910    }
911
912    #[test]
913    fn test_parse_missing_scheme() {
914        let result = ConnectorUrl::parse("broker.example.com:1883");
915        assert!(result.is_err());
916    }
917
918    // ========================================================================
919    // TopicProvider Tests
920    // ========================================================================
921
922    #[allow(dead_code)]
923    #[derive(Debug, Clone)]
924    struct TestTemperature {
925        sensor_id: String,
926        celsius: f32,
927    }
928
929    struct TestTopicProvider;
930
931    impl super::TopicProvider<TestTemperature> for TestTopicProvider {
932        fn topic(&self, value: &TestTemperature) -> Option<String> {
933            Some(format!("sensors/temp/{}", value.sensor_id))
934        }
935    }
936
937    #[test]
938    fn test_topic_provider_as_trait_object() {
939        // Providers are stored as Arc<dyn TopicProvider<T>> — typed, no
940        // erasure.
941        let provider: Arc<dyn super::TopicProvider<TestTemperature>> = Arc::new(TestTopicProvider);
942        let temp = TestTemperature {
943            sensor_id: "kitchen-001".into(),
944            celsius: 22.5,
945        };
946
947        assert_eq!(
948            provider.topic(&temp),
949            Some("sensors/temp/kitchen-001".into())
950        );
951    }
952
953    #[test]
954    fn test_topic_provider_returns_none() {
955        struct OptionalTopicProvider;
956
957        impl super::TopicProvider<TestTemperature> for OptionalTopicProvider {
958            fn topic(&self, temp: &TestTemperature) -> Option<String> {
959                if temp.sensor_id.is_empty() {
960                    None // Fall back to default topic
961                } else {
962                    Some(format!("sensors/{}", temp.sensor_id))
963                }
964            }
965        }
966
967        let provider: Arc<dyn super::TopicProvider<TestTemperature>> =
968            Arc::new(OptionalTopicProvider);
969
970        // Non-empty sensor_id returns dynamic topic
971        let temp_with_id = TestTemperature {
972            sensor_id: "abc".into(),
973            celsius: 20.0,
974        };
975        assert_eq!(provider.topic(&temp_with_id), Some("sensors/abc".into()));
976
977        // Empty sensor_id returns None (fallback)
978        let temp_without_id = TestTemperature {
979            sensor_id: String::new(),
980            celsius: 20.0,
981        };
982        assert_eq!(provider.topic(&temp_without_id), None);
983    }
984
985    // ========================================================================
986    // TopicResolverFn Tests
987    // ========================================================================
988
989    #[test]
990    fn test_topic_resolver_returns_some() {
991        let resolver: super::TopicResolverFn = Arc::new(|| Some("resolved/topic".into()));
992
993        assert_eq!(resolver(), Some("resolved/topic".into()));
994    }
995
996    #[test]
997    fn test_topic_resolver_returns_none() {
998        let resolver: super::TopicResolverFn = Arc::new(|| None);
999
1000        // Returns None, should fall back to default topic
1001        assert_eq!(resolver(), None);
1002    }
1003
1004    #[cfg(feature = "std")]
1005    #[test]
1006    fn test_topic_resolver_with_captured_state() {
1007        use std::sync::Mutex;
1008
1009        let config = Arc::new(Mutex::new(Some("dynamic/topic".to_string())));
1010        let config_clone = config.clone();
1011
1012        let resolver: super::TopicResolverFn =
1013            Arc::new(move || config_clone.lock().unwrap().clone());
1014
1015        assert_eq!(resolver(), Some("dynamic/topic".into()));
1016
1017        // Clear config
1018        *config.lock().unwrap() = None;
1019        assert_eq!(resolver(), None);
1020    }
1021
1022    /// Dummy ingest factory for link-construction tests (never invoked).
1023    fn dummy_ingest_factory() -> super::IngestFactoryFn {
1024        Arc::new(|_db| Arc::new(|_ctx: &crate::RuntimeContext, _bytes: &[u8]| Ok(())))
1025    }
1026
1027    #[test]
1028    fn test_inbound_connector_link_resolve_topic_default() {
1029        use super::{InboundConnectorLink, LinkAddress};
1030
1031        let url = LinkAddress::parse("mqtt://sensors/temperature").unwrap();
1032        let link = InboundConnectorLink::new(url, dummy_ingest_factory());
1033
1034        // No resolver configured, should return static topic from URL
1035        assert_eq!(link.resolve_topic(), "sensors/temperature");
1036    }
1037
1038    #[test]
1039    fn test_inbound_connector_link_resolve_topic_dynamic() {
1040        use super::{InboundConnectorLink, LinkAddress};
1041
1042        let url = LinkAddress::parse("mqtt://sensors/default").unwrap();
1043        let mut link = InboundConnectorLink::new(url, dummy_ingest_factory());
1044
1045        // Configure dynamic resolver
1046        link.topic_resolver = Some(Arc::new(|| Some("sensors/dynamic/kitchen".into())));
1047
1048        // Should return resolved topic, not URL topic
1049        assert_eq!(link.resolve_topic(), "sensors/dynamic/kitchen");
1050    }
1051
1052    #[test]
1053    fn test_inbound_connector_link_resolve_topic_fallback() {
1054        use super::{InboundConnectorLink, LinkAddress};
1055
1056        let url = LinkAddress::parse("mqtt://sensors/fallback").unwrap();
1057        let mut link = InboundConnectorLink::new(url, dummy_ingest_factory());
1058
1059        // Configure resolver that returns None
1060        link.topic_resolver = Some(Arc::new(|| None));
1061
1062        // Should fall back to static topic from URL
1063        assert_eq!(link.resolve_topic(), "sensors/fallback");
1064    }
1065}