Skip to main content

condor/
error.rs

1//! Root-owned structured errors for embedded polygon-pack loading.
2//!
3//! # Facade role
4//!
5//! This module is **root-owned** on the public facade (not a re-export from an
6//! owner crate). [`crate::polygonal`] uses it to preserve typed parser,
7//! validation, and oracle failures while loading embedded public polygon packs.
8//! Domain build errors it wraps (`GridBuildError`, `PolygonValidationError`,
9//! `NavmeshValidationError`, …) remain owned by their domain crates.
10//!
11//! # Error layers
12//!
13//! Pack loading surfaces errors at two layers:
14//! - [`ArtifactLoadError`] is the **public load boundary**: which embedded pack
15//!   family failed while preserving the concrete source chain.
16//! - [`ArtifactDataError`] is the **data/contract failure**: I/O, parse, domain build,
17//!   or a typed [`ArtifactContractError`] when a decoded payload violates its schema.
18//!
19//! Prefer matching on the load-boundary family at API edges, then drill into
20//! `ArtifactDataError::Contract` / [`ArtifactContractError::kind`] for stable
21//! machine-readable categories without string-parsing display text.
22//!
23//! The public loader is gated by `polygonal`; nested source variants also follow
24//! their domain feature requirements.
25
26use crate::polygonal::PolygonValidationError;
27#[cfg(feature = "grid")]
28use crate::{FlowFieldBuildError, GridBuildError, GridEditError, GridSearchError};
29#[cfg(feature = "navmesh")]
30use crate::{
31    PreparedNavmeshBuildError,
32    navmesh::{DynamicNavmeshError, NavmeshValidationError},
33};
34
35/// Concrete parser, I/O, validation, or materialization failure behind a loader.
36///
37/// Domain build failures (`GridBuild`, `NavmeshValidation`, …) and
38/// [`ArtifactContractError`] (via [`Self::Contract`]) both live here so loaders
39/// can unify transport/format errors with payload-contract violations.
40#[derive(Debug, thiserror::Error)]
41#[non_exhaustive]
42pub enum ArtifactDataError {
43    /// Filesystem or reader I/O failure while loading pack bytes.
44    #[error("I/O failure: {0}")]
45    Io(#[from] std::io::Error),
46    /// Pack bytes were not valid UTF-8 text.
47    #[error("invalid UTF-8: {0}")]
48    Utf8(#[from] std::string::FromUtf8Error),
49    /// Integer field parse failed in a text pack format.
50    #[error("invalid integer: {0}")]
51    ParseInt(#[from] std::num::ParseIntError),
52    /// Floating-point field parse failed in a text pack format.
53    #[error("invalid floating-point number: {0}")]
54    ParseFloat(#[from] std::num::ParseFloatError),
55    /// TOML decode failed for a pack document.
56    #[error("invalid TOML: {0}")]
57    Toml(#[from] toml::de::Error),
58    /// JSON decode failed for a pack document.
59    #[error("invalid JSON: {0}")]
60    Json(#[from] serde_json::Error),
61    /// Domain grid construction rejected the decoded payload.
62    #[cfg(feature = "grid")]
63    #[error("grid construction failed: {0}")]
64    GridBuild(#[from] GridBuildError),
65    /// Domain grid edit rejected a decoded mutation.
66    #[cfg(feature = "grid")]
67    #[error("grid edit failed: {0}")]
68    GridEdit(#[from] GridEditError),
69    /// Domain grid search failed while materializing pack-backed work.
70    #[cfg(feature = "grid")]
71    #[error("grid search failed: {0}")]
72    GridSearch(#[from] GridSearchError),
73    /// Continuous polygon scene validation failed.
74    #[error("polygon validation failed: {0}")]
75    PolygonValidation(#[from] PolygonValidationError),
76    /// Static navmesh validation failed.
77    #[cfg(feature = "navmesh")]
78    #[error("navmesh validation failed: {0}")]
79    NavmeshValidation(#[from] NavmeshValidationError),
80    /// Dynamic navmesh overlay validation failed.
81    #[cfg(feature = "navmesh")]
82    #[error("dynamic navmesh validation failed: {0}")]
83    DynamicNavmesh(#[from] DynamicNavmeshError),
84    /// Prepared navmesh preprocess failed after decode.
85    #[cfg(feature = "navmesh")]
86    #[error("prepared navmesh construction failed: {0}")]
87    PreparedNavmesh(#[from] PreparedNavmeshBuildError),
88    /// Flow-field build failed for a pack-backed story/fixture.
89    #[cfg(feature = "grid")]
90    #[error("flow-field construction failed: {0}")]
91    FlowField(#[from] FlowFieldBuildError),
92    /// Decoded payload violated a stable pack-schema contract.
93    #[error(transparent)]
94    Contract(Box<ArtifactContractError>),
95}
96
97/// Stable category for decoded artifact contract violations.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99#[non_exhaustive]
100pub enum ArtifactContractKind {
101    /// Pack format/version field is not supported by this loader.
102    UnsupportedVersion,
103    /// A required schema field is absent.
104    MissingRequiredField,
105    /// An identifier field is present but empty or whitespace-only.
106    EmptyIdentifier,
107    /// A required collection field is empty when at least one entry is required.
108    EmptyCollection,
109    /// A coordinate or index falls outside the declared map/scene bounds.
110    OutOfBounds,
111    /// A reference points at a missing or foreign entity id.
112    InvalidReference,
113    /// Related fields disagree (counts, dimensions, cross-links).
114    InconsistentData,
115    /// A field is present but not a legal domain value.
116    InvalidValue,
117}
118
119/// Typed artifact-contract failure with machine-readable identity and values.
120#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
121#[non_exhaustive]
122pub enum ArtifactContractError {
123    /// Pack format/version is not supported by this loader revision.
124    #[error("unsupported {field} {value} for {artifact}")]
125    UnsupportedVersion {
126        /// Logical pack/artifact name for diagnostics.
127        artifact: String,
128        /// Version field key (for example `format_version`).
129        field: String,
130        /// Observed unsupported value.
131        value: String,
132    },
133    /// Required schema field is absent from the decoded payload.
134    #[error("{artifact} is missing required field {field}")]
135    MissingRequiredField {
136        /// Logical pack/artifact name for diagnostics.
137        artifact: String,
138        /// Missing field key.
139        field: String,
140    },
141    /// Identifier field is present but empty or whitespace-only.
142    #[error("{artifact} has an empty identifier in {field}")]
143    EmptyIdentifier {
144        /// Logical pack/artifact name for diagnostics.
145        artifact: String,
146        /// Offending identifier field key.
147        field: String,
148    },
149    /// Collection field is empty when the schema requires entries.
150    #[error("{artifact} has an empty collection in {field}")]
151    EmptyCollection {
152        /// Logical pack/artifact name for diagnostics.
153        artifact: String,
154        /// Empty collection field key.
155        field: String,
156    },
157    /// Index or coordinate is outside the declared map/scene bounds.
158    #[error("{artifact} field {field} is out of bounds at {location:?}: {value}")]
159    OutOfBounds {
160        /// Logical pack/artifact name for diagnostics.
161        artifact: String,
162        /// Out-of-bounds field key.
163        field: String,
164        /// Optional list/grid location within the artifact.
165        location: ArtifactContractLocation,
166        /// Observed out-of-bounds value (display form).
167        value: String,
168    },
169    /// Reference field points at a missing or foreign entity.
170    #[error("{artifact} field {field} has an invalid reference at {location:?}: {value}")]
171    InvalidReference {
172        /// Logical pack/artifact name for diagnostics.
173        artifact: String,
174        /// Reference field key.
175        field: String,
176        /// Optional list/grid location within the artifact.
177        location: ArtifactContractLocation,
178        /// Observed reference value.
179        value: String,
180    },
181    /// Related fields disagree (counts, dimensions, cross-links).
182    #[error("{artifact} fields are inconsistent at {location:?}: {field}={value}")]
183    InconsistentData {
184        /// Logical pack/artifact name for diagnostics.
185        artifact: String,
186        /// Primary field involved in the inconsistency.
187        field: String,
188        /// Optional list/grid location within the artifact.
189        location: ArtifactContractLocation,
190        /// Summary of the inconsistent value or pairing.
191        value: String,
192    },
193    /// Field is present but not a legal domain value.
194    #[error("{artifact} field {field} has invalid value at {location:?}: {value}")]
195    InvalidValue {
196        /// Logical pack/artifact name for diagnostics.
197        artifact: String,
198        /// Invalid field key.
199        field: String,
200        /// Optional list/grid location within the artifact.
201        location: ArtifactContractLocation,
202        /// Observed illegal value.
203        value: String,
204    },
205}
206
207/// Optional position of a contract violation within a decoded artifact.
208///
209/// Fields are independent hints (list index and/or grid row/column). Absent
210/// coordinates mean the violation is artifact-wide or the loader did not attach
211/// a finer location.
212#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
213#[non_exhaustive]
214pub struct ArtifactContractLocation {
215    /// Optional collection/list index within the artifact.
216    pub index: Option<usize>,
217    /// Optional map/grid row for spatial packs.
218    pub row: Option<usize>,
219    /// Optional map/grid column for spatial packs.
220    pub column: Option<usize>,
221}
222
223impl ArtifactContractLocation {
224    /// No positional context (artifact-wide or unspecified).
225    pub const NONE: Self = Self {
226        index: None,
227        row: None,
228        column: None,
229    };
230
231    /// Violation at a single collection index (scenario list, portal list, …).
232    pub const fn index(index: usize) -> Self {
233        Self {
234            index: Some(index),
235            row: None,
236            column: None,
237        }
238    }
239
240    /// Violation on a map/grid row without a column.
241    pub const fn row(row: usize) -> Self {
242        Self {
243            index: None,
244            row: Some(row),
245            column: None,
246        }
247    }
248
249    /// Violation at a map/grid cell (`row`, `column`).
250    pub const fn row_column(row: usize, column: usize) -> Self {
251        Self {
252            index: None,
253            row: Some(row),
254            column: Some(column),
255        }
256    }
257}
258
259impl ArtifactContractError {
260    /// Stable category for this error, independent of field names and display text.
261    ///
262    /// Use for routing, metrics, and fixture assertions without matching every
263    /// payload-bearing variant arm.
264    pub const fn kind(&self) -> ArtifactContractKind {
265        match self {
266            Self::UnsupportedVersion { .. } => ArtifactContractKind::UnsupportedVersion,
267            Self::MissingRequiredField { .. } => ArtifactContractKind::MissingRequiredField,
268            Self::EmptyIdentifier { .. } => ArtifactContractKind::EmptyIdentifier,
269            Self::EmptyCollection { .. } => ArtifactContractKind::EmptyCollection,
270            Self::OutOfBounds { .. } => ArtifactContractKind::OutOfBounds,
271            Self::InvalidReference { .. } => ArtifactContractKind::InvalidReference,
272            Self::InconsistentData { .. } => ArtifactContractKind::InconsistentData,
273            Self::InvalidValue { .. } => ArtifactContractKind::InvalidValue,
274        }
275    }
276}
277
278impl ArtifactDataError {
279    /// Contract factory: unsupported `format_version` for a named artifact pack.
280    pub(crate) fn unsupported_version(artifact: impl Into<String>, value: impl ToString) -> Self {
281        Self::Contract(Box::new(ArtifactContractError::UnsupportedVersion {
282            artifact: artifact.into(),
283            field: "format_version".into(),
284            value: value.to_string(),
285        }))
286    }
287
288    /// Contract factory: required schema field absent from a decoded pack.
289    pub(crate) fn missing_required_field(
290        artifact: impl Into<String>,
291        field: impl Into<String>,
292    ) -> Self {
293        Self::Contract(Box::new(ArtifactContractError::MissingRequiredField {
294            artifact: artifact.into(),
295            field: field.into(),
296        }))
297    }
298
299    /// Contract factory: identifier field present but empty/whitespace-only.
300    pub(crate) fn empty_identifier(artifact: impl Into<String>, field: impl Into<String>) -> Self {
301        Self::Contract(Box::new(ArtifactContractError::EmptyIdentifier {
302            artifact: artifact.into(),
303            field: field.into(),
304        }))
305    }
306
307    /// Contract factory: field value points at a missing or foreign entity.
308    pub(crate) fn invalid_reference(
309        artifact: impl Into<String>,
310        field: impl Into<String>,
311        location: ArtifactContractLocation,
312        value: impl ToString,
313    ) -> Self {
314        Self::Contract(Box::new(ArtifactContractError::InvalidReference {
315            artifact: artifact.into(),
316            field: field.into(),
317            location,
318            value: value.to_string(),
319        }))
320    }
321
322    /// Contract factory: related fields disagree at an optional location.
323    pub(crate) fn inconsistent_data(
324        artifact: impl Into<String>,
325        field: impl Into<String>,
326        location: ArtifactContractLocation,
327        value: impl ToString,
328    ) -> Self {
329        Self::Contract(Box::new(ArtifactContractError::InconsistentData {
330            artifact: artifact.into(),
331            field: field.into(),
332            location,
333            value: value.to_string(),
334        }))
335    }
336
337    /// Contract factory: field present but not a legal domain value.
338    pub(crate) fn invalid_value(
339        artifact: impl Into<String>,
340        field: impl Into<String>,
341        location: ArtifactContractLocation,
342        value: impl ToString,
343    ) -> Self {
344        Self::Contract(Box::new(ArtifactContractError::InvalidValue {
345            artifact: artifact.into(),
346            field: field.into(),
347            location,
348            value: value.to_string(),
349        }))
350    }
351}
352
353/// Public loading boundary: identifies the artifact family and preserves its source.
354///
355/// Variants name the **loader surface** (atlas maps, benchmark packs, story/fixture
356/// packs for polygon/navmesh/MAPF/replanning/any-angle, …). The nested
357/// [`ArtifactDataError`] holds parse, I/O, materialization, or contract detail.
358#[derive(Debug, thiserror::Error)]
359#[non_exhaustive]
360pub enum ArtifactLoadError {
361    /// Moving AI `.map`/`.scen` atlas loader surface failed.
362    #[error("failed to load Moving AI atlas data: {source}")]
363    Atlas {
364        /// Nested data/contract failure for this load.
365        #[source]
366        source: ArtifactDataError,
367    },
368    /// Grid benchmark scenario pack loader surface failed.
369    #[error("failed to load benchmark scenario data: {source}")]
370    Benchmark {
371        /// Nested data/contract failure for this load.
372        #[source]
373        source: ArtifactDataError,
374    },
375    /// Continuous polygon scene pack loader surface failed.
376    #[error("failed to load polygon scene data: {source}")]
377    Polygon {
378        /// Nested data/contract failure for this load.
379        #[source]
380        source: ArtifactDataError,
381    },
382    /// Navmesh pack loader surface failed.
383    #[error("failed to load navmesh data: {source}")]
384    Navmesh {
385        /// Nested data/contract failure for this load.
386        #[source]
387        source: ArtifactDataError,
388    },
389    /// Flow-field story/fixture pack loader surface failed.
390    #[error("failed to load flow-field data: {source}")]
391    FlowField {
392        /// Nested data/contract failure for this load.
393        #[source]
394        source: ArtifactDataError,
395    },
396    /// Multi-agent pathfinding pack loader surface failed.
397    #[error("failed to load MAPF data: {source}")]
398    Mapf {
399        /// Nested data/contract failure for this load.
400        #[source]
401        source: ArtifactDataError,
402    },
403    /// Incremental replanning pack loader surface failed.
404    #[error("failed to load replanning data: {source}")]
405    Replanning {
406        /// Nested data/contract failure for this load.
407        #[source]
408        source: ArtifactDataError,
409    },
410    /// Any-angle benchmark pack loader surface failed.
411    #[error("failed to load any-angle benchmark data: {source}")]
412    AnyAngle {
413        /// Nested data/contract failure for this load.
414        #[source]
415        source: ArtifactDataError,
416    },
417}
418
419impl ArtifactLoadError {
420    /// Load-boundary factory for the polygon pack family.
421    pub(crate) const fn polygon(source: ArtifactDataError) -> Self {
422        Self::Polygon { source }
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use std::error::Error;
429
430    use super::{
431        ArtifactContractError, ArtifactContractLocation, ArtifactDataError, ArtifactLoadError,
432    };
433
434    #[test]
435    fn benchmark_toml_error_preserves_concrete_source_chain() {
436        let parse_error = toml::from_str::<u32>("not = [valid").unwrap_err();
437        let error = ArtifactLoadError::Benchmark {
438            source: parse_error.into(),
439        };
440
441        assert!(matches!(
442            &error,
443            ArtifactLoadError::Benchmark {
444                source: ArtifactDataError::Toml(_)
445            }
446        ));
447        let data_source = error
448            .source()
449            .expect("loader error should expose data source");
450        assert!(
451            data_source.source().is_some(),
452            "TOML source should be chained"
453        );
454    }
455
456    #[test]
457    fn unsupported_version_exposes_exact_typed_data() {
458        let error = ArtifactDataError::unsupported_version("benchmark pack", 7);
459        let ArtifactDataError::Contract(contract) = error else {
460            panic!("expected contract error");
461        };
462        assert_eq!(
463            *contract,
464            ArtifactContractError::UnsupportedVersion {
465                artifact: "benchmark pack".into(),
466                field: "format_version".into(),
467                value: "7".into(),
468            }
469        );
470    }
471
472    #[test]
473    fn empty_identifier_exposes_exact_typed_data() {
474        let error = ArtifactDataError::empty_identifier("benchmark pack", "pack_id");
475        let ArtifactDataError::Contract(contract) = error else {
476            panic!("expected contract error");
477        };
478        assert_eq!(
479            *contract,
480            ArtifactContractError::EmptyIdentifier {
481                artifact: "benchmark pack".into(),
482                field: "pack_id".into(),
483            }
484        );
485    }
486
487    #[test]
488    fn missing_field_exposes_exact_typed_data() {
489        let error = ArtifactDataError::missing_required_field("scenario-1", "source_map");
490        let ArtifactDataError::Contract(contract) = error else {
491            panic!("expected contract error");
492        };
493        assert_eq!(
494            *contract,
495            ArtifactContractError::MissingRequiredField {
496                artifact: "scenario-1".into(),
497                field: "source_map".into(),
498            }
499        );
500    }
501
502    #[test]
503    fn invalid_reference_exposes_exact_typed_data() {
504        let error = ArtifactDataError::invalid_reference(
505            "mesh-1",
506            "portals.left",
507            ArtifactContractLocation::index(3),
508            "missing-cell",
509        );
510        let ArtifactDataError::Contract(contract) = error else {
511            panic!("expected contract error");
512        };
513        assert_eq!(
514            *contract,
515            ArtifactContractError::InvalidReference {
516                artifact: "mesh-1".into(),
517                field: "portals.left".into(),
518                location: ArtifactContractLocation::index(3),
519                value: "missing-cell".into(),
520            }
521        );
522    }
523
524    #[test]
525    fn invalid_value_exposes_exact_typed_data() {
526        let error = ArtifactDataError::invalid_value(
527            "scenario-1",
528            "movement_model",
529            ArtifactContractLocation::NONE,
530            "hex",
531        );
532        let ArtifactDataError::Contract(contract) = error else {
533            panic!("expected contract error");
534        };
535        assert_eq!(
536            *contract,
537            ArtifactContractError::InvalidValue {
538                artifact: "scenario-1".into(),
539                field: "movement_model".into(),
540                location: ArtifactContractLocation::NONE,
541                value: "hex".into(),
542            }
543        );
544    }
545}