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