Skip to main content

elevator_core/
error.rs

1//! Error types for configuration validation and runtime failures.
2
3use crate::components::{CallDirection, RiderPhaseKind, ServiceMode};
4use crate::entity::EntityId;
5use crate::ids::GroupId;
6use crate::stop::StopId;
7use ordered_float::OrderedFloat;
8use std::fmt;
9
10/// Errors that can occur during simulation setup or operation.
11#[derive(Debug, Clone, PartialEq, Eq)]
12#[non_exhaustive]
13pub enum SimError {
14    /// Configuration is invalid.
15    InvalidConfig {
16        /// Which config field is problematic.
17        field: &'static str,
18        /// Human-readable explanation.
19        reason: String,
20    },
21    /// A referenced entity does not exist.
22    EntityNotFound(EntityId),
23    /// A referenced stop ID does not exist in the config.
24    StopNotFound(StopId),
25    /// A referenced group does not exist.
26    GroupNotFound(GroupId),
27    /// The route's origin does not match the expected origin.
28    RouteOriginMismatch {
29        /// The expected origin entity.
30        expected_origin: EntityId,
31        /// The origin recorded in the route.
32        route_origin: EntityId,
33    },
34    /// An elevator's line does not serve the target stop.
35    LineDoesNotServeStop {
36        /// The elevator (or line) entity.
37        line_or_car: EntityId,
38        /// The stop that is not served.
39        stop: EntityId,
40    },
41    /// No hall call exists at the given stop and direction.
42    HallCallNotFound {
43        /// The stop entity.
44        stop: EntityId,
45        /// The call direction.
46        direction: CallDirection,
47    },
48    /// A rider is in the wrong lifecycle phase for the attempted operation.
49    WrongRiderPhase {
50        /// The rider entity.
51        rider: EntityId,
52        /// The phase required by the operation.
53        expected: RiderPhaseKind,
54        /// The rider's current phase.
55        actual: RiderPhaseKind,
56    },
57    /// An attempted lifecycle transition is not allowed by the legality matrix.
58    ///
59    /// Emitted by the transition gateway when the requested move from
60    /// `from` to `to` would skip a required intermediate state — e.g.
61    /// `Resident` → `Riding` (must go via `Waiting`/`Boarding` first).
62    IllegalTransition {
63        /// The rider whose transition was rejected.
64        rider: EntityId,
65        /// The rider's current phase.
66        from: RiderPhaseKind,
67        /// The phase the caller attempted to move into.
68        to: RiderPhaseKind,
69    },
70    /// A rider has no current stop when one is required.
71    RiderHasNoStop(EntityId),
72    /// A route has no legs.
73    EmptyRoute,
74    /// The entity is not an elevator.
75    NotAnElevator(EntityId),
76    /// The elevator is disabled.
77    ElevatorDisabled(EntityId),
78    /// The elevator is in an incompatible service mode.
79    WrongServiceMode {
80        /// The elevator entity.
81        entity: EntityId,
82        /// The service mode required by the operation.
83        expected: ServiceMode,
84        /// The elevator's current service mode.
85        actual: ServiceMode,
86    },
87    /// The entity is not a stop.
88    NotAStop(EntityId),
89    /// A line entity was not found.
90    LineNotFound(EntityId),
91    /// No route exists between origin and destination across any group.
92    NoRoute {
93        /// The origin stop.
94        origin: EntityId,
95        /// The destination stop.
96        destination: EntityId,
97        /// Groups that serve the origin (if any).
98        origin_groups: Vec<GroupId>,
99        /// Groups that serve the destination (if any).
100        destination_groups: Vec<GroupId>,
101    },
102    /// Multiple groups serve both origin and destination — caller must specify.
103    AmbiguousRoute {
104        /// The origin stop.
105        origin: EntityId,
106        /// The destination stop.
107        destination: EntityId,
108        /// The groups that serve both stops.
109        groups: Vec<GroupId>,
110    },
111    /// Snapshot bytes were produced by a different crate version.
112    SnapshotVersion {
113        /// Crate version recorded in the snapshot header.
114        saved: String,
115        /// Current crate version attempting the restore.
116        current: String,
117    },
118    /// Snapshot bytes are malformed or not a snapshot at all.
119    SnapshotFormat(String),
120    /// A custom dispatch strategy in a snapshot could not be resolved.
121    UnresolvedCustomStrategy {
122        /// The strategy name stored in the snapshot.
123        name: String,
124        /// The group that references the strategy.
125        group: GroupId,
126    },
127    /// `try_snapshot` was called between phases of an in-progress tick.
128    /// Mid-tick snapshots silently lose `EventBus` state from earlier
129    /// phases of that tick — surface the constraint instead. (#297)
130    MidTickSnapshot,
131}
132
133impl fmt::Display for SimError {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        match self {
136            Self::InvalidConfig { field, reason } => {
137                write!(f, "invalid config '{field}': {reason}")
138            }
139            Self::EntityNotFound(id) => write!(f, "entity not found: {id:?}"),
140            Self::StopNotFound(id) => write!(f, "stop not found: {id}"),
141            Self::GroupNotFound(id) => write!(f, "group not found: {id}"),
142            Self::RouteOriginMismatch {
143                expected_origin,
144                route_origin,
145            } => {
146                write!(
147                    f,
148                    "route origin {route_origin:?} does not match expected origin {expected_origin:?}"
149                )
150            }
151            Self::LineDoesNotServeStop { line_or_car, stop } => {
152                write!(f, "line/car {line_or_car:?} does not serve stop {stop:?}")
153            }
154            Self::HallCallNotFound { stop, direction } => {
155                write!(f, "no hall call at stop {stop:?} direction {direction:?}")
156            }
157            Self::WrongRiderPhase {
158                rider,
159                expected,
160                actual,
161            } => {
162                write!(
163                    f,
164                    "rider {rider:?} is in {actual} phase, expected {expected}"
165                )
166            }
167            Self::IllegalTransition { rider, from, to } => {
168                write!(
169                    f,
170                    "rider {rider:?} cannot transition {from} -> {to}: not in legality matrix"
171                )
172            }
173            Self::RiderHasNoStop(id) => write!(f, "rider {id:?} has no current stop"),
174            Self::EmptyRoute => write!(f, "route has no legs"),
175            Self::NotAnElevator(id) => write!(f, "entity {id:?} is not an elevator"),
176            Self::ElevatorDisabled(id) => write!(f, "elevator {id:?} is disabled"),
177            Self::WrongServiceMode {
178                entity,
179                expected,
180                actual,
181            } => {
182                write!(
183                    f,
184                    "elevator {entity:?} is in {actual} mode, expected {expected}"
185                )
186            }
187            Self::NotAStop(id) => write!(f, "entity {id:?} is not a stop"),
188            Self::LineNotFound(id) => write!(f, "line entity {id:?} not found"),
189            Self::NoRoute {
190                origin,
191                destination,
192                origin_groups,
193                destination_groups,
194            } => {
195                write!(
196                    f,
197                    "no route from {origin:?} to {destination:?} (origin served by {}, destination served by {})",
198                    format_group_list(origin_groups),
199                    format_group_list(destination_groups),
200                )
201            }
202            Self::AmbiguousRoute {
203                origin,
204                destination,
205                groups,
206            } => {
207                write!(
208                    f,
209                    "ambiguous route from {origin:?} to {destination:?}: served by groups {}",
210                    format_group_list(groups),
211                )
212            }
213            Self::SnapshotVersion { saved, current } => {
214                write!(
215                    f,
216                    "snapshot was saved on elevator-core {saved}, but current version is {current}",
217                )
218            }
219            Self::SnapshotFormat(reason) => write!(f, "malformed snapshot: {reason}"),
220            Self::MidTickSnapshot => write!(
221                f,
222                "snapshot taken between phases of an in-progress tick; \
223                 call advance_tick() before snapshot() in the substep API"
224            ),
225            Self::UnresolvedCustomStrategy { name, group } => {
226                write!(
227                    f,
228                    "custom dispatch strategy {name:?} for group {group} could not be resolved — \
229                     provide a factory that handles this name",
230                )
231            }
232        }
233    }
234}
235
236impl std::error::Error for SimError {
237    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
238        None
239    }
240}
241
242/// Failure modes for [`Simulation::eta`](crate::sim::Simulation::eta) queries.
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244#[non_exhaustive]
245pub enum EtaError {
246    /// The entity is not an elevator.
247    NotAnElevator(EntityId),
248    /// The entity is not a stop.
249    NotAStop(EntityId),
250    /// The stop is not in the elevator's destination queue.
251    StopNotQueued {
252        /// The queried elevator.
253        elevator: EntityId,
254        /// The queried stop.
255        stop: EntityId,
256    },
257    /// The elevator's service mode excludes it from dispatch-based queries.
258    ServiceModeExcluded(EntityId),
259    /// A stop in the route vanished during calculation.
260    StopVanished(EntityId),
261    /// No car has been assigned to serve the hall call at this stop.
262    NoCarAssigned(EntityId),
263}
264
265impl fmt::Display for EtaError {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        match self {
268            Self::NotAnElevator(id) => write!(f, "entity {id:?} is not an elevator"),
269            Self::NotAStop(id) => write!(f, "entity {id:?} is not a stop"),
270            Self::StopNotQueued { elevator, stop } => {
271                write!(f, "stop {stop:?} is not in elevator {elevator:?}'s queue")
272            }
273            Self::ServiceModeExcluded(id) => {
274                write!(f, "elevator {id:?} is in a dispatch-excluded service mode")
275            }
276            Self::StopVanished(id) => write!(f, "stop {id:?} vanished during ETA calculation"),
277            Self::NoCarAssigned(id) => {
278                write!(f, "no car assigned to serve hall call at stop {id:?}")
279            }
280        }
281    }
282}
283
284impl std::error::Error for EtaError {
285    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
286        None
287    }
288}
289
290/// Format a list of `GroupId`s as `[GroupId(0), GroupId(1)]` or `[]` if empty.
291fn format_group_list(groups: &[GroupId]) -> String {
292    if groups.is_empty() {
293        return "[]".to_string();
294    }
295    let parts: Vec<String> = groups.iter().map(GroupId::to_string).collect();
296    format!("[{}]", parts.join(", "))
297}
298
299/// Reason a rider was rejected from boarding an elevator.
300#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
301#[non_exhaustive]
302pub enum RejectionReason {
303    /// Rider's weight exceeds remaining elevator capacity.
304    OverCapacity,
305    /// Rider's boarding preferences prevented boarding (e.g., crowding threshold).
306    PreferenceBased,
307    /// Rider lacks access to the destination stop, or the elevator cannot serve it.
308    AccessDenied,
309}
310
311impl fmt::Display for RejectionReason {
312    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
313        match self {
314            Self::OverCapacity => write!(f, "over capacity"),
315            Self::PreferenceBased => write!(f, "rider preference"),
316            Self::AccessDenied => write!(f, "access denied"),
317        }
318    }
319}
320
321/// Additional context for a rider rejection.
322///
323/// Provides the numeric details that led to the rejection decision.
324/// Separated from [`RejectionReason`] to preserve `Eq` on the reason enum.
325#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
326pub struct RejectionContext {
327    /// Weight the rider attempted to add.
328    pub attempted_weight: OrderedFloat<f64>,
329    /// Current load on the elevator at rejection time.
330    pub current_load: OrderedFloat<f64>,
331    /// Maximum weight capacity of the elevator.
332    pub capacity: OrderedFloat<f64>,
333}
334
335impl fmt::Display for RejectionContext {
336    /// Compact summary for game feedback.
337    ///
338    /// ```
339    /// # use elevator_core::error::RejectionContext;
340    /// # use ordered_float::OrderedFloat;
341    /// let ctx = RejectionContext {
342    ///     attempted_weight: OrderedFloat(80.0),
343    ///     current_load: OrderedFloat(750.0),
344    ///     capacity: OrderedFloat(800.0),
345    /// };
346    /// assert_eq!(format!("{ctx}"), "over capacity by 30.0kg (750.0/800.0 + 80.0)");
347    /// ```
348    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
349        let excess = (*self.current_load + *self.attempted_weight) - *self.capacity;
350        if excess > 0.0 {
351            write!(
352                f,
353                "over capacity by {excess:.1}kg ({:.1}/{:.1} + {:.1})",
354                *self.current_load, *self.capacity, *self.attempted_weight,
355            )
356        } else {
357            write!(
358                f,
359                "load {:.1}kg/{:.1}kg + {:.1}kg",
360                *self.current_load, *self.capacity, *self.attempted_weight,
361            )
362        }
363    }
364}
365
366impl From<EntityId> for SimError {
367    fn from(id: EntityId) -> Self {
368        Self::EntityNotFound(id)
369    }
370}
371
372impl From<StopId> for SimError {
373    fn from(id: StopId) -> Self {
374        Self::StopNotFound(id)
375    }
376}
377
378impl From<GroupId> for SimError {
379    fn from(id: GroupId) -> Self {
380        Self::GroupNotFound(id)
381    }
382}