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}
108
109impl fmt::Display for SimError {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        match self {
112            Self::InvalidConfig { field, reason } => {
113                write!(f, "invalid config '{field}': {reason}")
114            }
115            Self::EntityNotFound(id) => write!(f, "entity not found: {id:?}"),
116            Self::StopNotFound(id) => write!(f, "stop not found: {id}"),
117            Self::GroupNotFound(id) => write!(f, "group not found: {id}"),
118            Self::RouteOriginMismatch {
119                expected_origin,
120                route_origin,
121            } => {
122                write!(
123                    f,
124                    "route origin {route_origin:?} does not match expected origin {expected_origin:?}"
125                )
126            }
127            Self::LineDoesNotServeStop { line_or_car, stop } => {
128                write!(f, "line/car {line_or_car:?} does not serve stop {stop:?}")
129            }
130            Self::HallCallNotFound { stop, direction } => {
131                write!(f, "no hall call at stop {stop:?} direction {direction:?}")
132            }
133            Self::WrongRiderPhase {
134                rider,
135                expected,
136                actual,
137            } => {
138                write!(
139                    f,
140                    "rider {rider:?} is in {actual} phase, expected {expected}"
141                )
142            }
143            Self::RiderHasNoStop(id) => write!(f, "rider {id:?} has no current stop"),
144            Self::EmptyRoute => write!(f, "route has no legs"),
145            Self::NotAnElevator(id) => write!(f, "entity {id:?} is not an elevator"),
146            Self::ElevatorDisabled(id) => write!(f, "elevator {id:?} is disabled"),
147            Self::WrongServiceMode {
148                entity,
149                expected,
150                actual,
151            } => {
152                write!(
153                    f,
154                    "elevator {entity:?} is in {actual} mode, expected {expected}"
155                )
156            }
157            Self::NotAStop(id) => write!(f, "entity {id:?} is not a stop"),
158            Self::LineNotFound(id) => write!(f, "line entity {id:?} not found"),
159            Self::NoRoute {
160                origin,
161                destination,
162                origin_groups,
163                destination_groups,
164            } => {
165                write!(
166                    f,
167                    "no route from {origin:?} to {destination:?} (origin served by {}, destination served by {})",
168                    format_group_list(origin_groups),
169                    format_group_list(destination_groups),
170                )
171            }
172            Self::AmbiguousRoute {
173                origin,
174                destination,
175                groups,
176            } => {
177                write!(
178                    f,
179                    "ambiguous route from {origin:?} to {destination:?}: served by groups {}",
180                    format_group_list(groups),
181                )
182            }
183            Self::SnapshotVersion { saved, current } => {
184                write!(
185                    f,
186                    "snapshot was saved on elevator-core {saved}, but current version is {current}",
187                )
188            }
189            Self::SnapshotFormat(reason) => write!(f, "malformed snapshot: {reason}"),
190        }
191    }
192}
193
194impl std::error::Error for SimError {
195    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
196        None
197    }
198}
199
200/// Failure modes for [`Simulation::eta`](crate::sim::Simulation::eta) queries.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202#[non_exhaustive]
203pub enum EtaError {
204    /// The entity is not an elevator.
205    NotAnElevator(EntityId),
206    /// The entity is not a stop.
207    NotAStop(EntityId),
208    /// The stop is not in the elevator's destination queue.
209    StopNotQueued {
210        /// The queried elevator.
211        elevator: EntityId,
212        /// The queried stop.
213        stop: EntityId,
214    },
215    /// The elevator's service mode excludes it from dispatch-based queries.
216    ServiceModeExcluded(EntityId),
217    /// A stop in the route vanished during calculation.
218    StopVanished(EntityId),
219    /// No car has been assigned to serve the hall call at this stop.
220    NoCarAssigned(EntityId),
221}
222
223impl fmt::Display for EtaError {
224    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225        match self {
226            Self::NotAnElevator(id) => write!(f, "entity {id:?} is not an elevator"),
227            Self::NotAStop(id) => write!(f, "entity {id:?} is not a stop"),
228            Self::StopNotQueued { elevator, stop } => {
229                write!(f, "stop {stop:?} is not in elevator {elevator:?}'s queue")
230            }
231            Self::ServiceModeExcluded(id) => {
232                write!(f, "elevator {id:?} is in a dispatch-excluded service mode")
233            }
234            Self::StopVanished(id) => write!(f, "stop {id:?} vanished during ETA calculation"),
235            Self::NoCarAssigned(id) => {
236                write!(f, "no car assigned to serve hall call at stop {id:?}")
237            }
238        }
239    }
240}
241
242impl std::error::Error for EtaError {
243    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
244        None
245    }
246}
247
248/// Format a list of `GroupId`s as `[GroupId(0), GroupId(1)]` or `[]` if empty.
249fn format_group_list(groups: &[GroupId]) -> String {
250    if groups.is_empty() {
251        return "[]".to_string();
252    }
253    let parts: Vec<String> = groups.iter().map(GroupId::to_string).collect();
254    format!("[{}]", parts.join(", "))
255}
256
257/// Reason a rider was rejected from boarding an elevator.
258#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
259#[non_exhaustive]
260pub enum RejectionReason {
261    /// Rider's weight exceeds remaining elevator capacity.
262    OverCapacity,
263    /// Rider's boarding preferences prevented boarding (e.g., crowding threshold).
264    PreferenceBased,
265    /// Rider lacks access to the destination stop, or the elevator cannot serve it.
266    AccessDenied,
267}
268
269impl fmt::Display for RejectionReason {
270    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271        match self {
272            Self::OverCapacity => write!(f, "over capacity"),
273            Self::PreferenceBased => write!(f, "rider preference"),
274            Self::AccessDenied => write!(f, "access denied"),
275        }
276    }
277}
278
279/// Additional context for a rider rejection.
280///
281/// Provides the numeric details that led to the rejection decision.
282/// Separated from [`RejectionReason`] to preserve `Eq` on the reason enum.
283#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
284pub struct RejectionContext {
285    /// Weight the rider attempted to add.
286    pub attempted_weight: OrderedFloat<f64>,
287    /// Current load on the elevator at rejection time.
288    pub current_load: OrderedFloat<f64>,
289    /// Maximum weight capacity of the elevator.
290    pub capacity: OrderedFloat<f64>,
291}
292
293impl fmt::Display for RejectionContext {
294    /// Compact summary for game feedback.
295    ///
296    /// ```
297    /// # use elevator_core::error::RejectionContext;
298    /// # use ordered_float::OrderedFloat;
299    /// let ctx = RejectionContext {
300    ///     attempted_weight: OrderedFloat(80.0),
301    ///     current_load: OrderedFloat(750.0),
302    ///     capacity: OrderedFloat(800.0),
303    /// };
304    /// assert_eq!(format!("{ctx}"), "over capacity by 30.0kg (750.0/800.0 + 80.0)");
305    /// ```
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        let excess = (*self.current_load + *self.attempted_weight) - *self.capacity;
308        if excess > 0.0 {
309            write!(
310                f,
311                "over capacity by {excess:.1}kg ({:.1}/{:.1} + {:.1})",
312                *self.current_load, *self.capacity, *self.attempted_weight,
313            )
314        } else {
315            write!(
316                f,
317                "load {:.1}kg/{:.1}kg + {:.1}kg",
318                *self.current_load, *self.capacity, *self.attempted_weight,
319            )
320        }
321    }
322}
323
324impl From<EntityId> for SimError {
325    fn from(id: EntityId) -> Self {
326        Self::EntityNotFound(id)
327    }
328}
329
330impl From<StopId> for SimError {
331    fn from(id: StopId) -> Self {
332        Self::StopNotFound(id)
333    }
334}
335
336impl From<GroupId> for SimError {
337    fn from(id: GroupId) -> Self {
338        Self::GroupNotFound(id)
339    }
340}