Skip to main content

ruststream_rumqttc/
publisher.rs

1//! [`MqttPublisher`], its [`MqttPublish`] policy, and the per-message settings.
2
3// Without the `testing` feature a link has one variant, so a `match` on it has a single arm; the
4// match stays so that the in-process arm has its place when the feature is on.
5#![cfg_attr(
6    not(feature = "testing"),
7    allow(clippy::infallible_destructuring_match)
8)]
9
10use std::fmt;
11use std::future::{Future, ready};
12
13use bytes::Bytes;
14use rumqttc::v5::mqttbytes::v5::PublishProperties;
15use rumqttc::v5::mqttbytes::valid_topic;
16#[cfg(feature = "asyncapi")]
17use ruststream::asyncapi::Bindings;
18use ruststream::runtime::{PublishBuilder, PublishSink};
19use ruststream::{BytesMut, OutgoingMessage, PairError, PublishPolicy, Publisher, Take};
20
21#[cfg(feature = "asyncapi")]
22use crate::asyncapi;
23use crate::broker::{ConnectedMqttBroker, CoreCell, Link};
24use crate::error::MqttError;
25use crate::filter::Qos;
26use crate::message::{is_mqtt_text, to_wire_properties};
27
28/// Refuses a topic no server takes, before any I/O.
29///
30/// The client's send-path error cannot say why a request failed, so the topic is validated here; a
31/// remaining failure unambiguously means the connection is gone. The client passes an empty topic
32/// through, and a server answers that packet by closing the session, which would take every other
33/// message in flight with it.
34pub(crate) fn check_topic(topic: &str) -> Result<(), MqttError> {
35    let reason = if topic.is_empty() {
36        "an MQTT topic must not be empty"
37    } else if !valid_topic(topic) {
38        "not a valid MQTT topic (wildcards are subscribe-only)"
39    } else if !is_mqtt_text(topic) {
40        "not a valid MQTT topic (it carries a control character or a Unicode non-character)"
41    } else {
42        return Ok(());
43    };
44    Err(MqttError::Publish {
45        topic: topic.to_owned(),
46        reason: reason.to_owned(),
47    })
48}
49
50/// The single send path: every publishing form resolves to a `QoS` and a retain flag, and the
51/// wire work happens here once.
52async fn send(
53    cell: &CoreCell,
54    qos: Qos,
55    retain: bool,
56    msg: OutgoingMessage<'_, BytesMut>,
57) -> Result<(), MqttError> {
58    let core = cell.get().ok_or(MqttError::NotConnected)?;
59    core.shared.ensure_open()?;
60    check_topic(msg.name())?;
61    let topic = msg.name();
62    let (payload, properties) = into_packet(msg)?;
63    let client = match &core.link {
64        Link::Wire(client) => client,
65        #[cfg(feature = "testing")]
66        Link::InProcess(bus) => {
67            bus.publish(&core.shared, topic, qos, retain, payload, properties);
68            return Ok(());
69        }
70    };
71    let outcome = match properties {
72        Some(properties) => {
73            client
74                .publish_bytes_with_properties(topic, qos.to_client(), retain, payload, properties)
75                .await
76        }
77        None => {
78            client
79                .publish_bytes(topic, qos.to_client(), retain, payload)
80                .await
81        }
82    };
83    outcome.map_err(|_| MqttError::Publish {
84        topic: topic.to_owned(),
85        reason: "the mqtt connection task has shut down".to_owned(),
86    })
87}
88
89/// What a PUBLISH packet carries, taken out of the message in one move: the payload the session
90/// keeps and the MQTT 5 properties the headers map onto.
91fn into_packet(
92    msg: OutgoingMessage<'_, BytesMut>,
93) -> Result<(Bytes, Option<PublishProperties>), MqttError> {
94    let properties = to_wire_properties(&msg)?;
95    Ok((msg.into_payload().freeze(), properties))
96}
97
98/// Publishes messages to MQTT topics through the shared connection.
99///
100/// The publish is queued into the client session, so `Ok` means "owned by the session", not
101/// "broker confirmed". For `QoS` 1/2 the client keeps the message until the broker acknowledges
102/// it, across reconnects: a resumed session retransmits it, and a new session sends it again,
103/// where it may arrive twice. Buildable before `connect` and usable until `shutdown`; afterwards
104/// every publish reports [`MqttError::NotConnected`].
105///
106/// Headers are sent as MQTT 5 user properties, so non-Rust peers see plain MQTT messages. The
107/// `content-type`, `reply-to` and `correlation-id` headers take the matching first-class
108/// properties (content type, response topic, correlation data), in both directions, and a
109/// message with no headers carries no properties at all. A textual content type
110/// (`application/json`, any `text/` subtype, any `+json` vendor type) sets the payload format
111/// indicator to 1, and every other one to 0. A header name or value that is not text an MQTT
112/// string carries refuses the publish with [`MqttError::Publish`]; the correlation data is binary
113/// on the wire and takes any bytes.
114///
115/// # Examples
116///
117/// Nothing writes the media type for a service: a reply, a deferred retry copy and a plain
118/// publish carry the headers something put on them. A service whose peers read the content type
119/// property sets it once at the mount site, through a publish transform over the position:
120///
121/// ```
122/// # mod demo {
123/// use ruststream::runtime::{Outgoing, Reads};
124/// use ruststream_rumqttc::prelude::*;
125/// use serde::{Deserialize, Serialize};
126///
127/// /// States the media type of every message this position sends, which is what fills the content
128/// /// type property and decides the payload format indicator. It reads nothing about the delivery,
129/// /// so it is generic over the position and mounts on any of them.
130/// struct Json;
131///
132/// impl<K: ContextKind, Options> PublishTransform<K, Options> for Json {
133///     type Destination = Reads;
134///
135///     fn apply(&self, out: &mut Outgoing<'_>, _options: &mut Option<Options>, _cx: &K::View<'_>) {
136///         out.headers_mut().insert("content-type", "application/json");
137///     }
138/// }
139///
140/// #[derive(Deserialize)]
141/// struct Telemetry {
142///     device: String,
143///     temperature: f64,
144/// }
145///
146/// #[derive(Serialize, Outgoing)]
147/// #[outgoing(name = "devices/state")]
148/// struct DeviceState {
149///     device: String,
150///     hot: bool,
151/// }
152///
153/// #[subscriber(MqttFilter::new("devices/+/telemetry"), reply)]
154/// async fn track(telemetry: &Telemetry) -> DeviceState {
155///     DeviceState { device: telemetry.device.clone(), hot: telemetry.temperature > 30.0 }
156/// }
157///
158/// #[ruststream::app]
159/// fn app() -> impl App {
160///     RustStream::new(AppInfo::new("states", "0.1.0")).with_broker(
161///         MqttBroker::new("mqtt://localhost:1883", "states-svc"),
162///         |b| {
163///             // Every reply now carries the content type property and the payload format
164///             // indicator 1, so a peer reads the body as UTF-8 JSON.
165///             b.include(track)
166///                 .out_reply(Publish::default())
167///                 .transform(Json)
168///                 .out_retry(Publish::default())
169///                 .to("devices/retry/telemetry");
170///         },
171///     )
172/// }
173/// # }
174/// # fn main() {}
175/// ```
176#[derive(Clone)]
177pub struct MqttPublisher {
178    cell: CoreCell,
179    qos: Qos,
180    retain: bool,
181}
182
183impl fmt::Debug for MqttPublisher {
184    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185        f.debug_struct("MqttPublisher")
186            .field("qos", &self.qos)
187            .field("retain", &self.retain)
188            .finish_non_exhaustive()
189    }
190}
191
192impl MqttPublisher {
193    pub(crate) fn new(cell: CoreCell, qos: Qos, retain: bool) -> Self {
194        Self { cell, qos, retain }
195    }
196}
197
198impl Publisher for MqttPublisher {
199    /// The client keeps the payload: a PUBLISH packet is queued into the session, which holds
200    /// the `Bytes` until the broker acknowledges it.
201    type Payload = Take;
202
203    type Error = MqttError;
204    type Options = MqttPublishOptions;
205
206    async fn publish(
207        &self,
208        msg: OutgoingMessage<'_, BytesMut>,
209        options: Option<&Self::Options>,
210    ) -> Result<(), Self::Error> {
211        let (qos, retain) = MqttPublishOptions::resolve(options, self.qos, self.retain);
212        send(&self.cell, qos, retain, msg).await
213    }
214}
215
216/// The two arguments MQTT carries on every PUBLISH packet, as one message asked for them.
217///
218/// This is [`Publisher::Options`] for every publisher this crate hands out. An argument left
219/// unset keeps what the [`MqttPublish`] policy fixed at the mount site, so a call carries only
220/// what it changed, and a path with no call site at all - a reply, the runtime's deferred
221/// redelivery - carries nothing and publishes entirely under the policy.
222///
223/// A service writes [the steps](MqttPublishSteps) rather than this type; it is named in a test
224/// asserting what a publish carried (`with_options`) and in the
225/// `Out<impl Publisher<Options = MqttPublishOptions>, Marker>` bound of a handler body that takes
226/// one.
227///
228/// # Examples
229///
230/// ```
231/// # #[cfg(feature = "testing")]
232/// # mod demo {
233/// use std::error::Error;
234///
235/// use ruststream::testing::TestApp;
236/// use ruststream_rumqttc::prelude::*;
237/// use serde::{Deserialize, Serialize};
238///
239/// #[derive(Deserialize, Serialize, Outgoing)]
240/// struct Telemetry {
241///     temperature: f64,
242/// }
243///
244/// #[derive(Outgoing, Serialized)]
245/// #[outgoing(name = "devices/dev42/state")]
246/// struct DeviceState(Vec<u8>);
247///
248/// #[derive(OutSlot)]
249/// #[publishes(DeviceState)]
250/// struct States;
251///
252/// #[subscriber("devices/dev42/telemetry")]
253/// async fn announce(
254///     telemetry: &Telemetry,
255///     Out(states): Out<impl Publisher<Options = MqttPublishOptions>, States>,
256/// ) -> HandlerOutcome {
257///     let state: &[u8] = if telemetry.temperature > 30.0 { b"hot" } else { b"ok" };
258///     if states.message(&DeviceState(state.to_vec())).retain(true).publish().await.is_err() {
259///         return HandlerOutcome::retry();
260///     }
261///     HandlerOutcome::ack()
262/// }
263///
264/// pub fn app() -> impl App {
265///     RustStream::new(AppInfo::new("states", "0.1.0")).with_broker(
266///         MqttBroker::new("mqtt://localhost:1883", "states-svc"),
267///         |b| {
268///             b.include(announce).out(States, Publish::default()).build();
269///         },
270///     )
271/// }
272///
273/// pub async fn the_state_is_published_retained() -> Result<(), Box<dyn Error>> {
274///     let tb = TestApp::start(app()).await?;
275///
276///     tb.broker::<MqttBroker>()
277///         .message(&Telemetry { temperature: 31.5 })
278///         .to("devices/dev42/telemetry")
279///         .publish()
280///         .await?;
281///
282///     tb.out::<States>()
283///         .assert_called_once()
284///         .with_raw(b"hot")
285///         .with_options(&MqttPublishOptions::default().retain(true));
286///     tb.shutdown().await?;
287///     Ok(())
288/// }
289/// # }
290/// # #[cfg(feature = "testing")]
291/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
292/// #     tokio::runtime::Builder::new_multi_thread()
293/// #         .enable_all()
294/// #         .build()?
295/// #         .block_on(demo::the_state_is_published_retained())
296/// # }
297/// # #[cfg(not(feature = "testing"))]
298/// # fn main() {}
299/// ```
300#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
301#[must_use]
302pub struct MqttPublishOptions {
303    qos: Option<Qos>,
304    retain: Option<bool>,
305}
306
307impl MqttPublishOptions {
308    /// Sends this one message at `qos` instead of the policy's.
309    pub const fn qos(mut self, qos: Qos) -> Self {
310        self.qos = Some(qos);
311        self
312    }
313
314    /// Sends this one message retained (or explicitly not retained), whatever the policy declares.
315    ///
316    /// A retained message is the last one the broker keeps per topic and hands to each new
317    /// subscriber on a matching filter; publishing an empty payload retained clears it.
318    pub const fn retain(mut self, retain: bool) -> Self {
319        self.retain = Some(retain);
320        self
321    }
322
323    /// Resolves one call's arguments over the defaults its publisher was paired with.
324    pub(crate) fn resolve(options: Option<&Self>, qos: Qos, retain: bool) -> (Qos, bool) {
325        let options = options.copied().unwrap_or_default();
326        (options.qos.unwrap_or(qos), options.retain.unwrap_or(retain))
327    }
328}
329
330/// The per-message steps of an MQTT publish, on the publish builder itself.
331///
332/// Both arguments MQTT carries on a PUBLISH packet are reopened at the call site:
333/// `out.message(&state).retain(true).publish()`. The steps sit on the builder rather than
334/// wrapping the publisher, so the publish still leaves through the mount site's own entry - with
335/// the codec that entry named, and attributed to the slot it belongs to.
336///
337/// The bound is on the sink's [options type](Publisher::Options), so these steps appear on a
338/// builder over an MQTT publisher and on no other broker's. A handler body that takes one imports
339/// this crate's prelude and bounds its slot with
340/// `Out<impl Publisher<Options = MqttPublishOptions>, Marker>`; everywhere else - a startup hook,
341/// a test - the steps are already there on the builder the publisher hands out.
342///
343/// # Examples
344///
345/// ```
346/// # mod demo {
347/// use ruststream::runtime::PublishError;
348/// use ruststream_rumqttc::MqttError;
349/// use ruststream_rumqttc::prelude::*;
350/// use serde::Deserialize;
351///
352/// #[derive(Deserialize)]
353/// struct Telemetry {
354///     device: String,
355///     temperature: f64,
356/// }
357///
358/// /// An MQTT state is bytes on the wire rather than an encoded model, so the type carries its own
359/// /// bytes and no codec runs on them; the `{device}` segment becomes a setter the call fills in.
360/// #[derive(Outgoing, Serialized)]
361/// #[outgoing(name = "devices/{device}/state")]
362/// struct DeviceState(Vec<u8>);
363///
364/// #[derive(OutSlot)]
365/// #[publishes(DeviceState)]
366/// struct States;
367///
368/// /// The reading is one of many; the state it puts the device in is what a service joining later
369/// /// must see. So the state is published retained, and only the state.
370/// #[subscriber(MqttFilter::new("devices/+/telemetry").qos(Qos::AtLeastOnce))]
371/// async fn announce_state(
372///     telemetry: &Telemetry,
373///     Out(states): Out<impl Publisher<Options = MqttPublishOptions>, States>,
374/// ) -> HandlerOutcome {
375///     let state = if telemetry.temperature > 30.0 { "hot" } else { "ok" };
376///     if states
377///         .message(&DeviceState(state.as_bytes().to_vec()))
378///         .retain(true)
379///         .to()
380///         .device(&telemetry.device)
381///         .publish()
382///         .await
383///         .is_err()
384///     {
385///         return HandlerOutcome::retry();
386///     }
387///     HandlerOutcome::ack()
388/// }
389///
390/// #[ruststream::app]
391/// fn app() -> impl App {
392///     RustStream::new(AppInfo::new("retained", "0.1.0")).with_broker(
393///         MqttBroker::new("mqtt://localhost:1883", "retained-example"),
394///         |b| {
395///             // The policy publishes plain; this one announcement is retained and exactly once,
396///             // so a subscriber that joins later still sees the device online. The hook runs once
397///             // the broker is connected, so the announcement cannot race it.
398///             b.after_startup(
399///                 Publish::default(),
400///                 async move |publisher| -> Result<(), PublishError<MqttError>> {
401///                     publisher
402///                         .message(&DeviceState(b"online".to_vec()))
403///                         .retain(true)
404///                         .qos(Qos::ExactlyOnce)
405///                         .to()
406///                         .device("dev42")
407///                         .publish()
408///                         .await
409///                 },
410///             );
411///             // The mount site fixes what every publish through the slot takes; the handler's
412///             // step is what one message changes.
413///             b.include(announce_state)
414///                 .out(States, Publish::default().qos(Qos::AtLeastOnce))
415///                 .out_retry(Publish::default())
416///                 .to("devices/retry/telemetry")
417///                 .build();
418///         },
419///     )
420/// }
421/// # }
422/// # fn main() {}
423/// ```
424pub trait MqttPublishSteps {
425    /// Sends this one message at `qos` instead of the publisher's own.
426    #[must_use]
427    fn qos(self, qos: Qos) -> Self;
428
429    /// Sends this one message retained (or explicitly not retained), whatever the publisher's
430    /// policy declares.
431    #[must_use]
432    fn retain(self, retain: bool) -> Self;
433}
434
435impl<Sink, Body, Enc, Hdrs, Dest> MqttPublishSteps for PublishBuilder<Sink, Body, Enc, Hdrs, Dest>
436where
437    Sink: PublishSink<Options = MqttPublishOptions>,
438{
439    fn qos(mut self, qos: Qos) -> Self {
440        self.options_mut()
441            .get_or_insert_with(MqttPublishOptions::default)
442            .qos = Some(qos);
443        self
444    }
445
446    fn retain(mut self, retain: bool) -> Self {
447        self.options_mut()
448            .get_or_insert_with(MqttPublishOptions::default)
449            .retain = Some(retain);
450        self
451    }
452}
453
454/// The publish policy for [`MqttPublisher`]: quality of service and the retain flag as pure
455/// declaration, paired with the connected broker by the runtime after `connect`.
456///
457/// # Examples
458///
459/// ```
460/// # mod demo {
461/// use ruststream_rumqttc::{MqttBroker, MqttFilter, MqttPublish, Qos};
462/// use ruststream::prelude::*;
463/// use serde::{Deserialize, Serialize};
464///
465/// #[derive(Deserialize)]
466/// struct Telemetry {
467///     device: String,
468///     temperature: f64,
469/// }
470///
471/// #[derive(Serialize, Outgoing)]
472/// #[outgoing(name = "devices/state")]
473/// struct DeviceState {
474///     device: String,
475///     hot: bool,
476/// }
477///
478/// #[subscriber(MqttFilter::new("devices/+/telemetry"), reply)]
479/// async fn track(telemetry: &Telemetry) -> DeviceState {
480///     DeviceState { device: telemetry.device.clone(), hot: telemetry.temperature > 30.0 }
481/// }
482///
483/// #[ruststream::app]
484/// fn app() -> impl App {
485///     RustStream::new(AppInfo::new("states", "0.1.0")).with_broker(
486///         MqttBroker::new("mqtt://localhost:1883", "states-svc"),
487///         |b| {
488///             // Every reply is retained, so a dashboard that starts later reads the last state.
489///             b.include(track)
490///                 .out_reply(MqttPublish::default().qos(Qos::ExactlyOnce).retain(true))
491///                 .out_retry(MqttPublish::default())
492///                 .to("devices/retry/telemetry");
493///         },
494///     )
495/// }
496/// # }
497/// # fn main() {}
498/// ```
499#[derive(Debug, Clone, Copy, Default)]
500#[must_use]
501pub struct MqttPublish {
502    qos: Qos,
503    retain: bool,
504}
505
506impl MqttPublish {
507    /// Sets the delivery quality of service. Defaults to [`Qos::AtLeastOnce`].
508    pub fn qos(mut self, qos: Qos) -> Self {
509        self.qos = qos;
510        self
511    }
512
513    /// Publishes messages as retained: the broker keeps the last one per topic and hands it
514    /// to new (non-shared) subscribers.
515    pub fn retain(mut self, retain: bool) -> Self {
516        self.retain = retain;
517        self
518    }
519}
520
521impl MqttPublish {
522    pub(crate) fn into_publisher(self, cell: CoreCell) -> MqttPublisher {
523        MqttPublisher::new(cell, self.qos, self.retain)
524    }
525}
526
527impl PublishPolicy<ConnectedMqttBroker> for MqttPublish {
528    type Live = MqttPublisher;
529
530    fn pair(
531        self,
532        connected: &ConnectedMqttBroker,
533    ) -> impl Future<Output = Result<Self::Live, PairError>> {
534        ready(Ok(connected.publisher_with(self)))
535    }
536
537    /// What every packet this policy sends carries: the quality of service and the retain flag,
538    /// which the `mqtt` binding puts on the operation.
539    #[cfg(feature = "asyncapi")]
540    fn operation_bindings(&self, _channel: &str) -> Bindings {
541        asyncapi::send_operation(self.qos, self.retain)
542    }
543
544    /// The MQTT 5 properties every message this policy sends is mapped through.
545    ///
546    /// The destination the mount site resolved is not read here, nor on the operation: the `mqtt`
547    /// binding has no field that names one. The Response Topic property is the requester's to
548    /// set, so a message a service sends describes none, and where a reply goes is the
549    /// operation's `reply` object.
550    #[cfg(feature = "asyncapi")]
551    fn message_bindings(&self, _channel: &str) -> Bindings {
552        asyncapi::publish_message()
553    }
554
555    /// The crate answers a request through the Response Topic property, which arrives as the
556    /// `reply-to` header, so that is where a client reads the address of its answer.
557    #[cfg(feature = "asyncapi")]
558    fn reply_address_location(&self) -> Option<&'static str> {
559        Some(asyncapi::REPLY_ADDRESS_LOCATION)
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use ruststream::runtime::PublishExt;
566    use ruststream::{HeaderMap, Outgoing, Serialized};
567
568    use super::*;
569    use crate::broker::MqttBroker;
570
571    /// A topic a server would close the connection on is refused before the packet is queued.
572    #[test]
573    fn a_topic_with_a_control_character_is_refused() {
574        assert!(check_topic("devices/a\nb").is_err());
575        assert!(check_topic("devices/\u{fdd0}").is_err());
576        assert!(check_topic("devices/zürich").is_ok());
577    }
578
579    /// The packet is made of the buffer the publish wrote, not of a copy of it: the session keeps
580    /// the payload, so this crate hands it over.
581    #[test]
582    fn the_packet_carries_the_buffer_that_was_handed_in() {
583        let body = BytesMut::from(&br#"{"id":7}"#[..]);
584        let written_at = body.as_ptr();
585        let mut headers = HeaderMap::new();
586        headers.insert("content-type", "application/json");
587
588        let (payload, properties) =
589            into_packet(OutgoingMessage::produced("orders", body).with_headers(headers))
590                .expect("the headers are MQTT text");
591
592        assert!(properties.is_some(), "the headers still map to properties");
593        assert_eq!(
594            payload.as_ptr(),
595            written_at,
596            "the packet must carry the buffer the publish wrote, not a copy of it",
597        );
598    }
599
600    fn publisher() -> MqttPublisher {
601        MqttBroker::new("mqtt://localhost:1883", "arguments").publisher()
602    }
603
604    /// A payload that carries its own bytes, so a builder can be assembled without a codec in
605    /// the picture: what these tests read is the options the steps wrote, not an encoding.
606    #[derive(Outgoing, Serialized)]
607    #[outgoing(name = "devices/dev42/state")]
608    struct DeviceState(Vec<u8>);
609
610    fn state() -> DeviceState {
611        DeviceState(b"online".to_vec())
612    }
613
614    #[test]
615    fn a_call_that_names_nothing_publishes_entirely_under_the_policy() {
616        assert_eq!(
617            MqttPublishOptions::resolve(None, Qos::AtLeastOnce, false),
618            (Qos::AtLeastOnce, false),
619            "a reply and a deferred redelivery arrive here with no call site at all"
620        );
621    }
622
623    #[test]
624    fn a_named_argument_wins_and_an_unnamed_one_keeps_the_policy_value() {
625        let options = MqttPublishOptions::default().retain(true);
626        assert_eq!(
627            MqttPublishOptions::resolve(Some(&options), Qos::ExactlyOnce, false),
628            (Qos::ExactlyOnce, true),
629            "the quality of service the call left alone is still the policy's"
630        );
631    }
632
633    #[test]
634    fn the_steps_compose_in_either_order() {
635        let publisher = publisher();
636        let state = state();
637        let expected = MqttPublishOptions::default()
638            .qos(Qos::ExactlyOnce)
639            .retain(true);
640
641        let mut forward = publisher.message(&state).qos(Qos::ExactlyOnce).retain(true);
642        assert_eq!(*forward.options_mut(), Some(expected));
643
644        let mut reversed = publisher.message(&state).retain(true).qos(Qos::ExactlyOnce);
645        assert_eq!(*reversed.options_mut(), Some(expected));
646    }
647
648    #[test]
649    fn a_builder_no_step_touched_carries_no_options() {
650        let publisher = publisher();
651        let state = state();
652        let mut plain = publisher.message(&state);
653        assert_eq!(
654            *plain.options_mut(),
655            None,
656            "nothing to resolve means the policy is the whole answer"
657        );
658    }
659
660    #[tokio::test]
661    async fn a_publish_before_connect_reports_the_missing_connection() {
662        let publisher = publisher();
663        let msg = OutgoingMessage::new("devices/dev42/state", b"online".as_slice());
664        let error = publisher
665            .publish(msg, Some(&MqttPublishOptions::default().retain(true)))
666            .await
667            .expect_err("nothing is connected yet");
668        assert!(matches!(error, MqttError::NotConnected));
669    }
670}