Skip to main content

tui_dispatch_core/
resource.rs

1//! DataResource: typed async data lifecycle
2//!
3//! A type representing the lifecycle of async-loaded data. Use this instead of
4//! scattering `loading: bool` and `error: Option<String>` across your state.
5//!
6//! # Example
7//!
8//! ```
9//! use tui_dispatch_core::DataResource;
10//!
11//! // In state
12//! struct AppState {
13//!     weather: DataResource<WeatherData>,
14//! }
15//!
16//! # #[derive(Clone)]
17//! # struct WeatherData;
18//!
19//! // In reducer
20//! # let mut state = AppState { weather: DataResource::Empty };
21//! # enum Action { FetchWeather, WeatherDidLoad(WeatherData), WeatherDidFail(String) }
22//! # let action = Action::FetchWeather;
23//! match action {
24//!     Action::FetchWeather => {
25//!         state.weather = DataResource::Loading;
26//!         // return effect to fetch
27//!     }
28//!     Action::WeatherDidLoad(data) => {
29//!         state.weather = DataResource::Loaded(data);
30//!     }
31//!     Action::WeatherDidFail(err) => {
32//!         state.weather = DataResource::Failed(err);
33//!     }
34//! }
35//!
36//! // In render
37//! match &state.weather {
38//!     DataResource::Empty => { /* show placeholder */ }
39//!     DataResource::Loading => { /* show spinner */ }
40//!     DataResource::Loaded(data) => { /* render data */ }
41//!     DataResource::Failed(err) => { /* show error */ }
42//! }
43//! ```
44
45/// Represents the lifecycle of async-loaded data.
46///
47/// This type captures the four states data can be in:
48/// - `Empty`: No data requested yet
49/// - `Loading`: Data is being fetched
50/// - `Loaded(T)`: Data successfully loaded
51/// - `Failed(String)`: Loading failed with error message
52#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
53#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
54#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
55pub enum DataResource<T> {
56    /// No data requested yet
57    #[default]
58    Empty,
59    /// Data is being fetched
60    Loading,
61    /// Data successfully loaded
62    Loaded(T),
63    /// Loading failed with error message
64    Failed(String),
65}
66
67impl<T> DataResource<T> {
68    /// Returns `true` if this is `Empty`.
69    pub fn is_empty(&self) -> bool {
70        matches!(self, Self::Empty)
71    }
72
73    /// Returns `true` if this is `Loading`.
74    pub fn is_loading(&self) -> bool {
75        matches!(self, Self::Loading)
76    }
77
78    /// Returns `true` if this is `Loaded(_)`.
79    pub fn is_loaded(&self) -> bool {
80        matches!(self, Self::Loaded(_))
81    }
82
83    /// Returns `true` if this is `Failed(_)`.
84    pub fn is_failed(&self) -> bool {
85        matches!(self, Self::Failed(_))
86    }
87
88    /// Returns a reference to the loaded data, or `None` if not loaded.
89    pub fn data(&self) -> Option<&T> {
90        match self {
91            Self::Loaded(t) => Some(t),
92            _ => None,
93        }
94    }
95
96    /// Returns a mutable reference to the loaded data, or `None` if not loaded.
97    pub fn data_mut(&mut self) -> Option<&mut T> {
98        match self {
99            Self::Loaded(t) => Some(t),
100            _ => None,
101        }
102    }
103
104    /// Returns the error message if failed, or `None` otherwise.
105    pub fn error(&self) -> Option<&str> {
106        match self {
107            Self::Failed(e) => Some(e),
108            _ => None,
109        }
110    }
111
112    /// Maps the loaded value using the provided function.
113    ///
114    /// If `Loaded(t)`, returns `Loaded(f(t))`. Otherwise returns the same state.
115    pub fn map<U>(self, f: impl FnOnce(T) -> U) -> DataResource<U> {
116        match self {
117            Self::Empty => DataResource::Empty,
118            Self::Loading => DataResource::Loading,
119            Self::Loaded(t) => DataResource::Loaded(f(t)),
120            Self::Failed(e) => DataResource::Failed(e),
121        }
122    }
123
124    /// Maps a reference to the loaded value.
125    pub fn map_ref<U>(&self, f: impl FnOnce(&T) -> U) -> DataResource<U> {
126        match self {
127            Self::Empty => DataResource::Empty,
128            Self::Loading => DataResource::Loading,
129            Self::Loaded(t) => DataResource::Loaded(f(t)),
130            Self::Failed(e) => DataResource::Failed(e.clone()),
131        }
132    }
133
134    /// Applies a function that returns a `DataResource` to the loaded value.
135    ///
136    /// Useful for chaining dependent async operations.
137    pub fn and_then<U>(self, f: impl FnOnce(T) -> DataResource<U>) -> DataResource<U> {
138        match self {
139            Self::Empty => DataResource::Empty,
140            Self::Loading => DataResource::Loading,
141            Self::Loaded(t) => f(t),
142            Self::Failed(e) => DataResource::Failed(e),
143        }
144    }
145
146    /// Returns the loaded value or a default.
147    pub fn unwrap_or(self, default: T) -> T {
148        match self {
149            Self::Loaded(t) => t,
150            _ => default,
151        }
152    }
153
154    /// Returns the loaded value or computes a default.
155    pub fn unwrap_or_else(self, f: impl FnOnce() -> T) -> T {
156        match self {
157            Self::Loaded(t) => t,
158            _ => f(),
159        }
160    }
161
162    /// Converts from `&DataResource<T>` to `DataResource<&T>`.
163    pub fn as_ref(&self) -> DataResource<&T> {
164        match self {
165            Self::Empty => DataResource::Empty,
166            Self::Loading => DataResource::Loading,
167            Self::Loaded(t) => DataResource::Loaded(t),
168            Self::Failed(e) => DataResource::Failed(e.clone()),
169        }
170    }
171
172    /// Returns `true` if there's either data or an error (not empty or loading).
173    pub fn is_settled(&self) -> bool {
174        matches!(self, Self::Loaded(_) | Self::Failed(_))
175    }
176
177    /// Returns `true` if there's no data yet (empty or loading).
178    pub fn is_pending(&self) -> bool {
179        matches!(self, Self::Empty | Self::Loading)
180    }
181
182    /// Transitions to `Loading` state. Returns `true` if state actually changed.
183    ///
184    /// Useful in reducers to start a fetch:
185    /// ```
186    /// # use tui_dispatch_core::DataResource;
187    /// # let mut resource: DataResource<String> = DataResource::Empty;
188    /// if resource.start_loading() {
189    ///     // dispatch effect to fetch
190    /// }
191    /// ```
192    pub fn start_loading(&mut self) -> bool
193    where
194        T: Clone,
195    {
196        if self.is_loading() {
197            false
198        } else {
199            *self = Self::Loading;
200            true
201        }
202    }
203}
204
205impl<T: Clone> DataResource<T> {
206    /// Returns a clone of the loaded data, or `None` if not loaded.
207    pub fn cloned(&self) -> Option<T> {
208        self.data().cloned()
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn test_default_is_empty() {
218        let resource: DataResource<String> = DataResource::default();
219        assert!(resource.is_empty());
220    }
221
222    #[test]
223    fn test_state_checks() {
224        let empty: DataResource<i32> = DataResource::Empty;
225        let loading: DataResource<i32> = DataResource::Loading;
226        let loaded: DataResource<i32> = DataResource::Loaded(42);
227        let failed: DataResource<i32> = DataResource::Failed("oops".to_string());
228
229        assert!(empty.is_empty());
230        assert!(!empty.is_loading());
231        assert!(empty.is_pending());
232        assert!(!empty.is_settled());
233
234        assert!(!loading.is_empty());
235        assert!(loading.is_loading());
236        assert!(loading.is_pending());
237        assert!(!loading.is_settled());
238
239        assert!(!loaded.is_empty());
240        assert!(!loaded.is_loading());
241        assert!(loaded.is_loaded());
242        assert!(!loaded.is_pending());
243        assert!(loaded.is_settled());
244
245        assert!(!failed.is_empty());
246        assert!(failed.is_failed());
247        assert!(!failed.is_pending());
248        assert!(failed.is_settled());
249    }
250
251    #[test]
252    fn test_data_accessors() {
253        let loaded: DataResource<i32> = DataResource::Loaded(42);
254        let failed: DataResource<i32> = DataResource::Failed("error".to_string());
255
256        assert_eq!(loaded.data(), Some(&42));
257        assert_eq!(failed.data(), None);
258        assert_eq!(failed.error(), Some("error"));
259        assert_eq!(loaded.error(), None);
260    }
261
262    #[test]
263    fn test_map() {
264        let loaded: DataResource<i32> = DataResource::Loaded(21);
265        let doubled = loaded.map(|x| x * 2);
266        assert_eq!(doubled.data(), Some(&42));
267
268        let loading: DataResource<i32> = DataResource::Loading;
269        let still_loading: DataResource<i32> = loading.map(|x| x * 2);
270        assert!(still_loading.is_loading());
271    }
272
273    #[test]
274    fn test_and_then() {
275        let loaded: DataResource<i32> = DataResource::Loaded(42);
276        let chained = loaded.and_then(|x| DataResource::Loaded(x.to_string()));
277        assert_eq!(chained.data(), Some(&"42".to_string()));
278
279        let failed: DataResource<i32> = DataResource::Failed("err".to_string());
280        let still_failed: DataResource<String> =
281            failed.and_then(|x| DataResource::Loaded(x.to_string()));
282        assert!(still_failed.is_failed());
283    }
284
285    #[test]
286    fn test_unwrap_or() {
287        let loaded: DataResource<i32> = DataResource::Loaded(42);
288        let empty: DataResource<i32> = DataResource::Empty;
289
290        assert_eq!(loaded.unwrap_or(0), 42);
291        assert_eq!(empty.unwrap_or(0), 0);
292    }
293
294    #[test]
295    fn test_start_loading() {
296        let mut resource: DataResource<i32> = DataResource::Empty;
297        assert!(resource.start_loading());
298        assert!(resource.is_loading());
299
300        // Already loading, should return false
301        assert!(!resource.start_loading());
302        assert!(resource.is_loading());
303    }
304
305    #[test]
306    #[cfg(feature = "serde")]
307    fn test_serialize_deserialize() {
308        let loaded: DataResource<i32> = DataResource::Loaded(42);
309        let json = serde_json::to_string(&loaded).unwrap();
310        let restored: DataResource<i32> = serde_json::from_str(&json).unwrap();
311        assert_eq!(loaded, restored);
312
313        let failed: DataResource<i32> = DataResource::Failed("oops".to_string());
314        let json = serde_json::to_string(&failed).unwrap();
315        let restored: DataResource<i32> = serde_json::from_str(&json).unwrap();
316        assert_eq!(failed, restored);
317    }
318}