1use sim_kernel::{Error, Expr, Result, Symbol};
12use sim_value::access;
13
14#[path = "cassette/redaction.rs"]
15mod redaction;
16#[path = "cassette/stats.rs"]
17mod stats;
18#[path = "cassette/timing.rs"]
19mod timing;
20
21use crate::buffer::{expr_kind, field, string_field, symbol_field};
22use crate::{
23 StreamCapability, StreamEnvelope, StreamItem, StreamMetadata, StreamStats, StreamValue,
24 TransportProfile,
25};
26
27use redaction::{
28 envelope_has_host_device, is_host_device_symbol, metadata_has_host_device,
29 packet_has_private_payload, redact_envelope, redact_metadata, redact_symbol,
30};
31use stats::{stream_stats_expr, stream_stats_from_expr};
32use timing::{diagnostics_from_envelopes, timing_from_envelopes};
33
34pub const STREAM_CASSETTE_FIXTURE_ROOT: &str = "fixtures/streams/golden";
36pub const STREAM_CASSETTE_EXTENSION: &str = "simcassette";
38
39#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct StreamCassetteTiming {
46 pub clock: Symbol,
48 pub packet_count: usize,
50 pub first_sequence: Option<u64>,
52 pub last_sequence: Option<u64>,
54 pub finite: bool,
56}
57
58#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct StreamCassette {
67 metadata: StreamMetadata,
68 envelopes: Vec<StreamEnvelope>,
69 timing: StreamCassetteTiming,
70 diagnostics: Vec<Symbol>,
71 final_stats: StreamStats,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct StreamGoldenFixtureReport {
81 pub path: String,
83 pub format: Symbol,
85 pub packet_count: usize,
87 pub final_stats: StreamStats,
89}
90
91impl StreamCassette {
92 pub fn from_stream_value(stream: &StreamValue, profile: TransportProfile) -> Result<Self> {
100 let mut items = Vec::new();
101 while let Some(item) = stream.next_packet()? {
102 items.push(item);
103 }
104 if !stream.is_done()? {
105 return Err(Error::Eval(
106 "cannot record a cassette from a stream that has not reached done".to_owned(),
107 ));
108 }
109 let final_stats = stream.stats()?;
110 Self::from_items(stream.metadata().clone(), items, profile, final_stats)
111 }
112
113 pub fn from_items(
119 metadata: StreamMetadata,
120 items: Vec<StreamItem>,
121 profile: TransportProfile,
122 final_stats: StreamStats,
123 ) -> Result<Self> {
124 let envelopes = items
125 .iter()
126 .enumerate()
127 .map(|(sequence, item)| {
128 StreamEnvelope::from_item_with_profile(
129 &metadata,
130 sequence as u64,
131 item,
132 profile.clone(),
133 )
134 })
135 .collect::<Result<Vec<_>>>()?;
136 Self::from_envelopes(metadata, envelopes, final_stats)
137 }
138
139 pub fn from_envelopes(
145 metadata: StreamMetadata,
146 envelopes: Vec<StreamEnvelope>,
147 final_stats: StreamStats,
148 ) -> Result<Self> {
149 let timing = timing_from_envelopes(&metadata, &envelopes);
150 let diagnostics = diagnostics_from_envelopes(&envelopes);
151 Ok(Self {
152 metadata,
153 envelopes,
154 timing,
155 diagnostics,
156 final_stats,
157 })
158 }
159
160 pub fn metadata(&self) -> &StreamMetadata {
162 &self.metadata
163 }
164
165 pub fn envelopes(&self) -> &[StreamEnvelope] {
167 &self.envelopes
168 }
169
170 pub fn timing(&self) -> &StreamCassetteTiming {
172 &self.timing
173 }
174
175 pub fn diagnostics(&self) -> &[Symbol] {
177 &self.diagnostics
178 }
179
180 pub fn final_stats(&self) -> &StreamStats {
182 &self.final_stats
183 }
184
185 pub fn items(&self) -> Result<Vec<StreamItem>> {
190 self.envelopes
191 .iter()
192 .map(|envelope| {
193 StreamItem::with_ticks(envelope.packet().clone(), envelope.ticks().to_vec())
194 })
195 .collect()
196 }
197
198 pub fn replay_stream_value(&self) -> Result<StreamValue> {
200 Ok(StreamValue::pull(self.metadata.clone(), self.items()?))
201 }
202
203 pub fn to_expr(&self) -> Expr {
209 Expr::Map(vec![
210 (
211 Expr::Symbol(Symbol::new("cassette")),
212 Expr::Symbol(stream_cassette_format_symbol()),
213 ),
214 (
215 Expr::Symbol(Symbol::new("metadata")),
216 self.metadata.table_expr(),
217 ),
218 (Expr::Symbol(Symbol::new("timing")), self.timing.to_expr()),
219 (
220 Expr::Symbol(Symbol::new("envelopes")),
221 Expr::List(self.envelopes.iter().map(StreamEnvelope::to_expr).collect()),
222 ),
223 (
224 Expr::Symbol(Symbol::new("diagnostics")),
225 Expr::List(self.diagnostics.iter().cloned().map(Expr::Symbol).collect()),
226 ),
227 (
228 Expr::Symbol(Symbol::new("final-stats")),
229 stream_stats_expr(&self.final_stats),
230 ),
231 ])
232 }
233
234 pub fn from_expr(expr: &Expr) -> Result<Self> {
241 let Expr::Map(entries) = expr else {
242 return Err(Error::TypeMismatch {
243 expected: "stream cassette map",
244 found: expr_kind(expr),
245 });
246 };
247 ensure_fields(
248 entries,
249 &[
250 "cassette",
251 "metadata",
252 "timing",
253 "envelopes",
254 "diagnostics",
255 "final-stats",
256 ],
257 )?;
258 let format = symbol_field(entries, "cassette")?;
259 if *format != stream_cassette_format_symbol() {
260 return Err(Error::Eval(format!(
261 "unknown stream cassette format {}",
262 format.as_qualified_str()
263 )));
264 }
265 let metadata = StreamMetadata::from_table_expr(field(entries, "metadata")?)?;
266 let envelopes = list_field(entries, "envelopes")?
267 .iter()
268 .map(|expr| StreamEnvelope::try_from(expr.clone()))
269 .collect::<Result<Vec<_>>>()?;
270 let metadata = restore_metadata_id(metadata, &envelopes);
271 let timing = StreamCassetteTiming::from_expr(field(entries, "timing")?)?;
272 let diagnostics = symbol_list(entries, "diagnostics")?;
273 let final_stats = stream_stats_from_expr(field(entries, "final-stats")?)?;
274 Ok(Self {
275 metadata,
276 envelopes,
277 timing,
278 diagnostics,
279 final_stats,
280 })
281 }
282
283 pub fn redacted(&self) -> Result<Self> {
288 let metadata = redact_metadata(&self.metadata);
289 let envelopes = self
290 .envelopes
291 .iter()
292 .map(redact_envelope)
293 .collect::<Result<Vec<_>>>()?;
294 let mut redacted = Self::from_envelopes(metadata, envelopes, self.final_stats.clone())?;
295 redacted.diagnostics = self.diagnostics.iter().map(redact_symbol).collect();
296 Ok(redacted)
297 }
298
299 pub fn validate_golden_fixture(&self, path: &str) -> Result<StreamGoldenFixtureReport> {
308 validate_fixture_path(path)?;
309 if !self.timing.finite {
310 return Err(Error::Eval(
311 "golden stream fixture must be finite".to_owned(),
312 ));
313 }
314 for (index, envelope) in self.envelopes.iter().enumerate() {
315 if envelope.sequence() != index as u64 {
316 return Err(Error::Eval(format!(
317 "golden stream fixture sequence {} is not packet index {index}",
318 envelope.sequence()
319 )));
320 }
321 if !envelope
322 .profile()
323 .has_capability(StreamCapability::Replayable)
324 && !envelope.profile().has_capability(StreamCapability::Preview)
325 {
326 return Err(Error::Eval(format!(
327 "golden stream fixture profile {} is not replayable or previewable",
328 envelope.profile().name()
329 )));
330 }
331 if envelope
332 .profile()
333 .has_capability(StreamCapability::Realtime)
334 {
335 return Err(Error::Eval(
336 "golden stream fixture cannot require realtime transport".to_owned(),
337 ));
338 }
339 if packet_has_private_payload(envelope.packet()) || envelope_has_host_device(envelope) {
340 return Err(Error::Eval(
341 "golden stream fixture contains unredacted payload".to_owned(),
342 ));
343 }
344 }
345 if metadata_has_host_device(&self.metadata)
346 || is_host_device_symbol(&self.timing.clock)
347 || self.diagnostics.iter().any(is_host_device_symbol)
348 {
349 return Err(Error::Eval(
350 "golden stream fixture contains an unredacted host device name".to_owned(),
351 ));
352 }
353 Ok(StreamGoldenFixtureReport {
354 path: path.to_owned(),
355 format: stream_cassette_format_symbol(),
356 packet_count: self.envelopes.len(),
357 final_stats: self.final_stats.clone(),
358 })
359 }
360}
361
362pub fn stream_cassette_format_symbol() -> Symbol {
364 Symbol::qualified("stream/cassette", "v1")
365}
366
367pub fn stream_cassette_golden_root() -> &'static str {
369 STREAM_CASSETTE_FIXTURE_ROOT
370}
371
372pub fn stream_cassette_golden_extension() -> &'static str {
374 STREAM_CASSETTE_EXTENSION
375}
376
377fn restore_metadata_id(metadata: StreamMetadata, envelopes: &[StreamEnvelope]) -> StreamMetadata {
378 let Some(first) = envelopes.first() else {
379 return metadata;
380 };
381 if metadata.id().as_qualified_str() != first.stream_id().as_qualified_str() {
382 return metadata;
383 }
384 StreamMetadata::new(
385 first.stream_id().clone(),
386 metadata.media(),
387 metadata.direction(),
388 metadata.clock().clone(),
389 metadata.buffer().clone(),
390 )
391}
392
393fn validate_fixture_path(path: &str) -> Result<()> {
394 let Some(relative) = path.strip_prefix(STREAM_CASSETTE_FIXTURE_ROOT) else {
395 return Err(Error::Eval(format!(
396 "golden stream fixture path must live under {STREAM_CASSETTE_FIXTURE_ROOT}"
397 )));
398 };
399 if !relative.starts_with('/') || relative == "/" {
400 return Err(Error::Eval(format!(
401 "golden stream fixture path must live under {STREAM_CASSETTE_FIXTURE_ROOT}"
402 )));
403 }
404 let expected_extension = format!(".{STREAM_CASSETTE_EXTENSION}");
405 if !path.ends_with(&expected_extension) {
406 return Err(Error::Eval(format!(
407 "golden stream fixture path must end in .{STREAM_CASSETTE_EXTENSION}"
408 )));
409 }
410 Ok(())
411}
412
413fn ensure_fields(entries: &[(Expr, Expr)], allowed: &[&str]) -> Result<()> {
414 for (key, _) in entries {
415 let Expr::Symbol(symbol) = key else {
416 return Err(Error::TypeMismatch {
417 expected: "symbol stream cassette field",
418 found: expr_kind(key),
419 });
420 };
421 if symbol.namespace.is_none() && allowed.contains(&symbol.name.as_ref()) {
422 continue;
423 }
424 return Err(Error::Eval(format!(
425 "unknown stream cassette field {}",
426 symbol.as_qualified_str()
427 )));
428 }
429 Ok(())
430}
431
432fn list_field<'a>(entries: &'a [(Expr, Expr)], name: &str) -> Result<&'a [Expr]> {
433 access::entry_required_list(entries, name, "list field")
434}
435
436fn symbol_list(entries: &[(Expr, Expr)], name: &str) -> Result<Vec<Symbol>> {
437 list_field(entries, name)?
438 .iter()
439 .map(|expr| match expr {
440 Expr::Symbol(symbol) => Ok(symbol.clone()),
441 other => Err(Error::TypeMismatch {
442 expected: "symbol list item",
443 found: expr_kind(other),
444 }),
445 })
446 .collect()
447}
448
449fn parse_usize(entries: &[(Expr, Expr)], name: &str) -> Result<usize> {
450 string_field(entries, name)?
451 .parse::<usize>()
452 .map_err(|err| Error::Eval(format!("invalid stream cassette {name}: {err}")))
453}
454
455fn optional_u64(expr: &Expr) -> Result<Option<u64>> {
456 match expr {
457 Expr::Nil => Ok(None),
458 Expr::String(value) => value
459 .parse::<u64>()
460 .map(Some)
461 .map_err(|err| Error::Eval(format!("invalid stream cassette sequence: {err}"))),
462 other => Err(Error::TypeMismatch {
463 expected: "optional u64 string",
464 found: expr_kind(other),
465 }),
466 }
467}
468
469fn optional_u64_expr(value: Option<u64>) -> Expr {
470 value
471 .map(|value| Expr::String(value.to_string()))
472 .unwrap_or(Expr::Nil)
473}
474
475fn bool_field(entries: &[(Expr, Expr)], name: &str) -> Result<bool> {
476 access::entry_required_bool(entries, name, "bool field")
477}