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}
56
57impl fmt::Display for SimError {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match self {
60            Self::InvalidConfig { field, reason } => {
61                write!(f, "invalid config '{field}': {reason}")
62            }
63            Self::EntityNotFound(id) => write!(f, "entity not found: {id:?}"),
64            Self::StopNotFound(id) => write!(f, "stop not found: {id}"),
65            Self::GroupNotFound(id) => write!(f, "group not found: {id}"),
66            Self::InvalidState { entity, reason } => {
67                write!(f, "invalid state for {entity:?}: {reason}")
68            }
69            Self::LineNotFound(id) => write!(f, "line entity {id:?} not found"),
70            Self::NoRoute {
71                origin,
72                destination,
73                origin_groups,
74                destination_groups,
75            } => {
76                write!(
77                    f,
78                    "no route from {origin:?} to {destination:?} (origin served by {}, destination served by {})",
79                    format_group_list(origin_groups),
80                    format_group_list(destination_groups),
81                )
82            }
83            Self::AmbiguousRoute {
84                origin,
85                destination,
86                groups,
87            } => {
88                write!(
89                    f,
90                    "ambiguous route from {origin:?} to {destination:?}: served by groups {}",
91                    format_group_list(groups),
92                )
93            }
94        }
95    }
96}
97
98impl std::error::Error for SimError {
99    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
100        None
101    }
102}
103
104/// Format a list of `GroupId`s as `[GroupId(0), GroupId(1)]` or `[]` if empty.
105fn format_group_list(groups: &[GroupId]) -> String {
106    if groups.is_empty() {
107        return "[]".to_string();
108    }
109    let parts: Vec<String> = groups.iter().map(GroupId::to_string).collect();
110    format!("[{}]", parts.join(", "))
111}
112
113/// Reason a rider was rejected from boarding an elevator.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
115#[non_exhaustive]
116pub enum RejectionReason {
117    /// Rider's weight exceeds remaining elevator capacity.
118    OverCapacity,
119    /// Rider's boarding preferences prevented boarding (e.g., crowding threshold).
120    PreferenceBased,
121    /// Rider lacks access to the destination stop, or the elevator cannot serve it.
122    AccessDenied,
123}
124
125impl fmt::Display for RejectionReason {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        match self {
128            Self::OverCapacity => write!(f, "over capacity"),
129            Self::PreferenceBased => write!(f, "rider preference"),
130            Self::AccessDenied => write!(f, "access denied"),
131        }
132    }
133}
134
135/// Additional context for a rider rejection.
136///
137/// Provides the numeric details that led to the rejection decision.
138/// Separated from [`RejectionReason`] to preserve `Eq` on the reason enum.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
140pub struct RejectionContext {
141    /// Weight the rider attempted to add.
142    pub attempted_weight: OrderedFloat<f64>,
143    /// Current load on the elevator at rejection time.
144    pub current_load: OrderedFloat<f64>,
145    /// Maximum weight capacity of the elevator.
146    pub capacity: OrderedFloat<f64>,
147}
148
149impl fmt::Display for RejectionContext {
150    /// Compact summary for game feedback.
151    ///
152    /// ```
153    /// # use elevator_core::error::RejectionContext;
154    /// # use ordered_float::OrderedFloat;
155    /// let ctx = RejectionContext {
156    ///     attempted_weight: OrderedFloat(80.0),
157    ///     current_load: OrderedFloat(750.0),
158    ///     capacity: OrderedFloat(800.0),
159    /// };
160    /// assert_eq!(format!("{ctx}"), "over capacity by 30.0kg (750.0/800.0 + 80.0)");
161    /// ```
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        let excess = (*self.current_load + *self.attempted_weight) - *self.capacity;
164        if excess > 0.0 {
165            write!(
166                f,
167                "over capacity by {excess:.1}kg ({:.1}/{:.1} + {:.1})",
168                *self.current_load, *self.capacity, *self.attempted_weight,
169            )
170        } else {
171            write!(
172                f,
173                "load {:.1}kg/{:.1}kg + {:.1}kg",
174                *self.current_load, *self.capacity, *self.attempted_weight,
175            )
176        }
177    }
178}
179
180impl From<EntityId> for SimError {
181    fn from(id: EntityId) -> Self {
182        Self::EntityNotFound(id)
183    }
184}
185
186impl From<StopId> for SimError {
187    fn from(id: StopId) -> Self {
188        Self::StopNotFound(id)
189    }
190}
191
192impl From<GroupId> for SimError {
193    fn from(id: GroupId) -> Self {
194        Self::GroupNotFound(id)
195    }
196}