Skip to main content

tui_dispatch_core/
store.rs

1//! Centralized state store with reducer pattern
2
3use crate::Action;
4use std::marker::PhantomData;
5
6/// A reducer function that handles actions and mutates state
7///
8/// Returns `true` if the state changed and a re-render is needed.
9pub type Reducer<S, A> = fn(&mut S, A) -> bool;
10
11/// Compose a reducer by routing actions to focused handlers.
12///
13/// # When to Use
14///
15/// For most reducers, a flat `match` is simpler and clearer. Use this macro when:
16/// - Your reducer exceeds ~500 lines and splitting improves organization
17/// - You have **context-aware routing** (e.g., vim normal vs command mode)
18/// - Handlers live in **separate modules** and you want clean composition
19///
20/// # Syntax
21///
22/// ```text
23/// reducer_compose!(state, action, {
24///     // Arms are tried in order, first match wins
25///     category "name" => handler,      // Route by action category
26///     Action::Specific => handler,     // Route by pattern match
27///     _ => fallback_handler,           // Catch-all (required last)
28/// })
29///
30/// // With context (e.g., for modal/mode-aware routing):
31/// reducer_compose!(state, action, context, {
32///     context Mode::Command => handle_command,  // Route by context value
33///     category "nav" => handle_nav,
34///     _ => handle_default,
35/// })
36/// ```
37///
38/// # Arm Types
39///
40/// **`category "name" => handler`** - Routes actions where
41/// `ActionCategory::category(&action) == Some("name")`. Requires
42/// `#[action(infer_categories)]` on your action enum.
43///
44/// **`context Value => handler`** - Routes when the context expression equals
45/// `Value`. Only available in the 4-argument form.
46///
47/// **`Pattern => handler`** - Standard pattern match (e.g., `Action::Quit`,
48/// `Action::Input(_)`).
49///
50/// **`_ => handler`** - Catch-all fallback. Must be last.
51///
52/// # Handler Signature
53///
54/// All handlers must have the same signature:
55/// ```text
56/// fn handler(state: &mut S, action: A) -> R
57/// ```
58/// Where `R` is typically `bool` or `ReducerResult<E>`.
59///
60/// # Category Inference
61///
62/// With `#[action(infer_categories)]`, categories are inferred from action
63/// names by taking the prefix before the verb:
64///
65/// | Action | Verb | Category |
66/// |--------|------|----------|
67/// | `NavScrollUp` | Scroll | `"nav"` |
68/// | `SearchQuerySubmit` | Submit | `"search_query"` |
69/// | `WeatherDidLoad` | Did | `"weather"` |
70/// | `Quit` | (none) | `None` |
71///
72/// For predictable categories, use explicit `#[category = "name"]` attributes.
73///
74/// # Example
75///
76/// ```ignore
77/// fn reducer(state: &mut AppState, action: Action, mode: Mode) -> bool {
78///     reducer_compose!(state, action, mode, {
79///         // Command mode gets priority
80///         context Mode::Command => handle_command,
81///         // Then route by category
82///         category "nav" => handle_navigation,
83///         category "search" => handle_search,
84///         // Specific actions
85///         Action::Quit => |_, _| false,
86///         // Everything else
87///         _ => handle_ui,
88///     })
89/// }
90///
91/// fn handle_navigation(state: &mut AppState, action: Action) -> bool {
92///     match action {
93///         Action::NavUp => { state.cursor -= 1; true }
94///         Action::NavDown => { state.cursor += 1; true }
95///         _ => false,
96///     }
97/// }
98/// ```
99#[macro_export]
100macro_rules! reducer_compose {
101    // 3-argument form must come first to prevent $context:expr from matching the braces
102    ($state:expr, $action:expr, { $($arms:tt)+ }) => {{
103        let __state = $state;
104        let __action_input = $action;
105        let __context = ();
106        $crate::reducer_compose!(@accum __state, __action_input, __context; () $($arms)+)
107    }};
108    ($state:expr, $action:expr, $context:expr, { $($arms:tt)+ }) => {{
109        let __state = $state;
110        let __action_input = $action;
111        let __context = $context;
112        $crate::reducer_compose!(@accum __state, __action_input, __context; () $($arms)+)
113    }};
114    (@accum $state:ident, $action:ident, $context:ident; ($($out:tt)*) category $category:expr => $handler:expr, $($rest:tt)+) => {
115        $crate::reducer_compose!(
116            @accum $state, $action, $context;
117            (
118                $($out)*
119                __action if $crate::ActionCategory::category(&__action) == Some($category) => {
120                    ($handler)($state, __action)
121                },
122            )
123            $($rest)+
124        )
125    };
126    (@accum $state:ident, $action:ident, $context:ident; ($($out:tt)*) context $context_value:expr => $handler:expr, $($rest:tt)+) => {
127        $crate::reducer_compose!(
128            @accum $state, $action, $context;
129            (
130                $($out)*
131                __action if $context == $context_value => {
132                    ($handler)($state, __action)
133                },
134            )
135            $($rest)+
136        )
137    };
138    (@accum $state:ident, $action:ident, $context:ident; ($($out:tt)*) _ => $handler:expr, $($rest:tt)+) => {
139        $crate::reducer_compose!(
140            @accum $state, $action, $context;
141            (
142                $($out)*
143                __action => {
144                    ($handler)($state, __action)
145                },
146            )
147            $($rest)+
148        )
149    };
150    (@accum $state:ident, $action:ident, $context:ident; ($($out:tt)*) $pattern:pat $(if $guard:expr)? => $handler:expr, $($rest:tt)+) => {
151        $crate::reducer_compose!(
152            @accum $state, $action, $context;
153            (
154                $($out)*
155                __action @ $pattern $(if $guard)? => {
156                    ($handler)($state, __action)
157                },
158            )
159            $($rest)+
160        )
161    };
162    (@accum $state:ident, $action:ident, $context:ident; ($($out:tt)*) category $category:expr => $handler:expr $(,)?) => {
163        match $action {
164            $($out)*
165            __action if $crate::ActionCategory::category(&__action) == Some($category) => {
166                ($handler)($state, __action)
167            }
168        }
169    };
170    (@accum $state:ident, $action:ident, $context:ident; ($($out:tt)*) context $context_value:expr => $handler:expr $(,)?) => {
171        match $action {
172            $($out)*
173            __action if $context == $context_value => {
174                ($handler)($state, __action)
175            }
176        }
177    };
178    (@accum $state:ident, $action:ident, $context:ident; ($($out:tt)*) _ => $handler:expr $(,)?) => {
179        match $action {
180            $($out)*
181            __action => {
182                ($handler)($state, __action)
183            }
184        }
185    };
186    (@accum $state:ident, $action:ident, $context:ident; ($($out:tt)*) $pattern:pat $(if $guard:expr)? => $handler:expr $(,)?) => {
187        match $action {
188            $($out)*
189            __action @ $pattern $(if $guard)? => {
190                ($handler)($state, __action)
191            }
192        }
193    };
194}
195
196/// Centralized state store with Redux-like reducer pattern
197///
198/// The store holds the application state and provides a single point
199/// for state mutations through the `dispatch` method.
200///
201/// # Type Parameters
202/// * `S` - The application state type
203/// * `A` - The action type (must implement `Action`)
204///
205/// # Example
206/// ```
207/// use tui_dispatch_core::{Action, Store};
208///
209/// #[derive(Clone, Debug)]
210/// enum MyAction { Increment, Decrement }
211///
212/// impl Action for MyAction {
213///     fn name(&self) -> &'static str {
214///         match self {
215///             MyAction::Increment => "Increment",
216///             MyAction::Decrement => "Decrement",
217///         }
218///     }
219/// }
220///
221/// #[derive(Default)]
222/// struct AppState { counter: i32 }
223///
224/// fn reducer(state: &mut AppState, action: MyAction) -> bool {
225///     match action {
226///         MyAction::Increment => { state.counter += 1; true }
227///         MyAction::Decrement => { state.counter -= 1; true }
228///     }
229/// }
230///
231/// let mut store = Store::new(AppState::default(), reducer);
232/// store.dispatch(MyAction::Increment);
233/// assert_eq!(store.state().counter, 1);
234/// ```
235pub struct Store<S, A: Action> {
236    state: S,
237    reducer: Reducer<S, A>,
238    _marker: PhantomData<A>,
239}
240
241impl<S, A: Action> Store<S, A> {
242    /// Create a new store with initial state and reducer
243    pub fn new(state: S, reducer: Reducer<S, A>) -> Self {
244        Self {
245            state,
246            reducer,
247            _marker: PhantomData,
248        }
249    }
250
251    /// Dispatch an action to the store
252    ///
253    /// The reducer will be called with the current state and action.
254    /// Returns `true` if the state changed and a re-render is needed.
255    pub fn dispatch(&mut self, action: A) -> bool {
256        (self.reducer)(&mut self.state, action)
257    }
258
259    /// Get a reference to the current state
260    pub fn state(&self) -> &S {
261        &self.state
262    }
263
264    /// Get a mutable reference to the state
265    ///
266    /// Use this sparingly - prefer dispatching actions for state changes.
267    /// This is useful for initializing state or for cases where the
268    /// action pattern doesn't fit well.
269    pub fn state_mut(&mut self) -> &mut S {
270        &mut self.state
271    }
272}
273
274/// Store with middleware support
275///
276/// Wraps a `Store` and allows middleware to intercept actions
277/// before and after they are processed by the reducer.
278pub struct StoreWithMiddleware<S, A: Action, M: Middleware<S, A>> {
279    store: Store<S, A>,
280    middleware: M,
281    dispatch_depth: usize,
282}
283
284impl<S, A: Action, M: Middleware<S, A>> StoreWithMiddleware<S, A, M> {
285    /// Create a new store with middleware
286    pub fn new(state: S, reducer: Reducer<S, A>, middleware: M) -> Self {
287        Self {
288            store: Store::new(state, reducer),
289            middleware,
290            dispatch_depth: 0,
291        }
292    }
293
294    /// Dispatch an action through middleware and store
295    ///
296    /// The action passes through `middleware.before()` (which can cancel it),
297    /// then the reducer, then `middleware.after()` (which can inject follow-up actions).
298    /// Injected actions go through the full pipeline recursively.
299    pub fn dispatch(&mut self, action: A) -> bool {
300        self.dispatch_depth += 1;
301        assert!(
302            self.dispatch_depth <= MAX_DISPATCH_DEPTH,
303            "middleware dispatch depth exceeded {MAX_DISPATCH_DEPTH} — likely infinite injection loop"
304        );
305
306        if self.middleware.before(&action, &self.store.state) {
307            let mut changed = self.store.dispatch(action.clone());
308            let injected = self.middleware.after(&action, changed, &self.store.state);
309            for a in injected {
310                changed |= self.dispatch(a);
311            }
312            self.dispatch_depth -= 1;
313            changed
314        } else {
315            self.dispatch_depth -= 1;
316            false
317        }
318    }
319
320    /// Get a reference to the current state
321    pub fn state(&self) -> &S {
322        self.store.state()
323    }
324
325    /// Get a mutable reference to the state
326    pub fn state_mut(&mut self) -> &mut S {
327        self.store.state_mut()
328    }
329
330    /// Get a reference to the middleware
331    pub fn middleware(&self) -> &M {
332        &self.middleware
333    }
334
335    /// Get a mutable reference to the middleware
336    pub fn middleware_mut(&mut self) -> &mut M {
337        &mut self.middleware
338    }
339}
340
341/// Maximum dispatch depth before panicking to prevent infinite middleware injection loops.
342pub(crate) const MAX_DISPATCH_DEPTH: usize = 16;
343
344/// Middleware trait for intercepting actions
345///
346/// Implement this trait to add logging, persistence, throttling, or other
347/// cross-cutting concerns to your store. Middleware can:
348///
349/// - **Observe**: inspect actions and state (logging, analytics, persistence)
350/// - **Cancel**: return `false` from `before()` to prevent the action from reaching the reducer
351/// - **Inject**: return follow-up actions from `after()` that are dispatched through the full pipeline
352///
353/// # Cancel
354///
355/// Return `false` from `before()` to cancel the action — the reducer is never called and
356/// `after()` is not invoked. Useful for throttling, validation, and auth guards.
357///
358/// # Inject
359///
360/// Return actions from `after()` to trigger follow-up dispatches. Injected actions go through
361/// the full middleware + reducer pipeline. A recursion depth limit prevents infinite loops.
362///
363/// Useful for cascading behavior: moving a card to "Done" triggers a notification,
364/// without the move reducer knowing about notifications.
365pub trait Middleware<S, A: Action> {
366    /// Called before the action is dispatched to the reducer.
367    ///
368    /// Return `true` to proceed with dispatch, `false` to cancel.
369    fn before(&mut self, action: &A, state: &S) -> bool;
370
371    /// Called after the action is processed by the reducer.
372    ///
373    /// Return any follow-up actions to dispatch through the full pipeline.
374    fn after(&mut self, action: &A, state_changed: bool, state: &S) -> Vec<A>;
375}
376
377/// A no-op middleware that does nothing
378#[derive(Debug, Clone, Copy, Default)]
379pub struct NoopMiddleware;
380
381impl<S, A: Action> Middleware<S, A> for NoopMiddleware {
382    fn before(&mut self, _action: &A, _state: &S) -> bool {
383        true
384    }
385    fn after(&mut self, _action: &A, _state_changed: bool, _state: &S) -> Vec<A> {
386        vec![]
387    }
388}
389
390/// Middleware that logs actions via the `tracing` crate.
391///
392/// Requires the `tracing` feature.
393#[cfg(feature = "tracing")]
394#[derive(Debug, Clone, Default)]
395pub struct LoggingMiddleware {
396    /// Whether to log before dispatch
397    pub log_before: bool,
398    /// Whether to log after dispatch
399    pub log_after: bool,
400}
401
402#[cfg(feature = "tracing")]
403impl LoggingMiddleware {
404    /// Create a new logging middleware with default settings (log after only)
405    pub fn new() -> Self {
406        Self {
407            log_before: false,
408            log_after: true,
409        }
410    }
411
412    /// Create a logging middleware that logs both before and after
413    pub fn verbose() -> Self {
414        Self {
415            log_before: true,
416            log_after: true,
417        }
418    }
419}
420
421#[cfg(feature = "tracing")]
422impl<S, A: Action> Middleware<S, A> for LoggingMiddleware {
423    fn before(&mut self, action: &A, _state: &S) -> bool {
424        if self.log_before {
425            tracing::debug!(action = %action.name(), "Dispatching action");
426        }
427        true
428    }
429
430    fn after(&mut self, action: &A, state_changed: bool, _state: &S) -> Vec<A> {
431        if self.log_after {
432            tracing::debug!(
433                action = %action.name(),
434                state_changed = state_changed,
435                "Action processed"
436            );
437        }
438        vec![]
439    }
440}
441
442/// Compose multiple middleware into a single middleware
443pub struct ComposedMiddleware<S, A: Action> {
444    middlewares: Vec<Box<dyn Middleware<S, A>>>,
445}
446
447impl<S, A: Action> std::fmt::Debug for ComposedMiddleware<S, A> {
448    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449        f.debug_struct("ComposedMiddleware")
450            .field("middlewares_count", &self.middlewares.len())
451            .finish()
452    }
453}
454
455impl<S, A: Action> Default for ComposedMiddleware<S, A> {
456    fn default() -> Self {
457        Self::new()
458    }
459}
460
461impl<S, A: Action> ComposedMiddleware<S, A> {
462    /// Create a new composed middleware
463    pub fn new() -> Self {
464        Self {
465            middlewares: Vec::new(),
466        }
467    }
468
469    /// Add a middleware to the composition
470    pub fn add<M: Middleware<S, A> + 'static>(&mut self, middleware: M) {
471        self.middlewares.push(Box::new(middleware));
472    }
473}
474
475impl<S, A: Action> Middleware<S, A> for ComposedMiddleware<S, A> {
476    fn before(&mut self, action: &A, state: &S) -> bool {
477        for middleware in &mut self.middlewares {
478            if !middleware.before(action, state) {
479                return false;
480            }
481        }
482        true
483    }
484
485    fn after(&mut self, action: &A, state_changed: bool, state: &S) -> Vec<A> {
486        let mut injected = Vec::new();
487        // Call in reverse order for proper nesting
488        for middleware in self.middlewares.iter_mut().rev() {
489            injected.extend(middleware.after(action, state_changed, state));
490        }
491        injected
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::ActionCategory;
499
500    #[derive(Default)]
501    struct TestState {
502        counter: i32,
503    }
504
505    #[derive(Clone, Debug)]
506    enum TestAction {
507        Increment,
508        Decrement,
509        NoOp,
510    }
511
512    impl Action for TestAction {
513        fn name(&self) -> &'static str {
514            match self {
515                TestAction::Increment => "Increment",
516                TestAction::Decrement => "Decrement",
517                TestAction::NoOp => "NoOp",
518            }
519        }
520    }
521
522    fn test_reducer(state: &mut TestState, action: TestAction) -> bool {
523        match action {
524            TestAction::Increment => {
525                state.counter += 1;
526                true
527            }
528            TestAction::Decrement => {
529                state.counter -= 1;
530                true
531            }
532            TestAction::NoOp => false,
533        }
534    }
535
536    #[test]
537    fn test_store_dispatch() {
538        let mut store = Store::new(TestState::default(), test_reducer);
539
540        assert!(store.dispatch(TestAction::Increment));
541        assert_eq!(store.state().counter, 1);
542
543        assert!(store.dispatch(TestAction::Increment));
544        assert_eq!(store.state().counter, 2);
545
546        assert!(store.dispatch(TestAction::Decrement));
547        assert_eq!(store.state().counter, 1);
548    }
549
550    #[test]
551    fn test_store_noop() {
552        let mut store = Store::new(TestState::default(), test_reducer);
553
554        assert!(!store.dispatch(TestAction::NoOp));
555        assert_eq!(store.state().counter, 0);
556    }
557
558    #[test]
559    fn test_store_state_mut() {
560        let mut store = Store::new(TestState::default(), test_reducer);
561
562        store.state_mut().counter = 100;
563        assert_eq!(store.state().counter, 100);
564    }
565
566    #[derive(Default)]
567    struct CountingMiddleware {
568        before_count: usize,
569        after_count: usize,
570    }
571
572    impl<S, A: Action> Middleware<S, A> for CountingMiddleware {
573        fn before(&mut self, _action: &A, _state: &S) -> bool {
574            self.before_count += 1;
575            true
576        }
577
578        fn after(&mut self, _action: &A, _state_changed: bool, _state: &S) -> Vec<A> {
579            self.after_count += 1;
580            vec![]
581        }
582    }
583
584    #[test]
585    fn test_store_with_middleware() {
586        let mut store = StoreWithMiddleware::new(
587            TestState::default(),
588            test_reducer,
589            CountingMiddleware::default(),
590        );
591
592        store.dispatch(TestAction::Increment);
593        store.dispatch(TestAction::Increment);
594
595        assert_eq!(store.middleware().before_count, 2);
596        assert_eq!(store.middleware().after_count, 2);
597        assert_eq!(store.state().counter, 2);
598    }
599
600    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
601    enum ComposeContext {
602        Default,
603        Command,
604    }
605
606    #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
607    enum ComposeCategory {
608        Nav,
609        Search,
610        Uncategorized,
611    }
612
613    #[derive(Clone, Debug)]
614    enum ComposeAction {
615        NavUp,
616        Search,
617        Other,
618    }
619
620    impl Action for ComposeAction {
621        fn name(&self) -> &'static str {
622            match self {
623                ComposeAction::NavUp => "NavUp",
624                ComposeAction::Search => "Search",
625                ComposeAction::Other => "Other",
626            }
627        }
628    }
629
630    impl ActionCategory for ComposeAction {
631        type Category = ComposeCategory;
632
633        fn category(&self) -> Option<&'static str> {
634            match self {
635                ComposeAction::NavUp => Some("nav"),
636                ComposeAction::Search => Some("search"),
637                ComposeAction::Other => None,
638            }
639        }
640
641        fn category_enum(&self) -> Self::Category {
642            match self {
643                ComposeAction::NavUp => ComposeCategory::Nav,
644                ComposeAction::Search => ComposeCategory::Search,
645                ComposeAction::Other => ComposeCategory::Uncategorized,
646            }
647        }
648    }
649
650    fn handle_nav(state: &mut usize, _action: ComposeAction) -> &'static str {
651        *state += 1;
652        "nav"
653    }
654
655    fn handle_command(state: &mut usize, _action: ComposeAction) -> &'static str {
656        *state += 10;
657        "command"
658    }
659
660    fn handle_search(state: &mut usize, _action: ComposeAction) -> &'static str {
661        *state += 100;
662        "search"
663    }
664
665    fn handle_default(state: &mut usize, _action: ComposeAction) -> &'static str {
666        *state += 1000;
667        "default"
668    }
669
670    fn composed_reducer(
671        state: &mut usize,
672        action: ComposeAction,
673        context: ComposeContext,
674    ) -> &'static str {
675        crate::reducer_compose!(state, action, context, {
676            category "nav" => handle_nav,
677            context ComposeContext::Command => handle_command,
678            ComposeAction::Search => handle_search,
679            _ => handle_default,
680        })
681    }
682
683    #[test]
684    fn test_reducer_compose_routes_category() {
685        let mut state = 0;
686        let result = composed_reducer(&mut state, ComposeAction::NavUp, ComposeContext::Command);
687        assert_eq!(result, "nav");
688        assert_eq!(state, 1);
689    }
690
691    #[test]
692    fn test_reducer_compose_routes_context() {
693        let mut state = 0;
694        let result = composed_reducer(&mut state, ComposeAction::Other, ComposeContext::Command);
695        assert_eq!(result, "command");
696        assert_eq!(state, 10);
697    }
698
699    #[test]
700    fn test_reducer_compose_routes_pattern() {
701        let mut state = 0;
702        let result = composed_reducer(&mut state, ComposeAction::Search, ComposeContext::Default);
703        assert_eq!(result, "search");
704        assert_eq!(state, 100);
705    }
706
707    #[test]
708    fn test_reducer_compose_routes_fallback() {
709        let mut state = 0;
710        let result = composed_reducer(&mut state, ComposeAction::Other, ComposeContext::Default);
711        assert_eq!(result, "default");
712        assert_eq!(state, 1000);
713    }
714
715    // Test 3-argument form (no context)
716    fn composed_reducer_no_context(state: &mut usize, action: ComposeAction) -> &'static str {
717        crate::reducer_compose!(state, action, {
718            category "nav" => handle_nav,
719            ComposeAction::Search => handle_search,
720            _ => handle_default,
721        })
722    }
723
724    #[test]
725    fn test_reducer_compose_3arg_category() {
726        let mut state = 0;
727        let result = composed_reducer_no_context(&mut state, ComposeAction::NavUp);
728        assert_eq!(result, "nav");
729        assert_eq!(state, 1);
730    }
731
732    #[test]
733    fn test_reducer_compose_3arg_pattern() {
734        let mut state = 0;
735        let result = composed_reducer_no_context(&mut state, ComposeAction::Search);
736        assert_eq!(result, "search");
737        assert_eq!(state, 100);
738    }
739
740    #[test]
741    fn test_reducer_compose_3arg_fallback() {
742        let mut state = 0;
743        let result = composed_reducer_no_context(&mut state, ComposeAction::Other);
744        assert_eq!(result, "default");
745        assert_eq!(state, 1000);
746    }
747}