Skip to main content

wyrd/runtime_impl/
error.rs

1//! Runtime-facing errors: bind failures, bad dense handles, and scenarios.
2
3use core::fmt;
4
5use crate::authoring::{BuildError, ValidationError};
6use crate::foundation::{NumericPath, PortSlot, SignalDomain};
7use std::boxed::Box;
8use std::string::String;
9
10use crate::runtime_impl::handles::{CmdId, HostPathId, KnotHandle, SenseId};
11
12/// Failure while restoring an opaque [`RuntimeState`](crate::RuntimeState).
13#[derive(Clone, Debug, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum RestoreError {
16    /// The snapshot uses a format this runtime cannot read.
17    UnsupportedVersion {
18        /// Version carried by the rejected snapshot.
19        found: u32,
20        /// Version accepted by this runtime.
21        supported: u32,
22    },
23    /// The snapshot belongs to an incompatible immutable executable graph.
24    FingerprintMismatch {
25        /// Fingerprint of the currently bound executable runtime.
26        expected: u64,
27        /// Fingerprint carried by the rejected snapshot.
28        found: u64,
29    },
30    /// The checkpoint was produced by the other compile-time signal path.
31    NumericPathMismatch {
32        /// Numeric path required by the receiving runtime.
33        expected: NumericPath,
34        /// Numeric path carried by the checkpoint.
35        found: NumericPath,
36    },
37    /// The checkpoint was captured at a frame phase that cannot be restored.
38    InvalidPhase {
39        /// Opaque wire phase value.
40        found: u8,
41    },
42    /// A stored signal does not satisfy its declared runtime domain.
43    InvalidSignal {
44        /// Checkpoint field that contained the invalid value.
45        field: &'static str,
46        /// Dense knot slot whose semantic contract rejected it.
47        knot: usize,
48        /// Declared signal domain.
49        domain: SignalDomain,
50    },
51    /// A timer value is outside the duration authored for its knot.
52    InvalidTimer {
53        /// Dense timer knot slot.
54        knot: usize,
55        /// Stored remaining ticks.
56        remaining: u16,
57        /// Authored timer duration.
58        max: u16,
59    },
60    /// A delay head is outside its immutable ring extent.
61    InvalidDelayHead {
62        /// Dense delay knot slot.
63        knot: usize,
64        /// Stored ring head.
65        head: u16,
66        /// Immutable ring length.
67        len: u16,
68    },
69    /// The xorshift stream must never be zero.
70    InvalidRng,
71    /// A mutable snapshot buffer does not fit this runtime's bound shape.
72    ShapeMismatch {
73        /// Snapshot buffer whose length differs.
74        field: &'static str,
75        /// Length required by the bound runtime.
76        expected: usize,
77        /// Length carried by the rejected snapshot.
78        found: usize,
79    },
80    /// An owner-free outbox id cannot be rebuilt for this runtime.
81    InvalidHandleIndex {
82        /// Outbox field containing the invalid dense index.
83        field: &'static str,
84        /// Owner-free dense index carried by the snapshot.
85        index: u16,
86        /// Number of valid local handles in the bound runtime.
87        len: usize,
88    },
89}
90
91impl fmt::Display for RestoreError {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        match self {
94            Self::UnsupportedVersion { found, supported } => write!(
95                f,
96                "runtime snapshot format version {found} is unsupported (expected {supported})"
97            ),
98            Self::FingerprintMismatch { expected, found } => write!(
99                f,
100                "runtime snapshot fingerprint {found:016x} does not match {expected:016x}"
101            ),
102            Self::NumericPathMismatch { expected, found } => write!(
103                f,
104                "runtime checkpoint numeric path {found:?} does not match {expected:?}"
105            ),
106            Self::InvalidPhase { found } => write!(f, "runtime checkpoint phase {found} is invalid"),
107            Self::InvalidSignal { field, knot, domain } => write!(
108                f,
109                "runtime checkpoint field '{field}' has an invalid {domain:?} signal at knot {knot}"
110            ),
111            Self::InvalidTimer { knot, remaining, max } => write!(
112                f,
113                "runtime checkpoint timer at knot {knot} has {remaining} ticks remaining, maximum is {max}"
114            ),
115            Self::InvalidDelayHead { knot, head, len } => write!(
116                f,
117                "runtime checkpoint delay at knot {knot} has head {head}, length is {len}"
118            ),
119            Self::InvalidRng => f.write_str("runtime checkpoint has an invalid zero RNG state"),
120            Self::ShapeMismatch {
121                field,
122                expected,
123                found,
124            } => write!(
125                f,
126                "runtime snapshot field '{field}' has length {found}, expected {expected}"
127            ),
128            Self::InvalidHandleIndex { field, index, len } => write!(
129                f,
130                "runtime snapshot field '{field}' has invalid index {index} for length {len}"
131            ),
132        }
133    }
134}
135
136#[cfg(feature = "std")]
137impl std::error::Error for RestoreError {}
138
139/// Failure while binding a fresh runtime from a durable checkpoint.
140#[derive(Clone, Debug, PartialEq, Eq)]
141#[non_exhaustive]
142pub enum BindRestoreError {
143    /// The weave could not be bound.
144    Bind(Box<BindError>),
145    /// The freshly bound runtime rejected the checkpoint.
146    Restore(RestoreError),
147}
148
149impl fmt::Display for BindRestoreError {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        match self {
152            Self::Bind(error) => write!(f, "cannot bind restored runtime: {error}"),
153            Self::Restore(error) => write!(f, "cannot restore runtime checkpoint: {error}"),
154        }
155    }
156}
157
158#[cfg(feature = "std")]
159impl std::error::Error for BindRestoreError {}
160
161/// Failure while applying a named authored runtime preset.
162#[derive(Clone, Debug, PartialEq, Eq)]
163#[non_exhaustive]
164pub enum PresetError {
165    /// The weave could not be bound.
166    Bind(Box<BindError>),
167    /// More than one entry targeted the same authored knot.
168    Duplicate {
169        /// Authored knot name listed more than once in the preset.
170        knot: String,
171    },
172    /// An entry named no knot in this weave.
173    Missing {
174        /// Authored knot name absent from the bound weave.
175        knot: String,
176    },
177    /// An entry targeted an incompatible knot kind.
178    WrongKind {
179        /// Authored knot name that does not match the expected kind.
180        knot: String,
181        /// Expected knot kind label (for example `"Flag"` or `"Counter"`).
182        expected: &'static str,
183    },
184    /// A SignalIn entry violated its declared signal domain.
185    InvalidSignal {
186        /// Authored SignalIn knot name carrying the invalid value.
187        knot: String,
188        /// Declared signal domain that rejected the preset value.
189        domain: SignalDomain,
190    },
191}
192
193impl fmt::Display for PresetError {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        match self {
196            Self::Bind(error) => write!(f, "cannot bind preset runtime: {error}"),
197            Self::Duplicate { knot } => write!(f, "runtime preset names '{knot}' more than once"),
198            Self::Missing { knot } => write!(f, "runtime preset knot '{knot}' is missing"),
199            Self::WrongKind { knot, expected } => {
200                write!(f, "runtime preset knot '{knot}' is not a {expected}")
201            }
202            Self::InvalidSignal { knot, domain } => write!(
203                f,
204                "runtime preset signal '{knot}' is invalid for {domain:?}"
205            ),
206        }
207    }
208}
209
210#[cfg(feature = "std")]
211impl std::error::Error for PresetError {}
212
213/// Failure while turning an authored [`crate::authoring::Weave`] into a runtime.
214#[derive(Clone, Debug, PartialEq, Eq)]
215#[non_exhaustive]
216pub enum BindError {
217    /// The consumed weave failed graph validation.
218    InvalidWeave {
219        /// Author weave id passed to bind.
220        weave_id: String,
221        /// Structural validation failure from [`ValidationError`].
222        source: ValidationError,
223    },
224    /// A validated weave exceeded a dense runtime representation.
225    CapacityExceeded {
226        /// Author weave id passed to bind.
227        weave_id: String,
228        /// Dense resource that overflowed (for example `"knot"`).
229        resource: &'static str,
230        /// Observed count that exceeded the runtime cap.
231        count: usize,
232    },
233    /// Validated graph data could not be resolved during binding.
234    InvalidReference {
235        /// Author weave id passed to bind.
236        weave_id: String,
237        /// Knot id whose port could not be interned.
238        knot: String,
239        /// Catalog port name that failed resolution.
240        port: String,
241    },
242    /// The validated topology could not be ordered.
243    InvalidTopology {
244        /// Author weave id passed to bind.
245        weave_id: String,
246    },
247}
248
249impl fmt::Display for BindError {
250    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251        match self {
252            Self::InvalidWeave { weave_id, source } => {
253                write!(f, "cannot bind weave '{weave_id}': {source}")
254            }
255            Self::CapacityExceeded {
256                weave_id,
257                resource,
258                count,
259            } => write!(
260                f,
261                "cannot bind weave '{weave_id}': {resource} count {count} exceeds runtime capacity"
262            ),
263            Self::InvalidReference {
264                weave_id,
265                knot,
266                port,
267            } => write!(
268                f,
269                "cannot bind weave '{weave_id}': unresolved port '{knot}.{port}'"
270            ),
271            Self::InvalidTopology { weave_id } => {
272                write!(f, "cannot bind weave '{weave_id}': invalid topology")
273            }
274        }
275    }
276}
277
278#[cfg(feature = "std")]
279impl std::error::Error for BindError {
280    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
281        match self {
282            Self::InvalidWeave { source, .. } => Some(source),
283            _ => None,
284        }
285    }
286}
287
288/// Failure caused by using a dense handle with the wrong runtime or port.
289#[derive(Clone, Copy, Debug, PartialEq, Eq)]
290#[non_exhaustive]
291pub enum HandleError {
292    /// Dense handle belongs to a different [`Runtime`](crate::runtime_impl::bind::Runtime).
293    ForeignRuntime {
294        /// Human-readable handle kind (`"sense"`, `"host path"`, …).
295        handle: &'static str,
296    },
297    /// [`SenseId`] is out of range or does not reference a `SignalIn` knot.
298    InvalidSense {
299        /// Rejected dense sense handle.
300        sense: SenseId,
301    },
302    /// [`HostPathId`] is out of range for this runtime.
303    InvalidHostPath {
304        /// Rejected dense host-path handle.
305        path: HostPathId,
306    },
307    /// [`CmdId`] is out of range for this runtime.
308    InvalidCommand {
309        /// Rejected dense command handle.
310        cmd: CmdId,
311    },
312    /// [`KnotHandle`] is out of range for this runtime.
313    InvalidKnot {
314        /// Rejected dense knot handle.
315        knot: KnotHandle,
316    },
317    /// [`PortSlot`] is invalid for the given knot in this runtime.
318    InvalidPort {
319        /// Knot that owns the rejected port slot.
320        knot: KnotHandle,
321        /// Rejected catalog port slot.
322        port: PortSlot,
323    },
324    /// A host-authored sense value violates its declared signal domain.
325    DomainValue {
326        /// Sense port that rejected the write.
327        sense: SenseId,
328        /// Declared domain for that `SignalIn` knot.
329        domain: SignalDomain,
330    },
331}
332
333impl fmt::Display for HandleError {
334    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
335        match self {
336            Self::ForeignRuntime { handle } => {
337                write!(f, "{handle} handle belongs to a different runtime")
338            }
339            Self::InvalidSense { sense } => {
340                write!(
341                    f,
342                    "sense handle {} is invalid for this runtime",
343                    sense.get()
344                )
345            }
346            Self::InvalidHostPath { path } => {
347                write!(f, "host path handle {} is invalid", path.get())
348            }
349            Self::InvalidCommand { cmd } => {
350                write!(f, "command handle {} is invalid", cmd.get())
351            }
352            Self::InvalidKnot { knot } => {
353                write!(f, "knot handle {} is invalid for this runtime", knot.get())
354            }
355            Self::InvalidPort { knot, port } => write!(
356                f,
357                "port handle {} is invalid for knot {} in this runtime",
358                port.get(),
359                knot.get()
360            ),
361            Self::DomainValue { sense, domain } => write!(
362                f,
363                "sense value for handle {} is invalid for {domain:?} domain",
364                sense.get()
365            ),
366        }
367    }
368}
369
370#[cfg(feature = "std")]
371impl std::error::Error for HandleError {}
372
373/// The kind of named endpoint a [`crate::Recipe`] requires from a bound runtime.
374#[derive(Clone, Copy, Debug, PartialEq, Eq)]
375pub enum RecipeEndpoint {
376    /// A host-writable [`crate::SenseId`] backed by a `SignalIn` knot.
377    SignalIn,
378    /// An interned [`crate::HostPathId`] backed by a `SignalOut` knot.
379    SignalOut,
380    /// An interned [`crate::CmdId`] backed by an `EmitCommand` knot.
381    EmitCommand,
382    /// A named knot used for checked tooling access.
383    Knot,
384    /// A catalog port on a named knot.
385    Port,
386}
387
388impl fmt::Display for RecipeEndpoint {
389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390        match self {
391            Self::SignalIn => f.write_str("SignalIn"),
392            Self::SignalOut => f.write_str("SignalOut"),
393            Self::EmitCommand => f.write_str("EmitCommand"),
394            Self::Knot => f.write_str("knot"),
395            Self::Port => f.write_str("port"),
396        }
397    }
398}
399
400/// Failure while resolving one of a recipe's required named endpoints.
401#[derive(Clone, Debug, PartialEq, Eq)]
402#[non_exhaustive]
403pub enum RecipeResolveError {
404    /// The required endpoint was absent from the bound runtime.
405    Missing {
406        /// Required endpoint category.
407        endpoint: RecipeEndpoint,
408        /// Author knot id, host path, command name, or `knot.port` reference.
409        name: String,
410    },
411    /// A named endpoint exists but does not satisfy the requested contract.
412    Invalid {
413        /// Required endpoint category.
414        endpoint: RecipeEndpoint,
415        /// Author knot id, host path, command name, or `knot.port` reference.
416        name: String,
417        /// Stable explanation of the incompatible endpoint.
418        reason: &'static str,
419    },
420}
421
422impl fmt::Display for RecipeResolveError {
423    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
424        match self {
425            Self::Missing { endpoint, name } => {
426                write!(f, "required {endpoint} endpoint '{name}' is missing")
427            }
428            Self::Invalid {
429                endpoint,
430                name,
431                reason,
432            } => write!(
433                f,
434                "required {endpoint} endpoint '{name}' is invalid: {reason}"
435            ),
436        }
437    }
438}
439
440#[cfg(feature = "std")]
441impl std::error::Error for RecipeResolveError {}
442
443/// Failure while constructing, binding, or resolving a [`crate::Recipe`].
444#[derive(Clone, Debug, PartialEq, Eq)]
445#[non_exhaustive]
446pub enum RecipeError {
447    /// The recipe could not construct its validated weave.
448    Build(BuildError),
449    /// The recipe's weave could not bind into a runtime.
450    Bind(Box<BindError>),
451    /// A required typed endpoint could not be resolved from the bound runtime.
452    Resolve(RecipeResolveError),
453}
454
455impl From<BuildError> for RecipeError {
456    fn from(value: BuildError) -> Self {
457        Self::Build(value)
458    }
459}
460
461impl From<BindError> for RecipeError {
462    fn from(value: BindError) -> Self {
463        Self::Bind(Box::new(value))
464    }
465}
466
467impl From<RecipeResolveError> for RecipeError {
468    fn from(value: RecipeResolveError) -> Self {
469        Self::Resolve(value)
470    }
471}
472
473impl fmt::Display for RecipeError {
474    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
475        match self {
476            Self::Build(source) => write!(f, "cannot build recipe: {source}"),
477            Self::Bind(source) => write!(f, "cannot bind recipe: {source}"),
478            Self::Resolve(source) => write!(f, "cannot resolve recipe ports: {source}"),
479        }
480    }
481}
482
483#[cfg(feature = "std")]
484impl std::error::Error for RecipeError {
485    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
486        match self {
487            Self::Build(source) => Some(source),
488            Self::Bind(source) => Some(source.as_ref()),
489            Self::Resolve(source) => Some(source),
490        }
491    }
492}
493
494/// Failure while driving or asserting a typed [`crate::Scenario`].
495#[derive(Clone, Debug, PartialEq)]
496#[non_exhaustive]
497pub enum ScenarioError {
498    /// Binding the recipe for the scenario failed.
499    Recipe(RecipeError),
500    /// A typed frame write or endpoint lookup used an invalid runtime handle.
501    Handle(HandleError),
502    /// An expectation ran before the scenario had produced a sample for the path.
503    MissingSignal {
504        /// Host path selected through the recipe's typed ports.
505        path: String,
506        /// Scenario frame that was being inspected.
507        tick: u64,
508    },
509    /// A signal sample did not have the expected value.
510    UnexpectedSignal {
511        /// Host path selected through the recipe's typed ports.
512        path: String,
513        /// Expected sample.
514        expected: crate::Signal,
515        /// Sample produced by the runtime.
516        actual: crate::Signal,
517        /// Scenario frame that produced the sample.
518        tick: u64,
519    },
520    /// A signal sample was present but falsey.
521    ExpectedTruthy {
522        /// Host path selected through the recipe's typed ports.
523        path: String,
524        /// Falsey sample produced by the runtime.
525        actual: crate::Signal,
526        /// Scenario frame that produced the sample.
527        tick: u64,
528    },
529    /// A command was emitted a different number of times than expected.
530    UnexpectedEmits {
531        /// Command selected through the recipe's typed ports.
532        command: String,
533        /// Expected number of emits in the current frame.
534        expected: usize,
535        /// Actual number of emits in the current frame.
536        actual: usize,
537        /// Scenario frame that produced the emits.
538        tick: u64,
539    },
540}
541
542impl From<RecipeError> for ScenarioError {
543    fn from(value: RecipeError) -> Self {
544        Self::Recipe(value)
545    }
546}
547
548impl From<HandleError> for ScenarioError {
549    fn from(value: HandleError) -> Self {
550        Self::Handle(value)
551    }
552}
553
554impl fmt::Display for ScenarioError {
555    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
556        match self {
557            Self::Recipe(source) => write!(f, "cannot start scenario: {source}"),
558            Self::Handle(source) => write!(f, "scenario handle error: {source}"),
559            Self::MissingSignal { path, tick } => {
560                write!(f, "SignalOut path '{path}' has no sample in scenario frame {tick}")
561            }
562            Self::UnexpectedSignal {
563                path,
564                expected,
565                actual,
566                tick,
567            } => write!(
568                f,
569                "SignalOut path '{path}' in scenario frame {tick} was {actual:?}, expected {expected:?}"
570            ),
571            Self::ExpectedTruthy { path, actual, tick } => write!(
572                f,
573                "SignalOut path '{path}' in scenario frame {tick} was falsey ({actual:?})"
574            ),
575            Self::UnexpectedEmits {
576                command,
577                expected,
578                actual,
579                tick,
580            } => write!(
581                f,
582                "EmitCommand '{command}' in scenario frame {tick} fired {actual} times, expected {expected}"
583            ),
584        }
585    }
586}
587
588#[cfg(feature = "std")]
589impl std::error::Error for ScenarioError {
590    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
591        match self {
592            Self::Recipe(source) => Some(source),
593            Self::Handle(source) => Some(source),
594            _ => None,
595        }
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602    use std::error::Error;
603    use std::string::ToString;
604    use std::vec::Vec;
605
606    #[test]
607    fn every_runtime_error_variant_has_a_stable_diagnostic_and_source_chain() {
608        let restore_errors = [
609            RestoreError::UnsupportedVersion {
610                found: 0,
611                supported: 1,
612            },
613            RestoreError::FingerprintMismatch {
614                expected: 1,
615                found: 2,
616            },
617            RestoreError::ShapeMismatch {
618                field: "counter",
619                expected: 3,
620                found: 4,
621            },
622            RestoreError::InvalidHandleIndex {
623                field: "out_emits.command_index",
624                index: 5,
625                len: 6,
626            },
627        ];
628        let diagnostics: Vec<String> = restore_errors.iter().map(ToString::to_string).collect();
629        assert_eq!(
630            diagnostics,
631            [
632                "runtime snapshot format version 0 is unsupported (expected 1)",
633                "runtime snapshot fingerprint 0000000000000002 does not match 0000000000000001",
634                "runtime snapshot field 'counter' has length 4, expected 3",
635                "runtime snapshot field 'out_emits.command_index' has invalid index 5 for length 6",
636            ]
637        );
638        assert!(restore_errors
639            .iter()
640            .all(|error| Error::source(error).is_none()));
641
642        let validation = ValidationError::EmptyWeave {
643            weave_id: String::from("empty"),
644        };
645        let bind_errors = [
646            BindError::InvalidWeave {
647                weave_id: String::from("invalid"),
648                source: validation.clone(),
649            },
650            BindError::CapacityExceeded {
651                weave_id: String::from("capacity"),
652                resource: "knot",
653                count: 257,
654            },
655            BindError::InvalidReference {
656                weave_id: String::from("reference"),
657                knot: String::from("target"),
658                port: String::from("input"),
659            },
660            BindError::InvalidTopology {
661                weave_id: String::from("cycle"),
662            },
663        ];
664        let diagnostics: Vec<String> = bind_errors.iter().map(ToString::to_string).collect();
665        assert_eq!(
666            diagnostics,
667            [
668                "cannot bind weave 'invalid': weave 'empty' has no knots",
669                "cannot bind weave 'capacity': knot count 257 exceeds runtime capacity",
670                "cannot bind weave 'reference': unresolved port 'target.input'",
671                "cannot bind weave 'cycle': invalid topology",
672            ]
673        );
674        assert!(Error::source(&bind_errors[0]).is_some());
675        assert!(bind_errors[1..]
676            .iter()
677            .all(|error| Error::source(error).is_none()));
678
679        let owner = 7;
680        let handles = [
681            HandleError::ForeignRuntime { handle: "sense" },
682            HandleError::InvalidSense {
683                sense: SenseId::new(owner, 1),
684            },
685            HandleError::InvalidHostPath {
686                path: HostPathId::new(owner, 2),
687            },
688            HandleError::InvalidCommand {
689                cmd: CmdId::new(owner, 3),
690            },
691            HandleError::InvalidKnot {
692                knot: KnotHandle::new(owner, 4),
693            },
694            HandleError::InvalidPort {
695                knot: KnotHandle::new(owner, 5),
696                port: PortSlot::new(6),
697            },
698            HandleError::DomainValue {
699                sense: SenseId::new(owner, 7),
700                domain: SignalDomain::Count,
701            },
702        ];
703        let diagnostics: Vec<String> = handles.iter().map(ToString::to_string).collect();
704        assert_eq!(
705            diagnostics,
706            [
707                "sense handle belongs to a different runtime",
708                "sense handle 1 is invalid for this runtime",
709                "host path handle 2 is invalid",
710                "command handle 3 is invalid",
711                "knot handle 4 is invalid for this runtime",
712                "port handle 6 is invalid for knot 5 in this runtime",
713                "sense value for handle 7 is invalid for Count domain",
714            ]
715        );
716        assert!(handles.iter().all(|error| Error::source(error).is_none()));
717
718        let endpoints = [
719            (RecipeEndpoint::SignalIn, "SignalIn"),
720            (RecipeEndpoint::SignalOut, "SignalOut"),
721            (RecipeEndpoint::EmitCommand, "EmitCommand"),
722            (RecipeEndpoint::Knot, "knot"),
723            (RecipeEndpoint::Port, "port"),
724        ];
725        assert!(endpoints
726            .iter()
727            .all(|(endpoint, label)| endpoint.to_string() == *label));
728
729        let resolve_errors = [
730            RecipeResolveError::Missing {
731                endpoint: RecipeEndpoint::SignalOut,
732                name: String::from("door.open"),
733            },
734            RecipeResolveError::Invalid {
735                endpoint: RecipeEndpoint::Port,
736                name: String::from("door.in"),
737                reason: "the port has the wrong domain",
738            },
739        ];
740        assert_eq!(
741            resolve_errors
742                .iter()
743                .map(ToString::to_string)
744                .collect::<Vec<_>>(),
745            [
746                "required SignalOut endpoint 'door.open' is missing",
747                "required port endpoint 'door.in' is invalid: the port has the wrong domain",
748            ]
749        );
750        assert!(resolve_errors
751            .iter()
752            .all(|error| Error::source(error).is_none()));
753
754        let recipe_errors = [
755            RecipeError::from(BuildError::ForeignHandle),
756            RecipeError::from(bind_errors[0].clone()),
757            RecipeError::from(resolve_errors[0].clone()),
758        ];
759        assert_eq!(
760            recipe_errors
761                .iter()
762                .map(ToString::to_string)
763                .collect::<Vec<_>>(),
764            [
765                "cannot build recipe: handle belongs to a different weave builder",
766                "cannot bind recipe: cannot bind weave 'invalid': weave 'empty' has no knots",
767                "cannot resolve recipe ports: required SignalOut endpoint 'door.open' is missing",
768            ]
769        );
770        assert!(recipe_errors
771            .iter()
772            .all(|error| Error::source(error).is_some()));
773
774        let scenario_errors = [
775            ScenarioError::from(recipe_errors[0].clone()),
776            ScenarioError::from(HandleError::ForeignRuntime { handle: "sense" }),
777            ScenarioError::MissingSignal {
778                path: String::from("door.open"),
779                tick: 4,
780            },
781            ScenarioError::UnexpectedSignal {
782                path: String::from("door.open"),
783                expected: crate::ONE,
784                actual: crate::ZERO,
785                tick: 5,
786            },
787            ScenarioError::ExpectedTruthy {
788                path: String::from("door.open"),
789                actual: crate::ZERO,
790                tick: 6,
791            },
792            ScenarioError::UnexpectedEmits {
793                command: String::from("chime"),
794                expected: 2,
795                actual: 1,
796                tick: 7,
797            },
798        ];
799        let diagnostics: Vec<String> = scenario_errors.iter().map(ToString::to_string).collect();
800        assert!(diagnostics[0].starts_with("cannot start scenario: cannot build recipe"));
801        assert!(diagnostics[1].starts_with("scenario handle error: sense"));
802        assert!(diagnostics[2].contains("no sample in scenario frame 4"));
803        assert!(diagnostics[3].contains("was") && diagnostics[3].contains("expected"));
804        assert!(diagnostics[4].contains("was falsey"));
805        assert!(diagnostics[5].contains("fired 1 times, expected 2"));
806        assert!(Error::source(&scenario_errors[0]).is_some());
807        assert!(Error::source(&scenario_errors[1]).is_some());
808        assert!(scenario_errors[2..]
809            .iter()
810            .all(|error| Error::source(error).is_none()));
811    }
812}