Skip to main content

cloud_sdk_testkit/
stream.rs

1//! Deterministic bounded streaming sources and sinks.
2
3use cloud_sdk::buffer::sanitize_bytes;
4use cloud_sdk::transport::{
5    AsyncStreamSink, AsyncStreamSource, BlockingStreamSink, BlockingStreamSource,
6    StreamPartialState, StreamRead, StreamReplayability,
7};
8
9/// Maximum chunks in one borrowed stream fixture.
10pub const MAX_STREAM_FIXTURE_CHUNKS: usize = 1_024;
11
12/// Invalid fixture or deterministic fixture I/O failure.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum StreamFixtureError {
15    /// The fixture contains too many chunks.
16    TooManyChunks,
17    /// One source chunk exceeds supplied scratch storage.
18    SourceScratchTooSmall,
19    /// Sink storage cannot accept the next deterministic write.
20    SinkStorageTooSmall,
21    /// The configured maximum sink write is zero.
22    ZeroWriteLimit,
23}
24
25impl_static_error!(StreamFixtureError,
26    Self::TooManyChunks => "stream fixture contains too many chunks",
27    Self::SourceScratchTooSmall => "stream fixture scratch storage is too small",
28    Self::SinkStorageTooSmall => "stream fixture sink storage is too small",
29    Self::ZeroWriteLimit => "stream fixture sink write limit is zero",
30);
31
32/// Borrowed ordered chunks for one deterministic finite source.
33pub struct StreamFixtureSource<'fixture> {
34    chunks: &'fixture [&'fixture [u8]],
35    index: usize,
36    observations: usize,
37    replayability: StreamReplayability<'fixture>,
38}
39
40impl<'fixture> StreamFixtureSource<'fixture> {
41    /// Creates one bounded source. Empty borrowed slices represent explicit
42    /// empty chunks rather than end-of-stream.
43    pub const fn new(chunks: &'fixture [&'fixture [u8]]) -> Result<Self, StreamFixtureError> {
44        if chunks.len() > MAX_STREAM_FIXTURE_CHUNKS {
45            return Err(StreamFixtureError::TooManyChunks);
46        }
47        Ok(Self {
48            chunks,
49            index: 0,
50            observations: 0,
51            replayability: StreamReplayability::NotReplayable,
52        })
53    }
54
55    /// Creates a bounded source with an explicit replay capability.
56    pub const fn with_replayability(
57        chunks: &'fixture [&'fixture [u8]],
58        replayability: StreamReplayability<'fixture>,
59    ) -> Result<Self, StreamFixtureError> {
60        if chunks.len() > MAX_STREAM_FIXTURE_CHUNKS {
61            return Err(StreamFixtureError::TooManyChunks);
62        }
63        Ok(Self {
64            chunks,
65            index: 0,
66            observations: 0,
67            replayability,
68        })
69    }
70
71    /// Returns source observations including the final end marker.
72    #[must_use]
73    pub const fn observations(&self) -> usize {
74        self.observations
75    }
76
77    fn read(&mut self, output: &mut [u8]) -> Result<StreamRead, StreamFixtureError> {
78        self.observations = self.observations.saturating_add(1);
79        let Some(chunk) = self.chunks.get(self.index) else {
80            return Ok(StreamRead::End);
81        };
82        let target = output
83            .get_mut(..chunk.len())
84            .ok_or(StreamFixtureError::SourceScratchTooSmall)?;
85        target.copy_from_slice(chunk);
86        self.index = self.index.saturating_add(1);
87        Ok(StreamRead::Chunk(chunk.len()))
88    }
89}
90
91impl BlockingStreamSource for StreamFixtureSource<'_> {
92    type Error = StreamFixtureError;
93
94    fn replayability(&self) -> StreamReplayability<'_> {
95        self.replayability
96    }
97
98    fn read_chunk(&mut self, output: &mut [u8]) -> Result<StreamRead, Self::Error> {
99        self.read(output)
100    }
101}
102
103impl AsyncStreamSource for StreamFixtureSource<'_> {
104    type Error = StreamFixtureError;
105
106    fn replayability(&self) -> StreamReplayability<'_> {
107        self.replayability
108    }
109
110    async fn read_chunk<'operation>(
111        &'operation mut self,
112        output: &'operation mut [u8],
113    ) -> Result<StreamRead, Self::Error> {
114        self.read(output)
115    }
116}
117
118/// Caller-buffered deterministic sink with configurable short writes.
119pub struct StreamFixtureSink<'storage> {
120    output: &'storage mut [u8],
121    initialized_len: usize,
122    max_write_bytes: usize,
123    writes: usize,
124    committed: bool,
125    aborted: Option<StreamPartialState>,
126}
127
128impl<'storage> StreamFixtureSink<'storage> {
129    /// Creates a sink and clears all caller storage before first use.
130    pub fn new(
131        output: &'storage mut [u8],
132        max_write_bytes: usize,
133    ) -> Result<Self, StreamFixtureError> {
134        sanitize_bytes(output);
135        if max_write_bytes == 0 {
136            return Err(StreamFixtureError::ZeroWriteLimit);
137        }
138        Ok(Self {
139            output,
140            initialized_len: 0,
141            max_write_bytes,
142            writes: 0,
143            committed: false,
144            aborted: None,
145        })
146    }
147
148    /// Returns initialized sink bytes.
149    #[must_use]
150    pub fn bytes(&self) -> &[u8] {
151        self.output.get(..self.initialized_len).unwrap_or_default()
152    }
153
154    /// Returns deterministic sink write count.
155    #[must_use]
156    pub const fn writes(&self) -> usize {
157        self.writes
158    }
159
160    /// Reports whether the successful stream was committed.
161    #[must_use]
162    pub const fn is_committed(&self) -> bool {
163        self.committed
164    }
165
166    /// Returns the last incomplete-state abort classification.
167    #[must_use]
168    pub const fn aborted_with(&self) -> Option<StreamPartialState> {
169        self.aborted
170    }
171
172    fn write(&mut self, input: &[u8]) -> Result<usize, StreamFixtureError> {
173        let accepted = core::cmp::min(input.len(), self.max_write_bytes);
174        let end = self
175            .initialized_len
176            .checked_add(accepted)
177            .ok_or(StreamFixtureError::SinkStorageTooSmall)?;
178        let target = self
179            .output
180            .get_mut(self.initialized_len..end)
181            .ok_or(StreamFixtureError::SinkStorageTooSmall)?;
182        let source = input
183            .get(..accepted)
184            .ok_or(StreamFixtureError::SinkStorageTooSmall)?;
185        target.copy_from_slice(source);
186        self.initialized_len = end;
187        self.writes = self.writes.saturating_add(1);
188        Ok(accepted)
189    }
190
191    fn commit_inner(&mut self) {
192        self.committed = true;
193    }
194
195    fn abort_inner(&mut self, partial: StreamPartialState) {
196        self.aborted = Some(partial);
197        if matches!(partial, StreamPartialState::RollbackRequired) {
198            sanitize_bytes(self.output);
199            self.initialized_len = 0;
200        }
201    }
202}
203
204impl BlockingStreamSink for StreamFixtureSink<'_> {
205    type Error = StreamFixtureError;
206
207    fn write_chunk(&mut self, input: &[u8]) -> Result<usize, Self::Error> {
208        self.write(input)
209    }
210
211    fn commit(&mut self) -> Result<(), Self::Error> {
212        self.commit_inner();
213        Ok(())
214    }
215
216    fn abort(&mut self, partial: StreamPartialState) {
217        self.abort_inner(partial);
218    }
219}
220
221impl AsyncStreamSink for StreamFixtureSink<'_> {
222    type Error = StreamFixtureError;
223
224    async fn write_chunk<'operation>(
225        &'operation mut self,
226        input: &'operation [u8],
227    ) -> Result<usize, Self::Error> {
228        self.write(input)
229    }
230
231    async fn commit(&mut self) -> Result<(), Self::Error> {
232        self.commit_inner();
233        Ok(())
234    }
235
236    fn abort(&mut self, partial: StreamPartialState) {
237        self.abort_inner(partial);
238    }
239}