Skip to main content

hojicha_core/
fallible.rs

1//! Fallible model support for error handling in the update cycle
2//!
3//! This module provides the `FallibleModel` trait which extends the basic `Model`
4//! trait with error handling capabilities. This allows models to handle errors
5//! gracefully without panicking or silently ignoring failures.
6
7use crate::{
8    core::{Cmd, Model},
9    error::{Error, Result},
10    event::Event,
11};
12
13/// A model that can handle errors in its update cycle
14///
15/// This trait extends the basic `Model` trait with fallible update support,
16/// allowing models to return errors from update operations and handle them
17/// appropriately.
18///
19/// # Example
20///
21/// ```ignore
22/// use hojicha::prelude::*;
23/// use hojicha::fallible::FallibleModel;
24///
25/// struct MyApp {
26///     data: Vec<String>,
27///     error_message: Option<String>,
28/// }
29///
30/// impl Model for MyApp {
31///     type Message = Msg;
32///
33///     fn update(&mut self, event: Event<Msg>) -> Cmd<Msg> {
34///         // Delegate to try_update for error handling
35///         match self.try_update(event) {
36///             Ok(cmd) => cmd,
37///             Err(err) => self.handle_error(err),
38///         }
39///     }
40///
41///     fn view(&self) -> String {
42///         // Render UI including any error messages
43///     }
44/// }
45///
46/// impl FallibleModel for MyApp {
47///     fn try_update(&mut self, event: Event<Msg>) -> Result<Cmd<Msg>> {
48///         match event {
49///             Event::User(Msg::LoadData) => {
50///                 // This operation might fail
51///                 let data = load_data_from_file()?;
52///                 self.data = data;
53///                 Ok(Cmd::noop())
54///             }
55///             _ => Ok(Cmd::noop())
56///         }
57///     }
58///
59///     fn handle_error(&mut self, error: Error) -> Cmd<Msg> {
60///         // Store error for display
61///         self.error_message = Some(error.to_string());
62///         // Could also convert to a message
63///         commands::custom(|| Some(Msg::ErrorOccurred(error.to_string())))
64///     }
65/// }
66/// ```
67pub trait FallibleModel: Model {
68    /// Fallible update that can return errors
69    ///
70    /// This method performs the actual update logic and can return errors
71    /// when operations fail. The default implementation delegates to the
72    /// infallible `update` method.
73    fn try_update(&mut self, event: Event<Self::Message>) -> Result<Cmd<Self::Message>> {
74        Ok(self.update(event))
75    }
76
77    /// Handle errors that occur during update
78    ///
79    /// This method is called when `try_update` returns an error. It allows
80    /// the model to handle the error appropriately, such as:
81    /// - Logging the error
82    /// - Storing it for display in the UI
83    /// - Converting it to a message for further processing
84    /// - Attempting recovery
85    ///
86    /// The default implementation logs the error and returns `Cmd::noop()`.
87    fn handle_error(&mut self, error: Error) -> Cmd<Self::Message> {
88        eprintln!("Error in model update: {}", error);
89
90        // Print error chain
91        let mut current_error: &dyn std::error::Error = &error;
92        while let Some(source) = current_error.source() {
93            eprintln!("  Caused by: {}", source);
94            current_error = source;
95        }
96
97        Cmd::noop()
98    }
99
100    /// Handle a panic that occurred during update
101    ///
102    /// This method is called when a panic is caught during the update cycle.
103    /// The default implementation converts it to an error and delegates to
104    /// `handle_error`.
105    fn handle_panic(&mut self, panic_info: String) -> Cmd<Self::Message> {
106        let error = Error::Model(format!("Panic in update: {}", panic_info));
107        self.handle_error(error)
108    }
109}
110
111/// Helper trait to make it easier to use FallibleModel in the Program
112pub trait FallibleModelExt: FallibleModel {
113    /// Perform a fallible update with automatic error handling
114    ///
115    /// This method combines `try_update` and `handle_error` for convenience.
116    fn update_with_error_handling(&mut self, event: Event<Self::Message>) -> Cmd<Self::Message> {
117        match self.try_update(event) {
118            Ok(cmd) => cmd,
119            Err(err) => self.handle_error(err),
120        }
121    }
122
123    /// Perform an update with panic catching
124    ///
125    /// This method catches panics during update and converts them to errors.
126    fn update_with_panic_catching(&mut self, event: Event<Self::Message>) -> Cmd<Self::Message> {
127        use std::panic;
128
129        match panic::catch_unwind(panic::AssertUnwindSafe(|| self.try_update(event))) {
130            Ok(Ok(cmd)) => cmd,
131            Ok(Err(err)) => self.handle_error(err),
132            Err(panic) => {
133                let panic_info = if let Some(s) = panic.downcast_ref::<&str>() {
134                    s.to_string()
135                } else if let Some(s) = panic.downcast_ref::<String>() {
136                    s.clone()
137                } else {
138                    "Unknown panic".to_string()
139                };
140                self.handle_panic(panic_info)
141            }
142        }
143    }
144}
145
146/// Automatically implement FallibleModelExt for all FallibleModel types
147impl<T: FallibleModel> FallibleModelExt for T {}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::commands;
153    use std::sync::{Arc, Mutex};
154
155    #[derive(Clone)]
156    enum TestMsg {
157        Succeed,
158        Fail,
159        Panic,
160        #[allow(dead_code)]
161        ErrorOccurred(String),
162    }
163
164    struct TestModel {
165        success_count: usize,
166        error_count: usize,
167        panic_count: usize,
168        last_error: Option<String>,
169        errors: Arc<Mutex<Vec<String>>>,
170    }
171
172    impl Model for TestModel {
173        type Message = TestMsg;
174
175        fn update(&mut self, event: Event<TestMsg>) -> Cmd<TestMsg> {
176            self.update_with_error_handling(event)
177        }
178
179        fn view(&self) -> String {
180            format!(
181                "Success: {} | Errors: {}",
182                self.success_count, self.error_count
183            )
184        }
185    }
186
187    impl FallibleModel for TestModel {
188        fn try_update(&mut self, event: Event<TestMsg>) -> Result<Cmd<TestMsg>> {
189            match event {
190                Event::User(TestMsg::Succeed) => {
191                    self.success_count += 1;
192                    Ok(Cmd::noop())
193                }
194                Event::User(TestMsg::Fail) => Err(Error::Model("Intentional failure".to_string())),
195                Event::User(TestMsg::Panic) => {
196                    panic!("Intentional panic!");
197                }
198                _ => Ok(Cmd::noop()),
199            }
200        }
201
202        fn handle_error(&mut self, error: Error) -> Cmd<TestMsg> {
203            self.error_count += 1;
204            let error_str = error.to_string();
205            self.last_error = Some(error_str.clone());
206            self.errors.lock().unwrap().push(error_str.clone());
207            commands::custom(move || Some(TestMsg::ErrorOccurred(error_str)))
208        }
209
210        fn handle_panic(&mut self, panic_info: String) -> Cmd<TestMsg> {
211            self.panic_count += 1;
212            self.last_error = Some(panic_info.clone());
213            self.errors.lock().unwrap().push(panic_info.clone());
214            commands::custom(|| Some(TestMsg::ErrorOccurred(panic_info)))
215        }
216    }
217
218    #[test]
219    fn test_fallible_model_success() {
220        let mut model = TestModel {
221            success_count: 0,
222            error_count: 0,
223            panic_count: 0,
224            last_error: None,
225            errors: Arc::new(Mutex::new(Vec::new())),
226        };
227
228        let _cmd = model.update(Event::User(TestMsg::Succeed));
229        // Command should be a no-op (Cmd::noop())
230        assert_eq!(model.success_count, 1);
231        assert_eq!(model.error_count, 0);
232    }
233
234    #[test]
235    fn test_fallible_model_error() {
236        let mut model = TestModel {
237            success_count: 0,
238            error_count: 0,
239            panic_count: 0,
240            last_error: None,
241            errors: Arc::new(Mutex::new(Vec::new())),
242        };
243
244        let _cmd = model.update(Event::User(TestMsg::Fail));
245        // Should return an error message command
246        assert_eq!(model.error_count, 1);
247        assert!(model.last_error.is_some());
248        assert!(model.last_error.unwrap().contains("Intentional failure"));
249    }
250
251    #[test]
252    fn test_fallible_model_panic_catching() {
253        let mut model = TestModel {
254            success_count: 0,
255            error_count: 0,
256            panic_count: 0,
257            last_error: None,
258            errors: Arc::new(Mutex::new(Vec::new())),
259        };
260
261        // Use panic catching version
262        let _cmd = model.update_with_panic_catching(Event::User(TestMsg::Panic));
263        // Should catch the panic and handle it
264        assert_eq!(model.panic_count, 1);
265        assert!(model.last_error.is_some());
266    }
267
268    #[test]
269    fn test_default_error_handling() {
270        struct DefaultModel;
271
272        impl Model for DefaultModel {
273            type Message = ();
274            fn update(&mut self, _: Event<()>) -> Cmd<()> {
275                Cmd::noop()
276            }
277            fn view(&self) -> String {
278                "DefaultModel".to_string()
279            }
280        }
281
282        impl FallibleModel for DefaultModel {}
283
284        let mut model = DefaultModel;
285        let _cmd = model.handle_error(Error::Model("test".to_string()));
286        // Default implementation returns Cmd::noop()
287    }
288}