Skip to main content

elevator_core/
error.rs

1//! Error types for configuration validation and runtime failures.
2
3use crate::entity::EntityId;
4use crate::ids::GroupId;
5use crate::stop::StopId;
6use ordered_float::OrderedFloat;
7use std::fmt;
8
9/// Errors that can occur during simulation setup or operation.
10#[derive(Debug, Clone, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum SimError {
13    /// Configuration is invalid.
14    InvalidConfig {
15        /// Which config field is problematic.
16        field: &'static str,
17        /// Human-readable explanation.
18        reason: String,
19    },
20    /// A referenced entity does not exist.
21    EntityNotFound(EntityId),
22    /// A referenced stop ID does not exist in the config.
23    StopNotFound(StopId),
24    /// A referenced group does not exist.
25    GroupNotFound(GroupId),
26    /// An operation was attempted on an entity in an invalid state.
27    InvalidState {
28        /// The entity in the wrong state.
29        entity: EntityId,
30        /// Human-readable explanation.
31        reason: String,
32    },
33    /// A line entity was not found.
34    LineNotFound(EntityId),
35    /// No route exists between origin and destination across any group.
36    NoRoute {
37        /// The origin stop.
38        origin: EntityId,
39        /// The destination stop.
40        destination: EntityId,
41        /// Groups that serve the origin (if any).
42        origin_groups: Vec<GroupId>,
43        /// Groups that serve the destination (if any).
44        destination_groups: Vec<GroupId>,
45    },
46    /// Multiple groups serve both origin and destination — caller must specify.
47    AmbiguousRoute {
48        /// The origin stop.
49        origin: EntityId,
50        /// The destination stop.
51        destination: EntityId,
52        /// The groups that serve both stops.
53        groups: Vec<GroupId>,
54    },
55    /// Snapshot bytes were produced by a different crate version.
56    SnapshotVersion {
57        /// Crate version recorded in the snapshot header.
58        saved: String,
59        /// Current crate version attempting the restore.
60        current: String,
61    },
62    /// Snapshot bytes are malformed or not a snapshot at all.
63    SnapshotFormat(String),
64}
65
66impl fmt::Display for SimError {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            Self::InvalidConfig { field, reason } => {
70                write!(f, "invalid config '{field}': {reason}")
71            }
72            Self::EntityNotFound(id) => write!(f, "entity not found: {id:?}"),
73            Self::StopNotFound(id) => write!(f, "stop not found: {id}"),
74            Self::GroupNotFound(id) => write!(f, "group not found: {id}"),
75            Self::InvalidState { entity, reason } => {
76                write!(f, "invalid state for {entity:?}: {reason}")
77            }
78            Self::LineNotFound(id) => write!(f, "line entity {id:?} not found"),
79            Self::NoRoute {
80                origin,
81                destination,
82                origin_groups,
83                destination_groups,
84            } => {
85                write!(
86                    f,
87                    "no route from {origin:?} to {destination:?} (origin served by {}, destination served by {})",
88                    format_group_list(origin_groups),
89                    format_group_list(destination_groups),
90                )
91            }
92            Self::AmbiguousRoute {
93                origin,
94                destination,
95                groups,
96            } => {
97                write!(
98                    f,
99                    "ambiguous route from {origin:?} to {destination:?}: served by groups {}",
100                    format_group_list(groups),
101                )
102            }
103            Self::SnapshotVersion { saved, current } => {
104                write!(
105                    f,
106                    "snapshot was saved on elevator-core {saved}, but current version is {current}",
107                )
108            }
109            Self::SnapshotFormat(reason) => write!(f, "malformed snapshot: {reason}"),
110        }
111    }
112}
113
114impl std::error::Error for SimError {
115    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
116        None
117    }
118}
119
120/// Format a list of `GroupId`s as `[GroupId(0), GroupId(1)]` or `[]` if empty.
121fn format_group_list(groups: &[GroupId]) -> String {
122    if groups.is_empty() {
123        return "[]".to_string();
124    }
125    let parts: Vec<String> = groups.iter().map(GroupId::to_string).collect();
126    format!("[{}]", parts.join(", "))
127}
128
129/// Reason a rider was rejected from boarding an elevator.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
131#[non_exhaustive]
132pub enum RejectionReason {
133    /// Rider's weight exceeds remaining elevator capacity.
134    OverCapacity,
135    /// Rider's boarding preferences prevented boarding (e.g., crowding threshold).
136    PreferenceBased,
137    /// Rider lacks access to the destination stop, or the elevator cannot serve it.
138    AccessDenied,
139}
140
141impl fmt::Display for RejectionReason {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        match self {
144            Self::OverCapacity => write!(f, "over capacity"),
145            Self::PreferenceBased => write!(f, "rider preference"),
146            Self::AccessDenied => write!(f, "access denied"),
147        }
148    }
149}
150
151/// Additional context for a rider rejection.
152///
153/// Provides the numeric details that led to the rejection decision.
154/// Separated from [`RejectionReason`] to preserve `Eq` on the reason enum.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
156pub struct RejectionContext {
157    /// Weight the rider attempted to add.
158    pub attempted_weight: OrderedFloat<f64>,
159    /// Current load on the elevator at rejection time.
160    pub current_load: OrderedFloat<f64>,
161    /// Maximum weight capacity of the elevator.
162    pub capacity: OrderedFloat<f64>,
163}
164
165impl fmt::Display for RejectionContext {
166    /// Compact summary for game feedback.
167    ///
168    /// ```
169    /// # use elevator_core::error::RejectionContext;
170    /// # use ordered_float::OrderedFloat;
171    /// let ctx = RejectionContext {
172    ///     attempted_weight: OrderedFloat(80.0),
173    ///     current_load: OrderedFloat(750.0),
174    ///     capacity: OrderedFloat(800.0),
175    /// };
176    /// assert_eq!(format!("{ctx}"), "over capacity by 30.0kg (750.0/800.0 + 80.0)");
177    /// ```
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        let excess = (*self.current_load + *self.attempted_weight) - *self.capacity;
180        if excess > 0.0 {
181            write!(
182                f,
183                "over capacity by {excess:.1}kg ({:.1}/{:.1} + {:.1})",
184                *self.current_load, *self.capacity, *self.attempted_weight,
185            )
186        } else {
187            write!(
188                f,
189                "load {:.1}kg/{:.1}kg + {:.1}kg",
190                *self.current_load, *self.capacity, *self.attempted_weight,
191            )
192        }
193    }
194}
195
196impl From<EntityId> for SimError {
197    fn from(id: EntityId) -> Self {
198        Self::EntityNotFound(id)
199    }
200}
201
202impl From<StopId> for SimError {
203    fn from(id: StopId) -> Self {
204        Self::StopNotFound(id)
205    }
206}
207
208impl From<GroupId> for SimError {
209    fn from(id: GroupId) -> Self {
210        Self::GroupNotFound(id)
211    }
212}