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}