Skip to main content

wowlab_engine/cli/
error.rs

1use wowlab_common::sim::intent::IntentConfigError;
2use wowlab_engine_application::ApplicationError;
3use wowlab_engine_ports::{ContentCatalogError, EngineError};
4use wowlab_fs::path::PathBuf;
5use wowlab_manifest_schema::ManifestLoadError;
6
7wowlab_engine_macros::define_error! {
8    #[derive(Debug)]
9    #[non_exhaustive]
10    pub(crate) struct CliError {
11        kind: CliErrorKind,
12    }
13
14    #[derive(Debug, thiserror::Error)]
15    enum CliErrorKind {
16        #[error("{0}")]
17        Filesystem(#[source] wowlab_fs::error::Error),
18        #[error("invalid sim config: {0}")]
19        SimConfig(#[source] IntentConfigError),
20        #[error("invalid rotation JSON: {0}")]
21        RotationJson(#[source] serde_json::Error),
22        #[error("invalid rotation names: {0}")]
23        RotationNames(#[source] wowlab_engine_domain::rotation::NameExtractionError),
24        #[error("failed to decode telemetry protobuf: {0}")]
25        TelemetryDecode(#[source] prost::DecodeError),
26        #[error(transparent)]
27        Manifest(ManifestLoadError),
28        #[error(transparent)]
29        Engine(EngineError),
30        #[error("{0}")]
31        Application(#[source] ApplicationError),
32        #[cfg(feature = "supabase")]
33        #[error("{0}")]
34        Supabase(#[source] wowlab_supabase::SupabaseError),
35        #[cfg(feature = "supabase")]
36        #[error("{0}")]
37        Cache(#[source] wowlab_engine_adapter_data::CacheError),
38        #[error("{0}")]
39        Resolver(String),
40        #[error("simulation runner received a non-simulation command")]
41        UnexpectedSimulationCommand,
42        #[error("no rotation for {spec}: resolver has none and {path} could not be read: {source}")]
43        RotationUnavailable {
44            spec: String,
45            path: PathBuf,
46            #[source]
47            source: Box<CliError>,
48        },
49        #[error("{0}")]
50        Audit(String),
51        #[error("{count} unresolved name(s) for spec {spec}")]
52        UnresolvedNames { count: usize, spec: String },
53    }
54}
55
56impl CliError {
57    pub(crate) fn resolver(message: impl Into<String>) -> Self {
58        Self {
59            kind: CliErrorKind::Resolver(message.into()),
60        }
61    }
62
63    pub(crate) fn unexpected_simulation_command() -> Self {
64        Self {
65            kind: CliErrorKind::UnexpectedSimulationCommand,
66        }
67    }
68
69    pub(crate) fn rotation_unavailable(spec: String, path: PathBuf, source: Self) -> Self {
70        Self {
71            kind: CliErrorKind::RotationUnavailable {
72                spec,
73                path,
74                source: Box::new(source),
75            },
76        }
77    }
78
79    pub(crate) fn audit(message: impl Into<String>) -> Self {
80        Self {
81            kind: CliErrorKind::Audit(message.into()),
82        }
83    }
84
85    pub(crate) fn unresolved_names(count: usize, spec: String) -> Self {
86        Self {
87            kind: CliErrorKind::UnresolvedNames { count, spec },
88        }
89    }
90}
91
92macro_rules! cli_error_from {
93    ($( $(#[$meta:meta])* $source:ty => $variant:ident ),+ $(,)?) => {
94        $(
95            $(#[$meta])*
96            impl From<$source> for CliError {
97                fn from(source: $source) -> Self {
98                    Self {
99                        kind: CliErrorKind::$variant(source),
100                    }
101                }
102            }
103        )+
104    };
105}
106
107cli_error_from! {
108    wowlab_fs::error::Error => Filesystem,
109    IntentConfigError => SimConfig,
110    serde_json::Error => RotationJson,
111    wowlab_engine_domain::rotation::NameExtractionError => RotationNames,
112    prost::DecodeError => TelemetryDecode,
113    ManifestLoadError => Manifest,
114    EngineError => Engine,
115    ApplicationError => Application,
116    #[cfg(feature = "supabase")]
117    wowlab_supabase::SupabaseError => Supabase,
118    #[cfg(feature = "supabase")]
119    wowlab_engine_adapter_data::CacheError => Cache,
120}
121
122impl From<ContentCatalogError> for CliError {
123    fn from(source: ContentCatalogError) -> Self {
124        Self {
125            kind: CliErrorKind::Engine(EngineError::from(source)),
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use std::error::Error as _;
133
134    use googletest::prelude::*;
135
136    use super::CliError;
137
138    #[gtest]
139    fn sim_config_error_preserves_common_and_toml_sources() -> Result<()> {
140        let source = wowlab_common::sim::intent::parse_sim_config("[")
141            .err()
142            .or_fail()?;
143        let error = CliError::from(source);
144
145        let config_source = error.source().or_fail()?;
146
147        verify_true!(config_source.is::<wowlab_common::sim::intent::IntentConfigError>())?;
148        verify_true!(
149            config_source
150                .source()
151                .and_then(std::error::Error::source)
152                .is_some_and(<dyn std::error::Error>::is::<toml::de::Error>)
153        )?;
154        verify_true!(
155            error
156                .to_string()
157                .starts_with("invalid sim config: TOML parse error:")
158        )?;
159
160        Ok(())
161    }
162
163    #[gtest]
164    fn rotation_json_error_preserves_serde_source() -> Result<()> {
165        let source = serde_json::from_str::<serde_json::Value>("{")
166            .err()
167            .or_fail()?;
168        let error = CliError::from(source);
169
170        verify_true!(
171            error
172                .source()
173                .is_some_and(<dyn std::error::Error>::is::<serde_json::Error>)
174        )?;
175
176        Ok(())
177    }
178
179    #[gtest]
180    fn rotation_name_error_preserves_domain_and_serde_source_chain() -> Result<()> {
181        let source = wowlab_engine_domain::rotation::extract_names("{")
182            .err()
183            .or_fail()?;
184        let error = CliError::from(source);
185
186        verify_true!(error.source().is_some_and(
187            <dyn std::error::Error>::is::<wowlab_engine_domain::rotation::NameExtractionError>
188        ))?;
189        verify_true!(
190            error
191                .source()
192                .and_then(std::error::Error::source)
193                .is_some_and(<dyn std::error::Error>::is::<serde_json::Error>)
194        )?;
195        verify_true!(error.to_string().starts_with(
196            "invalid rotation names: Failed to parse rotation: EOF while parsing an object"
197        ))?;
198
199        Ok(())
200    }
201
202    #[gtest]
203    fn telemetry_error_preserves_protobuf_source() -> Result<()> {
204        let source = <wowlab_types::proto::ChunkTelemetry as prost::Message>::decode(&[0x80][..])
205            .err()
206            .or_fail()?;
207        let error = CliError::from(source);
208
209        verify_true!(
210            error
211                .source()
212                .is_some_and(<dyn std::error::Error>::is::<prost::DecodeError>)
213        )?;
214
215        Ok(())
216    }
217
218    #[gtest]
219    fn application_error_remains_typed_at_the_cli_boundary() -> Result<()> {
220        let source = wowlab_engine_application::ApplicationError::from_engine(
221            wowlab_engine_application::ApplicationStage::SpecIntrospection,
222            wowlab_engine_ports::EngineError::spec_not_found("missing"),
223        );
224        let error = CliError::from(source);
225
226        let application = error
227            .source()
228            .and_then(|source| source.downcast_ref::<wowlab_engine_application::ApplicationError>())
229            .or_fail()?;
230
231        verify_that!(
232            application.stage(),
233            eq(wowlab_engine_application::ApplicationStage::SpecIntrospection)
234        )?;
235        verify_true!(
236            application
237                .source()
238                .and_then(std::error::Error::source)
239                .is_some_and(<dyn std::error::Error>::is::<wowlab_engine_ports::EngineError>)
240        )?;
241        verify_that!(error.to_string(), eq("spec not found: missing"))?;
242
243        Ok(())
244    }
245
246    #[cfg(feature = "supabase")]
247    #[gtest]
248    fn cache_error_preserves_adapter_data_and_io_sources() -> Result<()> {
249        let directory = wowlab_fs::temporary::Directory::new().or_fail()?;
250        let path = directory.path().join("cache");
251
252        wowlab_fs::file::write_text(&path, "not-a-directory").or_fail()?;
253        let client =
254            wowlab_supabase::SupabaseClient::new("http://localhost:0", "test-key").or_fail()?;
255        let source = wowlab_engine_adapter_data::GameDataCache::new(client, "11.1.0", path)
256            .err()
257            .or_fail()?;
258        let error = CliError::from(source);
259
260        let cache_source = error
261            .source()
262            .and_then(|source| source.downcast_ref::<wowlab_engine_adapter_data::CacheError>())
263            .or_fail()?;
264        let mut source = cache_source.source();
265        let mut found_filesystem_source = false;
266        let mut found_io_source = false;
267
268        while let Some(current) = source {
269            found_filesystem_source |= current.is::<wowlab_fs::error::Error>();
270            found_io_source |= current.is::<std::io::Error>();
271            source = current.source();
272        }
273
274        verify_true!(found_filesystem_source)?;
275        verify_true!(found_io_source)?;
276
277        Ok(())
278    }
279
280    #[gtest]
281    fn rotation_unavailable_preserves_read_error_source_chain() -> Result<()> {
282        let directory = wowlab_fs::temporary::Directory::new().or_fail()?;
283        let path = directory.path().join("missing.json");
284        let source = wowlab_fs::file::read_text(&path)
285            .map_err(CliError::from)
286            .err()
287            .or_fail()?;
288        let error = CliError::rotation_unavailable("outlaw_rogue".to_string(), path, source);
289
290        let mut source = error.source();
291        let mut found_filesystem_source = false;
292        let mut found_io_source = false;
293
294        while let Some(current) = source {
295            found_filesystem_source |= current.is::<wowlab_fs::error::Error>();
296            found_io_source |= current.is::<std::io::Error>();
297            source = current.source();
298        }
299
300        verify_true!(found_filesystem_source)?;
301        verify_true!(found_io_source)?;
302
303        Ok(())
304    }
305}