use core::future::Future;
use core::pin::Pin;
use thiserror::Error;
use crate::domain::FramingError;
pub trait FrameSource {
fn next_frame(&self) -> impl Future<Output = Result<Option<String>, SourceError>> + Send;
}
pub trait DynFrameSource: Send + Sync {
fn next_frame<'a>(
&'a self,
) -> Pin<Box<dyn Future<Output = Result<Option<String>, SourceError>> + Send + 'a>>;
}
impl<T> DynFrameSource for T
where
T: FrameSource + Send + Sync,
{
fn next_frame<'a>(
&'a self,
) -> Pin<Box<dyn Future<Output = Result<Option<String>, SourceError>> + Send + 'a>> {
Box::pin(FrameSource::next_frame(self))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SourceError {
#[error("the transport could not read the next frame")]
Transport,
#[error("the peer's bytes are not a valid frame: {0}")]
Framing(#[from] FramingError),
}
#[cfg(test)]
mod tests {
use core::future::Future;
use core::task::{Context, Poll, Waker};
use std::sync::Arc;
use assert_matches::assert_matches;
use super::{DynFrameSource, FrameSource, SourceError};
use crate::domain::{FrameDecoder, FramingError, MaxFrameSize};
struct Replay {
decoder: std::sync::Mutex<FrameDecoder>,
}
impl Replay {
fn new(wire: &[u8]) -> Self {
let mut decoder =
FrameDecoder::new(MaxFrameSize::new(1024).unwrap_or(MaxFrameSize::DEFAULT));
let _ = decoder.push(wire);
Self {
decoder: std::sync::Mutex::new(decoder),
}
}
}
impl FrameSource for Replay {
#[expect(
clippy::unused_async_trait_impl,
reason = "FrameSource is a native async fn trait, so the signature is the trait's and not the fixture's to change; locking a std mutex and polling the decoder are both synchronous, which is the whole point of the fixture"
)]
async fn next_frame(&self) -> Result<Option<String>, SourceError> {
let mut decoder = self.decoder.lock().map_err(|_| SourceError::Transport)?;
decoder
.next_frame()
.map(|frame| frame.map(str::to_owned))
.map_err(SourceError::from)
}
}
struct Broken;
impl FrameSource for Broken {
#[expect(
clippy::unused_async_trait_impl,
reason = "FrameSource is a native async fn trait, so the signature is the trait's and not the fixture's to change; there is nothing to await in reporting a failure that has already happened"
)]
async fn next_frame(&self) -> Result<Option<String>, SourceError> {
Err(SourceError::Transport)
}
}
fn drive<F: Future>(future: F) -> Option<F::Output> {
let mut context = Context::from_waker(Waker::noop());
let mut future = Box::pin(future);
match future.as_mut().poll(&mut context) {
Poll::Ready(value) => Some(value),
Poll::Pending => None,
}
}
#[test]
fn a_frame_arrives_through_the_generic_spelling() {
let source = Replay::new(b"{\"jsonrpc\":\"2.0\"}\n");
assert_eq!(
drive(FrameSource::next_frame(&source)),
Some(Ok(Some(String::from("{\"jsonrpc\":\"2.0\"}"))))
);
}
#[test]
fn the_boxed_spelling_forwards_to_the_same_implementation() {
let source = Replay::new(b"{\"jsonrpc\":\"2.0\"}\n");
let boxed: Arc<dyn DynFrameSource> = Arc::new(source);
assert_eq!(
drive(boxed.next_frame()),
Some(Ok(Some(String::from("{\"jsonrpc\":\"2.0\"}"))))
);
}
#[test]
fn a_partially_read_frame_is_not_reported_as_a_closed_stream() {
let source = Replay::new(b"{\"jsonrpc\":\"2.0\"}");
assert_eq!(drive(FrameSource::next_frame(&source)), Some(Ok(None)));
}
#[test]
fn a_framing_refusal_keeps_its_own_type_through_the_port() {
let source = Replay::new(b"{\"a\":\"\xff\"}\n");
assert_matches!(
drive(FrameSource::next_frame(&source)),
Some(Err(SourceError::Framing(FramingError::NotUtf8 { .. })))
);
}
#[test]
fn a_transport_failure_is_a_domain_error_and_names_no_third_party() {
assert_eq!(
SourceError::Transport.to_string(),
"the transport could not read the next frame"
);
}
#[test]
fn a_failed_read_stays_failed_and_never_degrades_into_a_closed_stream() {
let source = Broken;
for attempt in 0..2 {
assert_eq!(
drive(FrameSource::next_frame(&source)),
Some(Err(SourceError::Transport)),
"attempt {attempt}"
);
}
}
}