Skip to main content

scientific_workflow/system_state/
error.rs

1//! Errors produced while defining and manipulating system states.
2//!
3//! This module keeps the SystemState error surface in one place so template
4//! loading, layout validation, time advancement, and typed payload access
5//! report failures consistently. The variants are deliberately specific
6//! enough for callers to inspect programmatically while their display messages
7//! retain the field, path, or time context needed for logs.
8//!
9//! # Error sources
10//!
11//! Filesystem and JSON failures preserve their original errors through
12//! [`std::error::Error::source`]. Semantic template failures and state-access
13//! failures do not wrap another error because they are detected directly by
14//! this crate. Checked time-advance failures likewise retain their complete
15//! numeric context directly in [`StateError`].
16//!
17//! # Performance
18//!
19//! These errors are constructed only on failure paths. Owned paths and field
20//! names are retained to make an error independent of the state or template
21//! that produced it; successful state access does not allocate error context.
22//!
23//! # Ownership-preserving insertion failures
24//!
25//! [`PayloadInsertError`] is generic because it returns ownership of a payload that
26//! [`SystemState::insert_payload`](super::state::SystemState::insert_payload) could not accept. Its
27//! diagnostics deliberately omit the payload value, so scientific data does
28//! not need to implement [`Debug`](std::fmt::Debug) and is never traversed or
29//! copied merely to format an error.
30
31use std::error::Error;
32use std::fmt;
33use std::io;
34use std::path::PathBuf;
35
36use thiserror::Error;
37
38/// A failure encountered while defining, accessing, or advancing a state.
39///
40/// `StateError` is non-exhaustive because later workflow features may add
41/// validation failures without forcing downstream crates to update exhaustive
42/// matches. Callers should match variants of interest and retain a fallback
43/// arm.
44#[derive(Debug, Error)]
45#[non_exhaustive]
46pub enum StateError {
47    /// A JSON state template could not be read from the filesystem.
48    #[error("failed to read state template `{path}`")]
49    TemplateRead {
50        /// Path passed to the template loader.
51        path: PathBuf,
52        /// Underlying filesystem error.
53        #[source]
54        source: io::Error,
55    },
56
57    /// A state template was readable but did not contain valid JSON.
58    #[error("failed to parse state template `{path}` as JSON")]
59    TemplateParse {
60        /// Path of the malformed template.
61        path: PathBuf,
62        /// Underlying JSON syntax or data-model error.
63        #[source]
64        source: serde_json::Error,
65    },
66
67    /// A field definition used an empty or whitespace-only name.
68    #[error("state template field at index {index} has an empty name")]
69    EmptyFieldName {
70        /// Zero-based position of the invalid field in template order.
71        index: usize,
72    },
73
74    /// Two field definitions used the same name.
75    #[error("state template declares duplicate field `{field}`")]
76    DuplicateField {
77        /// Repeated field name.
78        field: String,
79    },
80
81    /// An operation addressed a key that is absent from the template layout.
82    #[error("state template does not declare field `{field}`")]
83    UnknownField {
84        /// Requested field name.
85        field: String,
86    },
87
88    /// One coordinated borrow requested the same resolved field more than once.
89    ///
90    /// Mutable aliases to one payload would violate Rust's exclusivity rules.
91    /// Immutable coordinated borrows reject the same input as well so
92    /// `SystemState::borrow_payloads` and `SystemState::borrow_payloads_mut` retain identical,
93    /// predictable request validation.
94    #[error("coordinated state borrow repeats field `{field}`")]
95    RepeatedPayloadBorrow {
96        /// Field name at the first repeated tuple position.
97        field: String,
98    },
99
100    /// An operation required a payload from a declared but currently empty
101    /// field.
102    #[error("state field `{field}` does not contain a payload")]
103    MissingPayload {
104        /// Declared field whose slot is empty.
105        field: String,
106    },
107
108    /// A typed operation requested a different Rust type from the retained
109    /// field contract.
110    #[error(
111        "state field `{field}` is bound to `{actual}`, but the operation requested `{expected}`"
112    )]
113    TypeMismatch {
114        /// Field on which the typed operation was attempted.
115        field: String,
116        /// Rust type requested by the caller.
117        expected: &'static str,
118        /// Rust type bound to the field during state assembly.
119        actual: &'static str,
120    },
121
122    /// Incrementing the authoritative iteration would overflow `u64`.
123    ///
124    /// `SystemState::advance_simulation_time` will detect this condition before mutating the
125    /// state, so the original time point remains unchanged.
126    #[error("cannot advance state iteration {iteration}: the next iteration exceeds u64::MAX")]
127    IterationOverflow {
128        /// Current iteration that cannot be incremented.
129        iteration: u64,
130    },
131
132    /// A physical-time delta was requested for a state without a physical
133    /// coordinate.
134    ///
135    /// Absence is not interpreted as zero: callers must establish a known
136    /// origin explicitly before advancing physical time.
137    #[error(
138        "cannot advance physical time at iteration {iteration}: no physical coordinate is present"
139    )]
140    MissingPhysicalTime {
141        /// Iteration at which physical advancement was requested.
142        iteration: u64,
143    },
144
145    /// A physical-time delta or its sum with the current coordinate is not
146    /// finite.
147    ///
148    /// Both operands are retained for diagnosis. This variant covers a
149    /// non-finite input delta and finite operands whose addition overflows to
150    /// infinity. The state remains unchanged.
151    #[error(
152        "cannot advance physical time {current} by {delta}: the delta and resulting coordinate must be finite"
153    )]
154    InvalidPhysicalAdvance {
155        /// Current finite physical coordinate.
156        current: f64,
157        /// Requested delta, which may itself be non-finite.
158        delta: f64,
159    },
160}
161
162/// A failed [`SystemState::insert_payload`](super::state::SystemState::insert_payload) operation that
163/// retains ownership of the unchanged incoming payload.
164///
165/// A set operation can fail before moving `payload` into a state because the
166/// requested field is undeclared or because its assembly-retained type contract
167/// names a different concrete Rust type. The latter remains true even when the
168/// field is temporarily empty after `take` or `clear`. Returning only
169/// [`StateError`] in those cases would drop the caller's payload while unwinding
170/// the failed call. `PayloadInsertError` instead keeps the rejection reason and original
171/// `T` together, following the ownership-preserving pattern of channel send
172/// errors.
173///
174/// The payload remains private so diagnostics cannot accidentally expose or
175/// traverse large scientific data. Borrow it through [`PayloadInsertError::payload`] or
176/// recover ownership of both components through [`PayloadInsertError::into_parts`].
177/// Neither operation invokes [`Clone`].
178///
179/// # Formatting
180///
181/// [`Display`](fmt::Display) delegates to the contained [`StateError`]. The
182/// bounded [`Debug`](fmt::Debug) representation includes only that error and
183/// the compile-time Rust type name of `T`; it never requires `T: Debug` or
184/// formats the payload value.
185#[must_use = "the rejected payload remains owned by this error until it is recovered or dropped"]
186pub struct PayloadInsertError<T> {
187    error: StateError,
188    payload: T,
189}
190
191impl<T> PayloadInsertError<T> {
192    /// Creates an ownership-preserving set rejection.
193    ///
194    /// This constructor is crate-private because only SystemState validation
195    /// may determine that a payload was rejected. Public callers receive a
196    /// `PayloadInsertError<T>` from [`SystemState::insert_payload`](super::state::SystemState::insert_payload)
197    /// and recover its contents through the accessors below.
198    pub(crate) const fn new(error: StateError, payload: T) -> Self {
199        Self { error, payload }
200    }
201
202    /// Returns the state-validation error that rejected the payload.
203    ///
204    /// Borrowing the reason leaves the incoming payload owned by this error,
205    /// allowing callers to inspect the failure before deciding how to recover
206    /// or dispose of the scientific data.
207    pub const fn error(&self) -> &StateError {
208        &self.error
209    }
210
211    /// Returns the unchanged rejected payload by shared reference.
212    ///
213    /// The returned reference points to the same concrete `T` moved into
214    /// [`SystemState::insert_payload`](super::state::SystemState::insert_payload). No payload clone,
215    /// serialization, downcast, or backing-buffer copy occurs.
216    pub const fn payload(&self) -> &T {
217        &self.payload
218    }
219
220    /// Consumes the rejection and returns its reason and original payload.
221    ///
222    /// The tuple is ordered as `(StateError, T)`, matching the borrowed
223    /// [`PayloadInsertError::error`] then [`PayloadInsertError::payload`] inspection order. The
224    /// payload moves directly out of the error and retains its original owned
225    /// allocations.
226    pub fn into_parts(self) -> (StateError, T) {
227        (self.error, self.payload)
228    }
229}
230
231impl<T> fmt::Debug for PayloadInsertError<T> {
232    /// Formats bounded diagnostic context without requiring or inspecting
233    /// `T: Debug`.
234    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
235        formatter
236            .debug_struct("PayloadInsertError")
237            .field("error", &self.error)
238            .field("payload_type", &std::any::type_name::<T>())
239            .finish_non_exhaustive()
240    }
241}
242
243impl<T> fmt::Display for PayloadInsertError<T> {
244    /// Delegates the user-facing message to the state-validation reason.
245    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
246        fmt::Display::fmt(&self.error, formatter)
247    }
248}
249
250impl<T> Error for PayloadInsertError<T> {
251    /// Exposes the contained [`StateError`] for standard error-chain traversal.
252    fn source(&self) -> Option<&(dyn Error + 'static)> {
253        Some(&self.error)
254    }
255}