Skip to main content

tui_dispatch_core/
effect.rs

1//! Effect-based state management
2//!
3//! This module provides an effect-aware store that allows reducers to emit
4//! side effects alongside state changes. Effects are declarative descriptions
5//! of work to be done, not the work itself.
6//!
7//! # Overview
8//!
9//! The traditional reducer returns `bool` (state changed or not):
10//! ```text
11//! fn reducer(state: &mut S, action: A) -> bool
12//! ```
13//!
14//! An effect-aware reducer returns both change status and effects:
15//! ```text
16//! fn reducer(state: &mut S, action: A) -> ReducerResult<E>
17//! ```
18//!
19//! # Example
20//!
21//! ```
22//! use tui_dispatch_core::{Action, ReducerResult, EffectStore};
23//!
24//! // Define your effects
25//! enum Effect {
26//!     FetchData { url: String },
27//!     CopyToClipboard(String),
28//! }
29//!
30//! // Define state and actions
31//! struct AppState { loading: bool, data: Option<String> }
32//!
33//! #[derive(Clone, Debug)]
34//! enum AppAction { LoadData, DidLoadData(String) }
35//!
36//! impl Action for AppAction {
37//!     fn name(&self) -> &'static str {
38//!         match self {
39//!             AppAction::LoadData => "LoadData",
40//!             AppAction::DidLoadData(_) => "DidLoadData",
41//!         }
42//!     }
43//! }
44//!
45//! // Reducer emits effects
46//! fn reducer(state: &mut AppState, action: AppAction) -> ReducerResult<Effect> {
47//!     match action {
48//!         AppAction::LoadData => {
49//!             state.loading = true;
50//!             ReducerResult::changed_with(
51//!                 Effect::FetchData { url: "https://api.example.com".into() }
52//!             )
53//!         }
54//!         AppAction::DidLoadData(data) => {
55//!             state.loading = false;
56//!             state.data = Some(data);
57//!             ReducerResult::changed()
58//!         }
59//!     }
60//! }
61//!
62//! // Main loop handles effects
63//! let mut store = EffectStore::new(
64//!     AppState { loading: false, data: None },
65//!     reducer,
66//! );
67//! let result = store.dispatch(AppAction::LoadData);
68//! assert!(result.changed);
69//!
70//! for effect in result.effects {
71//!     match effect {
72//!         Effect::FetchData { url } => { /* spawn async task */ }
73//!         _ => {}
74//!     }
75//! }
76//! ```
77
78use std::marker::PhantomData;
79
80use crate::action::Action;
81use crate::store::{
82    debug_assert_valid_dispatch_limits, run_iterative_middleware_dispatch, DispatchError,
83    DispatchLimits, Middleware, MiddlewareDispatchDriver,
84};
85
86/// Result of dispatching an action to an effect-aware store.
87///
88/// Contains both the state change indicator and any effects to be processed.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct ReducerResult<E> {
91    /// Whether the state was modified by this action.
92    pub changed: bool,
93    /// Effects to be processed after dispatch.
94    pub effects: Vec<E>,
95}
96
97impl<E> Default for ReducerResult<E> {
98    fn default() -> Self {
99        Self::unchanged()
100    }
101}
102
103impl<E> ReducerResult<E> {
104    /// Create a result indicating no state change and no effects.
105    #[inline]
106    pub fn unchanged() -> Self {
107        Self {
108            changed: false,
109            effects: vec![],
110        }
111    }
112
113    /// Create a result indicating state changed but no effects.
114    #[inline]
115    pub fn changed() -> Self {
116        Self {
117            changed: true,
118            effects: vec![],
119        }
120    }
121
122    /// Create a result with a single effect but no state change.
123    #[inline]
124    pub fn effect(effect: E) -> Self {
125        Self {
126            changed: false,
127            effects: vec![effect],
128        }
129    }
130
131    /// Create a result with multiple effects but no state change.
132    #[inline]
133    pub fn effects(effects: Vec<E>) -> Self {
134        Self {
135            changed: false,
136            effects,
137        }
138    }
139
140    /// Create a result indicating state changed with a single effect.
141    #[inline]
142    pub fn changed_with(effect: E) -> Self {
143        Self {
144            changed: true,
145            effects: vec![effect],
146        }
147    }
148
149    /// Create a result indicating state changed with multiple effects.
150    #[inline]
151    pub fn changed_with_many(effects: Vec<E>) -> Self {
152        Self {
153            changed: true,
154            effects,
155        }
156    }
157
158    /// Add an effect to this result.
159    #[inline]
160    pub fn with(mut self, effect: E) -> Self {
161        self.effects.push(effect);
162        self
163    }
164
165    /// Set the changed flag to true.
166    #[inline]
167    pub fn mark_changed(mut self) -> Self {
168        self.changed = true;
169        self
170    }
171
172    /// Returns true if there are any effects to process.
173    #[inline]
174    pub fn has_effects(&self) -> bool {
175        !self.effects.is_empty()
176    }
177}
178
179/// A reducer function that can emit effects.
180///
181/// Takes mutable state and an action, returns whether state changed
182/// and any effects to process.
183pub type EffectReducer<S, A, E> = fn(&mut S, A) -> ReducerResult<E>;
184
185/// A store that supports effect-emitting reducers.
186///
187/// Similar to [`Store`](crate::Store), but the reducer returns
188/// [`ReducerResult<E>`] instead of `bool`, allowing it to declare
189/// side effects alongside state changes.
190///
191/// # Example
192///
193/// ```
194/// use tui_dispatch_core::{Action, ReducerResult, EffectStore};
195///
196/// #[derive(Clone, Debug)]
197/// enum MyAction { Increment }
198///
199/// impl Action for MyAction {
200///     fn name(&self) -> &'static str { "Increment" }
201/// }
202///
203/// enum Effect { Log(String) }
204/// struct State { count: i32 }
205///
206/// fn reducer(state: &mut State, action: MyAction) -> ReducerResult<Effect> {
207///     match action {
208///         MyAction::Increment => {
209///             state.count += 1;
210///             ReducerResult::changed_with(Effect::Log(format!("count is {}", state.count)))
211///         }
212///     }
213/// }
214///
215/// let mut store = EffectStore::new(State { count: 0 }, reducer);
216/// let result = store.dispatch(MyAction::Increment);
217/// assert!(result.changed);
218/// assert_eq!(result.effects.len(), 1);
219/// ```
220pub struct EffectStore<S, A, E> {
221    state: S,
222    reducer: EffectReducer<S, A, E>,
223    _marker: PhantomData<(A, E)>,
224}
225
226impl<S, A, E> EffectStore<S, A, E>
227where
228    A: Action,
229{
230    /// Create a new effect store with the given initial state and reducer.
231    pub fn new(state: S, reducer: EffectReducer<S, A, E>) -> Self {
232        Self {
233            state,
234            reducer,
235            _marker: PhantomData,
236        }
237    }
238
239    /// Get a reference to the current state.
240    #[inline]
241    pub fn state(&self) -> &S {
242        &self.state
243    }
244
245    /// Get a mutable reference to the state.
246    ///
247    /// Use sparingly - prefer dispatching actions for state changes.
248    /// This is mainly useful for initialization.
249    #[inline]
250    pub fn state_mut(&mut self) -> &mut S {
251        &mut self.state
252    }
253
254    /// Dispatch an action to the store.
255    ///
256    /// The reducer is called with the current state and action,
257    /// returning whether state changed and any effects to process.
258    #[inline]
259    pub fn dispatch(&mut self, action: A) -> ReducerResult<E> {
260        (self.reducer)(&mut self.state, action)
261    }
262}
263
264/// An effect store with middleware support.
265///
266/// Wraps an [`EffectStore`] and calls middleware hooks before and after
267/// each dispatch. The middleware receives action references and the
268/// state change indicator, but not the effects.
269///
270/// # Example
271///
272/// ```ignore
273/// use tui_dispatch::{ReducerResult, EffectStoreWithMiddleware};
274/// use tui_dispatch::debug::ActionLoggerMiddleware;
275///
276/// let middleware = ActionLoggerMiddleware::with_default_log();
277/// let mut store = EffectStoreWithMiddleware::new(
278///     State::default(),
279///     reducer,
280///     middleware,
281/// );
282///
283/// let result = store.dispatch(Action::Something);
284/// // Middleware logged the action
285/// // result.effects contains any effects to process
286/// ```
287pub struct EffectStoreWithMiddleware<S, A, E, M>
288where
289    A: Action,
290    M: Middleware<S, A>,
291{
292    store: EffectStore<S, A, E>,
293    middleware: M,
294    dispatch_limits: DispatchLimits,
295}
296
297impl<S, A, E, M> EffectStoreWithMiddleware<S, A, E, M>
298where
299    A: Action,
300    M: Middleware<S, A>,
301{
302    /// Create a new effect store with middleware.
303    pub fn new(state: S, reducer: EffectReducer<S, A, E>, middleware: M) -> Self {
304        Self {
305            store: EffectStore::new(state, reducer),
306            middleware,
307            dispatch_limits: DispatchLimits::default(),
308        }
309    }
310
311    /// Override middleware dispatch limits.
312    pub fn with_dispatch_limits(mut self, limits: DispatchLimits) -> Self {
313        debug_assert_valid_dispatch_limits(limits);
314        self.dispatch_limits = limits;
315        self
316    }
317
318    /// Current middleware dispatch limits.
319    pub fn dispatch_limits(&self) -> DispatchLimits {
320        self.dispatch_limits
321    }
322
323    /// Get a reference to the current state.
324    #[inline]
325    pub fn state(&self) -> &S {
326        self.store.state()
327    }
328
329    /// Get a mutable reference to the state.
330    #[inline]
331    pub fn state_mut(&mut self) -> &mut S {
332        self.store.state_mut()
333    }
334
335    /// Get a reference to the middleware.
336    #[inline]
337    pub fn middleware(&self) -> &M {
338        &self.middleware
339    }
340
341    /// Get a mutable reference to the middleware.
342    #[inline]
343    pub fn middleware_mut(&mut self) -> &mut M {
344        &mut self.middleware
345    }
346
347    /// Dispatch an action through middleware and store.
348    ///
349    /// This wraps [`Self::try_dispatch`] and panics if dispatch limits are exceeded.
350    /// Use `try_dispatch` to handle overflow as a typed error.
351    pub fn dispatch(&mut self, action: A) -> ReducerResult<E> {
352        self.try_dispatch(action)
353            .unwrap_or_else(|error| panic!("middleware dispatch failed: {error}"))
354    }
355
356    /// Dispatch an action through middleware and store.
357    ///
358    /// The action passes through `middleware.before()` (which can cancel it),
359    /// then the reducer, then `middleware.after()` (which can inject follow-up actions).
360    /// Injected actions go through the full pipeline in depth-first order; their effects
361    /// are merged into the returned result.
362    ///
363    /// Returns [`DispatchError`] if configured depth or action budget limits are exceeded.
364    ///
365    /// This operation is not transactional: if overflow happens in an injected chain,
366    /// earlier actions in the chain may have already mutated state.
367    ///
368    /// Action budget accounting includes attempted dispatches that are later cancelled by
369    /// `Middleware::before`.
370    pub fn try_dispatch(&mut self, action: A) -> Result<ReducerResult<E>, DispatchError> {
371        let mut driver = EffectDispatchDriver {
372            store: &mut self.store,
373            middleware: &mut self.middleware,
374        };
375        run_iterative_middleware_dispatch(self.dispatch_limits, action, &mut driver)
376    }
377}
378
379struct EffectDispatchDriver<'a, S, A, E, M>
380where
381    A: Action,
382    M: Middleware<S, A>,
383{
384    store: &'a mut EffectStore<S, A, E>,
385    middleware: &'a mut M,
386}
387
388impl<S, A, E, M> MiddlewareDispatchDriver<A> for EffectDispatchDriver<'_, S, A, E, M>
389where
390    A: Action,
391    M: Middleware<S, A>,
392{
393    type Output = ReducerResult<E>;
394
395    fn before(&mut self, action: &A) -> bool {
396        self.middleware.before(action, &self.store.state)
397    }
398
399    fn reduce(&mut self, action: A) -> Self::Output {
400        self.store.dispatch(action)
401    }
402
403    fn cancelled_output(&mut self) -> Self::Output {
404        ReducerResult::unchanged()
405    }
406
407    fn after(&mut self, action: &A, result: &Self::Output) -> Vec<A> {
408        self.middleware
409            .after(action, result.changed, &self.store.state)
410    }
411
412    fn merge_child(&mut self, parent: &mut Self::Output, child: Self::Output) {
413        parent.changed |= child.changed;
414        parent.effects.extend(child.effects);
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[derive(Clone, Debug)]
423    enum TestAction {
424        Increment,
425        Decrement,
426        NoOp,
427        TriggerEffect,
428    }
429
430    impl Action for TestAction {
431        fn name(&self) -> &'static str {
432            match self {
433                TestAction::Increment => "Increment",
434                TestAction::Decrement => "Decrement",
435                TestAction::NoOp => "NoOp",
436                TestAction::TriggerEffect => "TriggerEffect",
437            }
438        }
439    }
440
441    #[derive(Debug, Clone, PartialEq)]
442    enum TestEffect {
443        Log(String),
444        Save,
445    }
446
447    #[derive(Default)]
448    struct TestState {
449        count: i32,
450    }
451
452    fn test_reducer(state: &mut TestState, action: TestAction) -> ReducerResult<TestEffect> {
453        match action {
454            TestAction::Increment => {
455                state.count += 1;
456                ReducerResult::changed()
457            }
458            TestAction::Decrement => {
459                state.count -= 1;
460                ReducerResult::changed_with(TestEffect::Log(format!("count: {}", state.count)))
461            }
462            TestAction::NoOp => ReducerResult::unchanged(),
463            TestAction::TriggerEffect => {
464                ReducerResult::effects(vec![TestEffect::Log("triggered".into()), TestEffect::Save])
465            }
466        }
467    }
468
469    #[test]
470    fn test_dispatch_result_builders() {
471        let r: ReducerResult<TestEffect> = ReducerResult::unchanged();
472        assert!(!r.changed);
473        assert!(r.effects.is_empty());
474
475        let r: ReducerResult<TestEffect> = ReducerResult::changed();
476        assert!(r.changed);
477        assert!(r.effects.is_empty());
478
479        let r = ReducerResult::effect(TestEffect::Save);
480        assert!(!r.changed);
481        assert_eq!(r.effects, vec![TestEffect::Save]);
482
483        let r = ReducerResult::changed_with(TestEffect::Save);
484        assert!(r.changed);
485        assert_eq!(r.effects, vec![TestEffect::Save]);
486
487        let r =
488            ReducerResult::changed_with_many(vec![TestEffect::Save, TestEffect::Log("x".into())]);
489        assert!(r.changed);
490        assert_eq!(r.effects.len(), 2);
491    }
492
493    #[test]
494    fn test_dispatch_result_chaining() {
495        let r: ReducerResult<TestEffect> = ReducerResult::unchanged()
496            .with(TestEffect::Save)
497            .mark_changed();
498        assert!(r.changed);
499        assert_eq!(r.effects, vec![TestEffect::Save]);
500    }
501
502    #[test]
503    fn test_effect_store_basic() {
504        let mut store = EffectStore::new(TestState::default(), test_reducer);
505
506        assert_eq!(store.state().count, 0);
507
508        let result = store.dispatch(TestAction::Increment);
509        assert!(result.changed);
510        assert!(result.effects.is_empty());
511        assert_eq!(store.state().count, 1);
512
513        let result = store.dispatch(TestAction::NoOp);
514        assert!(!result.changed);
515        assert_eq!(store.state().count, 1);
516    }
517
518    #[test]
519    fn test_effect_store_with_effects() {
520        let mut store = EffectStore::new(TestState::default(), test_reducer);
521
522        let result = store.dispatch(TestAction::Decrement);
523        assert!(result.changed);
524        assert_eq!(result.effects.len(), 1);
525        assert!(matches!(&result.effects[0], TestEffect::Log(s) if s == "count: -1"));
526
527        let result = store.dispatch(TestAction::TriggerEffect);
528        assert!(!result.changed);
529        assert_eq!(result.effects.len(), 2);
530    }
531
532    #[test]
533    fn test_effect_store_state_mut() {
534        let mut store = EffectStore::new(TestState::default(), test_reducer);
535        store.state_mut().count = 100;
536        assert_eq!(store.state().count, 100);
537    }
538
539    #[test]
540    fn test_has_effects() {
541        let r: ReducerResult<TestEffect> = ReducerResult::unchanged();
542        assert!(!r.has_effects());
543
544        let r = ReducerResult::effect(TestEffect::Save);
545        assert!(r.has_effects());
546    }
547
548    struct SelfInjectingMiddleware;
549
550    impl Middleware<TestState, TestAction> for SelfInjectingMiddleware {
551        fn before(&mut self, _action: &TestAction, _state: &TestState) -> bool {
552            true
553        }
554
555        fn after(
556            &mut self,
557            action: &TestAction,
558            _state_changed: bool,
559            _state: &TestState,
560        ) -> Vec<TestAction> {
561            vec![action.clone()]
562        }
563    }
564
565    #[test]
566    fn test_effect_try_dispatch_depth_exceeded() {
567        let mut store = EffectStoreWithMiddleware::new(
568            TestState::default(),
569            test_reducer,
570            SelfInjectingMiddleware,
571        )
572        .with_dispatch_limits(DispatchLimits {
573            max_depth: 2,
574            max_actions: 100,
575        });
576
577        let err = store.try_dispatch(TestAction::Increment).unwrap_err();
578        assert_eq!(
579            err,
580            DispatchError::DepthExceeded {
581                max_depth: 2,
582                action: "Increment",
583            }
584        );
585        assert_eq!(store.state().count, 2);
586    }
587
588    #[test]
589    fn test_effect_try_dispatch_action_budget_exceeded() {
590        let mut store = EffectStoreWithMiddleware::new(
591            TestState::default(),
592            test_reducer,
593            SelfInjectingMiddleware,
594        )
595        .with_dispatch_limits(DispatchLimits {
596            max_depth: 32,
597            max_actions: 2,
598        });
599
600        let err = store.try_dispatch(TestAction::Increment).unwrap_err();
601        assert_eq!(
602            err,
603            DispatchError::ActionBudgetExceeded {
604                max_actions: 2,
605                processed: 2,
606                action: "Increment",
607            }
608        );
609        assert_eq!(store.state().count, 2);
610    }
611
612    struct FiniteCascadeMiddleware {
613        target: i32,
614    }
615
616    impl Middleware<TestState, TestAction> for FiniteCascadeMiddleware {
617        fn before(&mut self, _action: &TestAction, _state: &TestState) -> bool {
618            true
619        }
620
621        fn after(
622            &mut self,
623            action: &TestAction,
624            _state_changed: bool,
625            state: &TestState,
626        ) -> Vec<TestAction> {
627            if matches!(action, TestAction::Increment) && state.count < self.target {
628                vec![TestAction::Increment]
629            } else {
630                vec![]
631            }
632        }
633    }
634
635    #[test]
636    fn test_effect_try_dispatch_deep_finite_chain_succeeds() {
637        let target = 512usize;
638        let mut store = EffectStoreWithMiddleware::new(
639            TestState::default(),
640            test_reducer,
641            FiniteCascadeMiddleware {
642                target: target as i32,
643            },
644        )
645        .with_dispatch_limits(DispatchLimits {
646            max_depth: target + 1,
647            max_actions: target + 1,
648        });
649
650        let result = store.try_dispatch(TestAction::Increment).unwrap();
651        assert!(result.changed);
652        assert!(result.effects.is_empty());
653        assert_eq!(store.state().count, target as i32);
654    }
655
656    #[derive(Clone, Debug)]
657    enum OrderingAction {
658        Root,
659        Left,
660        Right,
661        Leaf,
662    }
663
664    impl Action for OrderingAction {
665        fn name(&self) -> &'static str {
666            match self {
667                OrderingAction::Root => "Root",
668                OrderingAction::Left => "Left",
669                OrderingAction::Right => "Right",
670                OrderingAction::Leaf => "Leaf",
671            }
672        }
673    }
674
675    #[derive(Debug, Clone, PartialEq, Eq)]
676    enum OrderingEffect {
677        Seen(&'static str),
678    }
679
680    #[derive(Default)]
681    struct OrderingState {
682        actions_seen: usize,
683    }
684
685    fn ordering_reducer(
686        state: &mut OrderingState,
687        action: OrderingAction,
688    ) -> ReducerResult<OrderingEffect> {
689        state.actions_seen += 1;
690        ReducerResult::changed_with(OrderingEffect::Seen(action.name()))
691    }
692
693    struct OrderingMiddleware;
694
695    impl Middleware<OrderingState, OrderingAction> for OrderingMiddleware {
696        fn before(&mut self, _action: &OrderingAction, _state: &OrderingState) -> bool {
697            true
698        }
699
700        fn after(
701            &mut self,
702            action: &OrderingAction,
703            _state_changed: bool,
704            _state: &OrderingState,
705        ) -> Vec<OrderingAction> {
706            match action {
707                OrderingAction::Root => vec![OrderingAction::Left, OrderingAction::Right],
708                OrderingAction::Left => vec![OrderingAction::Leaf],
709                OrderingAction::Right | OrderingAction::Leaf => vec![],
710            }
711        }
712    }
713
714    #[test]
715    fn test_effect_try_dispatch_merges_effects_in_depth_first_order() {
716        let mut store = EffectStoreWithMiddleware::new(
717            OrderingState::default(),
718            ordering_reducer,
719            OrderingMiddleware,
720        )
721        .with_dispatch_limits(DispatchLimits {
722            max_depth: 8,
723            max_actions: 8,
724        });
725
726        let result = store.try_dispatch(OrderingAction::Root).unwrap();
727        assert!(result.changed);
728        assert_eq!(
729            result.effects,
730            vec![
731                OrderingEffect::Seen("Root"),
732                OrderingEffect::Seen("Left"),
733                OrderingEffect::Seen("Leaf"),
734                OrderingEffect::Seen("Right"),
735            ]
736        );
737        assert_eq!(store.state().actions_seen, 4);
738    }
739}