Skip to main content

wowlab_engine_application/
error.rs

1use std::fmt;
2
3use wowlab_common::sim::intent::IntentConfigError;
4use wowlab_engine_ports::{EngineError, ResolverError};
5
6use crate::EncounterResolutionError;
7
8/// Application use case in which orchestration failed.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10#[non_exhaustive]
11pub enum ApplicationStage {
12    AssistedRotationResolution,
13    ChunkExecution,
14    ConfigParsing,
15    ConflictCheck,
16    EncounterResolution,
17    GameDataResolution,
18    GearResolution,
19    HandlerConstruction,
20    ParallelSimulation,
21    PaperdollResolution,
22    RotationValidation,
23    RotationResolution,
24    SettingsResolution,
25    Simulation,
26    SpecResolution,
27    SpecIntrospection,
28    TalentResolution,
29    WeaponEnchantResolution,
30}
31
32/// Stable classification of an [`ApplicationError`].
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34#[non_exhaustive]
35pub enum ApplicationErrorCategory {
36    EncounterResolution,
37    Engine,
38    IntentConfig,
39    Resolver,
40}
41
42wowlab_engine_macros::define_error! {
43/// Typed, intentionally opaque failure from an application-layer use case.
44#[non_exhaustive]
45pub struct ApplicationError {
46    stage: ApplicationStage,
47    #[source]
48    kind: ApplicationErrorKind,
49}
50
51#[derive(Debug, thiserror::Error)]
52enum ApplicationErrorKind {
53    #[error("spec construction error: {0}")]
54    EncounterResolution(#[source] EncounterResolutionError),
55    #[error("{0}")]
56    Engine(#[source] EngineError),
57    #[error("intent validation error: failed to parse sim config TOML: {0}")]
58    IntentConfig(#[source] IntentConfigError),
59    #[error("spec construction error: {context}: {source}")]
60    SpecConstructionResolver {
61        context: &'static str,
62        #[source]
63        source: ResolverError,
64    },
65}
66}
67
68impl ApplicationError {
69    /// Wraps a lower engine failure with its stage; a deliberate non-blanket alternative to `From<EngineError>`.
70    #[must_use]
71    pub fn from_engine(stage: ApplicationStage, source: EngineError) -> Self {
72        Self {
73            stage,
74            kind: ApplicationErrorKind::Engine(source),
75        }
76    }
77
78    pub(crate) fn encounter_resolution(
79        stage: ApplicationStage,
80        source: EncounterResolutionError,
81    ) -> Self {
82        Self {
83            stage,
84            kind: ApplicationErrorKind::EncounterResolution(source),
85        }
86    }
87
88    pub(crate) fn intent_config(stage: ApplicationStage, source: IntentConfigError) -> Self {
89        Self {
90            stage,
91            kind: ApplicationErrorKind::IntentConfig(source),
92        }
93    }
94
95    pub(crate) fn spec_construction_resolver(
96        stage: ApplicationStage,
97        context: &'static str,
98        source: ResolverError,
99    ) -> Self {
100        Self {
101            stage,
102            kind: ApplicationErrorKind::SpecConstructionResolver { context, source },
103        }
104    }
105
106    #[must_use]
107    pub const fn stage(&self) -> ApplicationStage {
108        self.stage
109    }
110
111    #[must_use]
112    pub const fn category(&self) -> ApplicationErrorCategory {
113        match self.kind {
114            ApplicationErrorKind::EncounterResolution(_) => {
115                ApplicationErrorCategory::EncounterResolution
116            }
117            ApplicationErrorKind::Engine(_) => ApplicationErrorCategory::Engine,
118            ApplicationErrorKind::IntentConfig(_) => ApplicationErrorCategory::IntentConfig,
119            ApplicationErrorKind::SpecConstructionResolver { .. } => {
120                ApplicationErrorCategory::Resolver
121            }
122        }
123    }
124
125    #[must_use]
126    pub const fn encounter_resolution_error(&self) -> Option<&EncounterResolutionError> {
127        match &self.kind {
128            ApplicationErrorKind::EncounterResolution(source) => Some(source),
129            _ => None,
130        }
131    }
132
133    #[must_use]
134    pub const fn resolver_error(&self) -> Option<&ResolverError> {
135        match &self.kind {
136            ApplicationErrorKind::SpecConstructionResolver { source, .. } => Some(source),
137            _ => None,
138        }
139    }
140
141    #[cfg(test)]
142    #[must_use]
143    pub(crate) const fn engine_error(&self) -> Option<&EngineError> {
144        match &self.kind {
145            ApplicationErrorKind::Engine(source) => Some(source),
146            _ => None,
147        }
148    }
149
150    #[cfg(test)]
151    #[must_use]
152    pub(crate) const fn intent_config_error(&self) -> Option<&IntentConfigError> {
153        match &self.kind {
154            ApplicationErrorKind::IntentConfig(source) => Some(source),
155            _ => None,
156        }
157    }
158}
159
160impl fmt::Debug for ApplicationError {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        f.debug_struct("ApplicationError")
163            .field("stage", &self.stage())
164            .field("category", &self.category())
165            .finish_non_exhaustive()
166    }
167}
168
169pub(crate) trait EngineResultExt<T> {
170    fn in_application_stage(self, stage: ApplicationStage) -> Result<T, ApplicationError>;
171}
172
173impl<T, E> EngineResultExt<T> for Result<T, E>
174where
175    EngineError: From<E>,
176{
177    fn in_application_stage(self, stage: ApplicationStage) -> Result<T, ApplicationError> {
178        self.map_err(|source| ApplicationError::from_engine(stage, EngineError::from(source)))
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use std::error::Error as _;
185
186    use googletest::prelude::*;
187    use wowlab_engine_ports::{
188        EngineConstructionError, EngineConstructionStage, SimRunError, SpecRuntimeError, SpellId,
189    };
190    use wowlab_types::sim::EnemyIdx;
191
192    use super::*;
193
194    #[gtest]
195    fn invalid_config_retains_common_and_toml_sources_and_exact_presentation() -> Result<()> {
196        let source = crate::settings::parse_config("[").err().or_fail()?;
197        let error = ApplicationError::intent_config(ApplicationStage::ConfigParsing, source);
198
199        verify_that!(error.stage(), eq(ApplicationStage::ConfigParsing))?;
200        verify_that!(error.category(), eq(ApplicationErrorCategory::IntentConfig))?;
201        verify_true!(error.intent_config_error().is_some())?;
202        verify_true!(
203            error
204                .source()
205                .and_then(std::error::Error::source)
206                .is_some_and(<dyn std::error::Error>::is::<IntentConfigError>)
207        )?;
208        verify_true!(
209            error
210                .source()
211                .and_then(std::error::Error::source)
212                .and_then(std::error::Error::source)
213                .and_then(std::error::Error::source)
214                .is_some_and(<dyn std::error::Error>::is::<toml::de::Error>)
215        )?;
216        verify_true!(error.to_string().starts_with(
217            "intent validation error: failed to parse sim config TOML: TOML parse error:"
218        ))?;
219
220        Ok(())
221    }
222
223    #[gtest]
224    fn resolver_context_retains_typed_source_and_exact_presentation() -> Result<()> {
225        let error = ApplicationError::spec_construction_resolver(
226            ApplicationStage::PaperdollResolution,
227            "failed to resolve scaling data",
228            ResolverError::spell_not_found(SpellId::new(42)),
229        );
230
231        verify_that!(error.stage(), eq(ApplicationStage::PaperdollResolution))?;
232        verify_that!(error.category(), eq(ApplicationErrorCategory::Resolver))?;
233        verify_that!(
234            error.to_string(),
235            eq("spec construction error: failed to resolve scaling data: spell 42 not found")
236        )?;
237        verify_true!(error.resolver_error().is_some())?;
238        verify_true!(
239            error
240                .source()
241                .and_then(std::error::Error::source)
242                .is_some_and(<dyn std::error::Error>::is::<ResolverError>)
243        )?;
244
245        Ok(())
246    }
247
248    #[gtest]
249    fn simulation_and_handler_failures_retain_every_typed_source() -> Result<()> {
250        let source = SimRunError::handler(SpecRuntimeError::missing_enemy(EnemyIdx::PRIMARY));
251        let error = ApplicationError::from_engine(
252            ApplicationStage::ChunkExecution,
253            EngineError::from(source),
254        );
255
256        verify_that!(error.category(), eq(ApplicationErrorCategory::Engine))?;
257        verify_that!(error.stage(), eq(ApplicationStage::ChunkExecution))?;
258        let engine = error
259            .source()
260            .and_then(std::error::Error::source)
261            .and_then(|source| source.downcast_ref::<EngineError>())
262            .or_fail()?;
263        let sim = engine
264            .source()
265            .and_then(std::error::Error::source)
266            .and_then(|source| source.downcast_ref::<SimRunError>())
267            .or_fail()?;
268
269        verify_true!(
270            sim.source()
271                .and_then(std::error::Error::source)
272                .is_some_and(<dyn std::error::Error>::is::<SpecRuntimeError>)
273        )?;
274        verify_that!(
275            error.to_string(),
276            eq("simulation runtime error: spec handler failed while processing an event")
277        )?;
278
279        Ok(())
280    }
281
282    #[gtest]
283    fn debug_reports_context_without_leaking_lower_layer_payloads() -> Result<()> {
284        let error = ApplicationError::from_engine(
285            ApplicationStage::HandlerConstruction,
286            EngineError::spec_construction("private resolver credential"),
287        );
288        let debug = format!("{error:?}");
289
290        verify_true!(debug.contains("HandlerConstruction"))?;
291        verify_true!(debug.contains("Engine"))?;
292        verify_true!(!debug.contains("private resolver credential"))?;
293        verify_true!(error.to_string().contains("private resolver credential"))?;
294
295        Ok(())
296    }
297
298    #[gtest]
299    fn construction_failure_retains_ports_and_leaf_sources_without_debug_leakage() -> Result<()> {
300        let error = ApplicationError::from_engine(
301            ApplicationStage::HandlerConstruction,
302            EngineError::rotation_compile(std::io::Error::new(
303                std::io::ErrorKind::InvalidData,
304                "private rotation payload",
305            )),
306        );
307
308        verify_that!(error.stage(), eq(ApplicationStage::HandlerConstruction))?;
309        verify_that!(error.category(), eq(ApplicationErrorCategory::Engine))?;
310        verify_that!(
311            error.to_string(),
312            eq("rotation compile error: private rotation payload")
313        )?;
314        let engine = error
315            .source()
316            .and_then(std::error::Error::source)
317            .and_then(|source| source.downcast_ref::<EngineError>())
318            .or_fail()?;
319        let construction = engine
320            .source()
321            .and_then(std::error::Error::source)
322            .and_then(|source| source.downcast_ref::<EngineConstructionError>())
323            .or_fail()?;
324
325        verify_that!(
326            construction.stage(),
327            eq(EngineConstructionStage::RotationCompile)
328        )?;
329        verify_true!(
330            construction
331                .source()
332                .and_then(std::error::Error::source)
333                .is_some_and(<dyn std::error::Error>::is::<std::io::Error>)
334        )?;
335        let debug = format!("{error:?}");
336
337        verify_true!(debug.contains("HandlerConstruction"))?;
338        verify_true!(!debug.contains("private rotation payload"))?;
339
340        Ok(())
341    }
342}