Skip to main content

oxiphysics_python/
error.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Error types for the `oxiphysics-python` crate.
5//!
6//! All public API methods that can fail return \[`Result`T`].
7//! Errors are designed to be easily surfaced as Python exceptions when
8//! exposed via PyO3/pyo3 FFI, and serialize cleanly to JSON for interchange.
9//!
10//! # Overview
11//!
12//! - [`enum@Error`] — comprehensive enum covering all Python-bridge failure modes
13//! - [`Result`T`\] — convenience alias
14//! - Helper constructors and classification predicates on [`enum@Error`]
15
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19// ---------------------------------------------------------------------------
20// Core error enum
21// ---------------------------------------------------------------------------
22
23/// Comprehensive error type for the `oxiphysics-python` bridge.
24///
25/// Every failure mode has a dedicated variant to allow fine-grained handling
26/// from Python user code and for clean mapping to `PyErr` via PyO3.
27#[derive(Debug, Error, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(tag = "type", content = "data")]
29pub enum Error {
30    // --- Handle / identity errors ---
31    /// A body or object handle was invalid.
32    #[error("invalid handle: {0}")]
33    InvalidHandle(u32),
34
35    /// A rigid body was not found by handle.
36    #[error("body not found: handle={0}")]
37    BodyNotFound(u32),
38
39    /// A collider was not found by handle.
40    #[error("collider not found: handle={0}")]
41    ColliderNotFound(u32),
42
43    // --- Parameter / validation errors ---
44    /// A named parameter was out of its valid range.
45    #[error("invalid parameter '{name}': {message}")]
46    InvalidParameter {
47        /// Parameter name.
48        name: String,
49        /// Constraint description.
50        message: String,
51    },
52
53    /// A mass value was non-positive.
54    #[error("mass must be positive, got {0}")]
55    InvalidMass(f64),
56
57    /// A time step was non-positive.
58    #[error("time step must be positive, got {0}")]
59    InvalidTimeStep(f64),
60
61    /// A geometry dimension was non-positive.
62    #[error("dimension must be positive, got {0}")]
63    InvalidDimension(f64),
64
65    // --- Capacity / resource errors ---
66    /// The world has reached its maximum body capacity.
67    #[error("world at capacity: max {max} bodies")]
68    CapacityExceeded {
69        /// Maximum allowed bodies.
70        max: usize,
71    },
72
73    // --- Simulation state errors ---
74    /// The simulation has diverged (NaN / Inf detected).
75    #[error("simulation diverged at step {step}")]
76    SimulationDiverged {
77        /// Step at which divergence was detected.
78        step: u64,
79    },
80
81    /// The solver failed to converge.
82    #[error("solver did not converge after {iterations} iterations")]
83    SolverConvergenceFailed {
84        /// Iterations attempted.
85        iterations: u32,
86    },
87
88    /// A body was queried but it is sleeping.
89    #[error("body {0} is sleeping")]
90    BodySleeping(u32),
91
92    // --- Serialization / IO errors ---
93    /// JSON serialization or deserialization failed.
94    #[error("serialization error: {0}")]
95    Serialization(String),
96
97    /// Snapshot validation failed (schema mismatch, missing fields, etc.).
98    #[error("snapshot validation failed: {0}")]
99    SnapshotValidation(String),
100
101    // --- Python interop errors ---
102    /// A Python argument had an unexpected type.
103    #[error("type error: expected {expected}, got {got}")]
104    TypeError {
105        /// Expected Python type name.
106        expected: String,
107        /// Actual Python type name.
108        got: String,
109    },
110
111    /// A required Python keyword argument was missing.
112    #[error("missing argument: '{0}'")]
113    MissingArgument(String),
114
115    /// A Python list or array had the wrong length.
116    #[error("wrong array length: expected {expected}, got {got}")]
117    WrongArrayLength {
118        /// Expected length.
119        expected: usize,
120        /// Actual length.
121        got: usize,
122    },
123
124    // --- Generic ---
125    /// Generic error with a free-form message.
126    #[error("{0}")]
127    General(String),
128}
129
130/// Result type alias for `oxiphysics-python` operations.
131pub type Result<T> = std::result::Result<T, Error>;
132
133// ---------------------------------------------------------------------------
134// impl Error
135// ---------------------------------------------------------------------------
136
137impl Error {
138    // --- Constructors -------------------------------------------------------
139
140    /// Create an `InvalidParameter` error.
141    pub fn invalid_param(name: impl Into<String>, message: impl Into<String>) -> Self {
142        Self::InvalidParameter {
143            name: name.into(),
144            message: message.into(),
145        }
146    }
147
148    /// Create a `General` error.
149    pub fn general(msg: impl Into<String>) -> Self {
150        Self::General(msg.into())
151    }
152
153    /// Create a `TypeError` error.
154    pub fn type_error(expected: impl Into<String>, got: impl Into<String>) -> Self {
155        Self::TypeError {
156            expected: expected.into(),
157            got: got.into(),
158        }
159    }
160
161    /// Create a `WrongArrayLength` error.
162    pub fn wrong_len(expected: usize, got: usize) -> Self {
163        Self::WrongArrayLength { expected, got }
164    }
165
166    // --- Classification predicates ------------------------------------------
167
168    /// Returns `true` if this error is handle-related.
169    pub fn is_handle_error(&self) -> bool {
170        matches!(
171            self,
172            Error::InvalidHandle(_) | Error::BodyNotFound(_) | Error::ColliderNotFound(_)
173        )
174    }
175
176    /// Returns `true` if this error is a parameter validation error.
177    pub fn is_parameter_error(&self) -> bool {
178        matches!(
179            self,
180            Error::InvalidParameter { .. }
181                | Error::InvalidMass(_)
182                | Error::InvalidTimeStep(_)
183                | Error::InvalidDimension(_)
184        )
185    }
186
187    /// Returns `true` if this error is capacity-related.
188    pub fn is_capacity_error(&self) -> bool {
189        matches!(self, Error::CapacityExceeded { .. })
190    }
191
192    /// Returns `true` if this error indicates simulation instability.
193    pub fn is_stability_error(&self) -> bool {
194        matches!(
195            self,
196            Error::SimulationDiverged { .. } | Error::SolverConvergenceFailed { .. }
197        )
198    }
199
200    /// Returns `true` if this is a serialization error.
201    pub fn is_serialization_error(&self) -> bool {
202        matches!(self, Error::Serialization(_) | Error::SnapshotValidation(_))
203    }
204
205    /// Returns `true` if this is a Python interop type error.
206    pub fn is_type_error(&self) -> bool {
207        matches!(
208            self,
209            Error::TypeError { .. } | Error::MissingArgument(_) | Error::WrongArrayLength { .. }
210        )
211    }
212
213    // --- JSON / Python interop ----------------------------------------------
214
215    /// Serialize this error to a JSON string for Python exception chaining.
216    ///
217    /// # Example
218    ///
219    /// ```no_run
220    /// use oxiphysics_python::Error;
221    ///
222    /// let e = Error::InvalidTimeStep(-0.1);
223    /// let json = e.to_json();
224    /// assert!(json.contains("InvalidTimeStep"));
225    /// ```
226    pub fn to_json(&self) -> String {
227        let variant_json =
228            serde_json::to_string(self).unwrap_or_else(|_| "\"<serialization failed>\"".into());
229        let message = self.to_string();
230        format!(
231            r#"{{"error":{variant_json},"message":{message_json}}}"#,
232            variant_json = variant_json,
233            message_json = serde_json::to_string(&message).unwrap_or_default(),
234        )
235    }
236
237    /// Deserialize an `Error` from JSON.
238    pub fn from_json(json: &str) -> std::result::Result<Self, String> {
239        // Try direct variant JSON first
240        let direct: std::result::Result<Error, _> = serde_json::from_str(json);
241        if let Ok(e) = direct {
242            return Ok(e);
243        }
244        // Try envelope with "error" field
245        let v: serde_json::Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
246        let inner = v
247            .get("error")
248            .ok_or_else(|| "missing 'error' field".to_string())?;
249        serde_json::from_value(inner.clone()).map_err(|e| e.to_string())
250    }
251
252    /// Return a Python exception class name that best maps to this error.
253    ///
254    /// Useful when calling PyO3's `PyErr::new::<PyXxx, _>()`.
255    pub fn python_exception_class(&self) -> &'static str {
256        match self {
257            Error::TypeError { .. } | Error::WrongArrayLength { .. } => "TypeError",
258            Error::MissingArgument(_) => "ValueError",
259            Error::InvalidParameter { .. }
260            | Error::InvalidMass(_)
261            | Error::InvalidTimeStep(_)
262            | Error::InvalidDimension(_) => "ValueError",
263            Error::InvalidHandle(_) | Error::BodyNotFound(_) | Error::ColliderNotFound(_) => {
264                "KeyError"
265            }
266            Error::CapacityExceeded { .. } => "MemoryError",
267            Error::SimulationDiverged { .. } | Error::SolverConvergenceFailed { .. } => {
268                "RuntimeError"
269            }
270            Error::Serialization(_) | Error::SnapshotValidation(_) => "ValueError",
271            _ => "RuntimeError",
272        }
273    }
274
275    /// Return a human-readable recovery hint for this error.
276    pub fn recovery_hint(&self) -> String {
277        match self {
278            Error::InvalidTimeStep(_) => {
279                "Use a positive dt such as 1/60 for a 60 Hz simulation.".into()
280            }
281            Error::InvalidMass(_) => "Mass must be strictly positive (e.g. 1.0).".into(),
282            Error::BodyNotFound(h) => format!(
283                "Body handle {} not found. Re-add the body or check it has not been removed.",
284                h
285            ),
286            Error::CapacityExceeded { max } => format!(
287                "World capacity of {} bodies reached. Remove unused bodies first.",
288                max
289            ),
290            Error::SimulationDiverged { step } => format!(
291                "Divergence detected at step {}. Reduce dt or applied forces.",
292                step
293            ),
294            Error::TypeError { expected, got } => {
295                format!("Expected a Python '{}' but received '{}'.", expected, got)
296            }
297            Error::WrongArrayLength { expected, got } => {
298                format!("Array length should be {} but is {}.", expected, got)
299            }
300            _ => String::new(),
301        }
302    }
303}
304
305// ---------------------------------------------------------------------------
306// Tests
307// ---------------------------------------------------------------------------
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    // ---- basic display tests -----------------------------------------------
314
315    #[test]
316    fn test_invalid_handle() {
317        let e = Error::InvalidHandle(5);
318        assert!(e.to_string().contains("5"));
319        assert!(e.is_handle_error());
320    }
321
322    #[test]
323    fn test_body_not_found() {
324        let e = Error::BodyNotFound(10);
325        assert!(e.to_string().contains("10"));
326        assert!(e.is_handle_error());
327    }
328
329    #[test]
330    fn test_collider_not_found() {
331        let e = Error::ColliderNotFound(3);
332        assert!(e.is_handle_error());
333    }
334
335    #[test]
336    fn test_invalid_param() {
337        let e = Error::invalid_param("mass", "must be positive");
338        assert!(e.to_string().contains("mass"));
339        assert!(e.to_string().contains("positive"));
340        assert!(e.is_parameter_error());
341    }
342
343    #[test]
344    fn test_invalid_mass() {
345        let e = Error::InvalidMass(-1.0);
346        assert!(e.is_parameter_error());
347        assert!(e.to_string().contains("-1"));
348    }
349
350    #[test]
351    fn test_invalid_time_step() {
352        let e = Error::InvalidTimeStep(0.0);
353        assert!(e.is_parameter_error());
354    }
355
356    #[test]
357    fn test_invalid_dimension() {
358        let e = Error::InvalidDimension(-0.5);
359        assert!(e.is_parameter_error());
360    }
361
362    #[test]
363    fn test_capacity_exceeded() {
364        let e = Error::CapacityExceeded { max: 1000 };
365        assert!(e.is_capacity_error());
366        assert!(e.to_string().contains("1000"));
367    }
368
369    #[test]
370    fn test_simulation_diverged() {
371        let e = Error::SimulationDiverged { step: 99 };
372        assert!(e.is_stability_error());
373        assert!(e.to_string().contains("99"));
374    }
375
376    #[test]
377    fn test_solver_convergence_failed() {
378        let e = Error::SolverConvergenceFailed { iterations: 50 };
379        assert!(e.is_stability_error());
380        assert!(e.to_string().contains("50"));
381    }
382
383    #[test]
384    fn test_body_sleeping() {
385        let e = Error::BodySleeping(7);
386        assert!(e.to_string().contains("7"));
387    }
388
389    #[test]
390    fn test_serialization_error() {
391        let e = Error::Serialization("unexpected eof".into());
392        assert!(e.is_serialization_error());
393    }
394
395    #[test]
396    fn test_snapshot_validation_error() {
397        let e = Error::SnapshotValidation("missing version".into());
398        assert!(e.is_serialization_error());
399    }
400
401    #[test]
402    fn test_type_error() {
403        let e = Error::type_error("list", "int");
404        assert!(e.is_type_error());
405        assert!(e.to_string().contains("list"));
406        assert!(e.to_string().contains("int"));
407    }
408
409    #[test]
410    fn test_missing_argument() {
411        let e = Error::MissingArgument("mass".into());
412        assert!(e.is_type_error());
413        assert!(e.to_string().contains("mass"));
414    }
415
416    #[test]
417    fn test_wrong_array_length() {
418        let e = Error::wrong_len(3, 2);
419        assert!(e.is_type_error());
420        assert!(e.to_string().contains("3"));
421        assert!(e.to_string().contains("2"));
422    }
423
424    #[test]
425    fn test_general_error() {
426        let e = Error::general("oops");
427        assert!(e.to_string().contains("oops"));
428    }
429
430    // ---- clone / eq -------------------------------------------------------
431
432    #[test]
433    fn test_clone_eq() {
434        let e1 = Error::InvalidHandle(42);
435        let e2 = e1.clone();
436        assert_eq!(e1, e2);
437    }
438
439    // ---- JSON roundtrip ---------------------------------------------------
440
441    #[test]
442    fn test_to_json_contains_type() {
443        let e = Error::InvalidTimeStep(-0.01);
444        let json = e.to_json();
445        assert!(json.contains("InvalidTimeStep"), "json={}", json);
446        assert!(json.contains("message"), "json={}", json);
447    }
448
449    #[test]
450    fn test_from_json_direct() {
451        let original = Error::BodyNotFound(7);
452        let json = serde_json::to_string(&original).unwrap();
453        let recovered = Error::from_json(&json).unwrap();
454        assert_eq!(original, recovered);
455    }
456
457    #[test]
458    fn test_from_json_envelope() {
459        let e = Error::CapacityExceeded { max: 256 };
460        let envelope = e.to_json();
461        let recovered = Error::from_json(&envelope).unwrap();
462        assert_eq!(recovered, e);
463    }
464
465    #[test]
466    fn test_from_json_invalid() {
467        assert!(Error::from_json("{bad json").is_err());
468    }
469
470    // ---- python_exception_class -------------------------------------------
471
472    #[test]
473    fn test_python_exception_class_value_error() {
474        assert_eq!(
475            Error::InvalidTimeStep(0.0).python_exception_class(),
476            "ValueError"
477        );
478        assert_eq!(
479            Error::InvalidMass(-1.0).python_exception_class(),
480            "ValueError"
481        );
482        assert_eq!(
483            Error::invalid_param("x", "y").python_exception_class(),
484            "ValueError"
485        );
486    }
487
488    #[test]
489    fn test_python_exception_class_key_error() {
490        assert_eq!(Error::BodyNotFound(1).python_exception_class(), "KeyError");
491        assert_eq!(Error::InvalidHandle(0).python_exception_class(), "KeyError");
492    }
493
494    #[test]
495    fn test_python_exception_class_type_error() {
496        assert_eq!(
497            Error::type_error("list", "int").python_exception_class(),
498            "TypeError"
499        );
500        assert_eq!(Error::wrong_len(3, 1).python_exception_class(), "TypeError");
501    }
502
503    #[test]
504    fn test_python_exception_class_runtime_error() {
505        assert_eq!(
506            Error::SimulationDiverged { step: 1 }.python_exception_class(),
507            "RuntimeError"
508        );
509        assert_eq!(
510            Error::SolverConvergenceFailed { iterations: 10 }.python_exception_class(),
511            "RuntimeError"
512        );
513    }
514
515    // ---- recovery hints ---------------------------------------------------
516
517    #[test]
518    fn test_recovery_hint_time_step() {
519        let hint = Error::InvalidTimeStep(0.0).recovery_hint();
520        assert!(!hint.is_empty());
521    }
522
523    #[test]
524    fn test_recovery_hint_body_not_found() {
525        let hint = Error::BodyNotFound(42).recovery_hint();
526        assert!(hint.contains("42"));
527    }
528
529    #[test]
530    fn test_recovery_hint_capacity() {
531        let hint = Error::CapacityExceeded { max: 500 }.recovery_hint();
532        assert!(hint.contains("500"));
533    }
534
535    #[test]
536    fn test_recovery_hint_diverged() {
537        let hint = Error::SimulationDiverged { step: 7 }.recovery_hint();
538        assert!(hint.contains("7"));
539    }
540
541    #[test]
542    fn test_recovery_hint_type_error() {
543        let hint = Error::type_error("ndarray", "str").recovery_hint();
544        assert!(hint.contains("ndarray"));
545    }
546
547    #[test]
548    fn test_recovery_hint_wrong_len() {
549        let hint = Error::wrong_len(3, 1).recovery_hint();
550        assert!(hint.contains("3"));
551        assert!(hint.contains("1"));
552    }
553
554    #[test]
555    fn test_recovery_hint_general_empty() {
556        // General errors have no specific hint
557        let hint = Error::general("x").recovery_hint();
558        // May or may not be empty, just ensure it does not panic
559        let _ = hint;
560    }
561}