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