wowlab_engine/cli/
error.rs1use 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}