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}