Skip to main content

tui_dispatch_debug/
session.rs

1use crate::cli::DebugCliArgs;
2use crate::debug::{glob_match, ActionLoggerConfig, DebugLayer, DebugState};
3use crate::replay::ReplayItem;
4use crate::snapshot::{ActionSnapshot, SnapshotError, StateSnapshot};
5use ratatui::backend::{Backend, TestBackend};
6use ratatui::layout::{Rect, Size};
7use ratatui::Terminal;
8use serde::de::DeserializeOwned;
9use serde::Serialize;
10use std::cell::RefCell;
11use std::error::Error;
12use std::fmt;
13use std::future::Future;
14use std::io;
15use std::io::Write;
16use std::path::PathBuf;
17use std::rc::Rc;
18use std::time::Duration;
19use tui_dispatch_core::runtime::{
20    EffectContext, EffectRuntime, EffectStoreLike, EventOutcome, RenderContext,
21};
22use tui_dispatch_core::store::{ComposedMiddleware, Middleware};
23use tui_dispatch_core::testing::RenderHarness;
24use tui_dispatch_core::{
25    Action, ActionParams, BindingContext, ComponentId, EventBus, EventContext, EventKind,
26    EventRoutingState, Keybindings,
27};
28
29/// Records actions for --debug-actions-out snapshots with optional filtering.
30#[derive(Clone)]
31pub struct DebugActionRecorder<A> {
32    actions: Rc<RefCell<Vec<A>>>,
33    filter: ActionLoggerConfig,
34}
35
36impl<A> DebugActionRecorder<A> {
37    pub fn new(filter: ActionLoggerConfig) -> Self {
38        Self {
39            actions: Rc::new(RefCell::new(Vec::new())),
40            filter,
41        }
42    }
43
44    pub fn actions(&self) -> Vec<A>
45    where
46        A: Clone,
47    {
48        self.actions.borrow().clone()
49    }
50}
51
52impl<A: Action> Middleware<A> for DebugActionRecorder<A> {
53    fn before(&mut self, action: &A) {
54        if self.filter.should_log(action.name()) {
55            self.actions.borrow_mut().push(action.clone());
56        }
57    }
58
59    fn after(&mut self, _action: &A, _state_changed: bool) {}
60}
61
62/// Output from a debug-aware app run.
63pub struct DebugRunOutput<S> {
64    state: S,
65    render_output: Option<String>,
66}
67
68impl<S> DebugRunOutput<S> {
69    pub fn new(state: S, render_output: Option<String>) -> Self {
70        Self {
71            state,
72            render_output,
73        }
74    }
75
76    pub fn state(&self) -> &S {
77        &self.state
78    }
79
80    pub fn into_state(self) -> S {
81        self.state
82    }
83
84    pub fn render_output(&self) -> Option<&str> {
85        self.render_output.as_deref()
86    }
87
88    pub fn take_render_output(self) -> Option<String> {
89        self.render_output
90    }
91
92    pub fn write_render_output(&self) -> io::Result<()> {
93        if let Some(output) = self.render_output.as_ref() {
94            let mut stdout = io::stdout();
95            stdout.write_all(output.as_bytes())?;
96            stdout.flush()?;
97        }
98        Ok(())
99    }
100}
101
102pub type DebugSessionResult<T> = Result<T, DebugSessionError>;
103
104#[derive(Debug)]
105pub enum DebugSessionError {
106    Snapshot(SnapshotError),
107    Fallback(Box<dyn Error + Send + Sync>),
108    MissingActionRecorder { path: PathBuf },
109}
110
111impl DebugSessionError {
112    fn fallback<E>(error: E) -> Self
113    where
114        E: Error + Send + Sync + 'static,
115    {
116        Self::Fallback(Box::new(error))
117    }
118}
119
120impl fmt::Display for DebugSessionError {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        match self {
123            Self::Snapshot(error) => write!(f, "snapshot error: {error:?}"),
124            Self::Fallback(error) => write!(f, "fallback error: {error}"),
125            Self::MissingActionRecorder { path } => write!(
126                f,
127                "debug actions out requested but no recorder attached: {}",
128                path.display()
129            ),
130        }
131    }
132}
133
134impl std::error::Error for DebugSessionError {}
135
136/// Error from replay with await markers.
137#[derive(Debug)]
138pub enum ReplayError {
139    /// Timeout waiting for action to be dispatched.
140    Timeout { pattern: String },
141    /// Broadcast channel closed unexpectedly.
142    ChannelClosed,
143}
144
145impl fmt::Display for ReplayError {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        match self {
148            Self::Timeout { pattern } => {
149                write!(f, "timeout waiting for action matching '{pattern}'")
150            }
151            Self::ChannelClosed => write!(f, "action broadcast channel closed"),
152        }
153    }
154}
155
156impl std::error::Error for ReplayError {}
157
158/// Wait for an action matching one of the patterns to be dispatched.
159async fn wait_for_action(
160    action_rx: &mut tokio::sync::broadcast::Receiver<String>,
161    patterns: &[String],
162    timeout: Duration,
163) -> Result<(), ReplayError> {
164    let deadline = tokio::time::Instant::now() + timeout;
165
166    loop {
167        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
168        if remaining.is_zero() {
169            return Err(ReplayError::Timeout {
170                pattern: patterns.join(" | "),
171            });
172        }
173
174        match tokio::time::timeout(remaining, action_rx.recv()).await {
175            Ok(Ok(action_name)) => {
176                // Check if any pattern matches
177                for pattern in patterns {
178                    if glob_match(pattern, &action_name) {
179                        return Ok(());
180                    }
181                }
182                // Not a match, keep waiting
183            }
184            Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => {
185                return Err(ReplayError::ChannelClosed);
186            }
187            Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => {
188                // Missed some messages, but keep waiting
189                continue;
190            }
191            Err(_) => {
192                // Timeout
193                return Err(ReplayError::Timeout {
194                    pattern: patterns.join(" | "),
195                });
196            }
197        }
198    }
199}
200
201/// Helper for wiring debug CLI flags into an app runtime.
202#[derive(Debug)]
203pub struct DebugSession {
204    args: DebugCliArgs,
205}
206
207impl DebugSession {
208    pub fn new(args: DebugCliArgs) -> Self {
209        Self { args }
210    }
211
212    pub fn args(&self) -> &DebugCliArgs {
213        &self.args
214    }
215
216    pub fn enabled(&self) -> bool {
217        self.args.enabled
218    }
219
220    pub fn render_once(&self) -> bool {
221        self.args.render_once
222    }
223
224    pub fn use_alt_screen(&self) -> bool {
225        !self.args.render_once
226    }
227
228    pub fn action_filter(&self) -> ActionLoggerConfig {
229        self.args.action_filter()
230    }
231
232    pub fn auto_fetch(&self) -> bool {
233        self.args.auto_fetch()
234    }
235
236    pub fn load_state_or_else<S, F, E>(&self, fallback: F) -> DebugSessionResult<S>
237    where
238        S: DeserializeOwned,
239        F: FnOnce() -> Result<S, E>,
240        E: Error + Send + Sync + 'static,
241    {
242        if let Some(path) = self.args.state_in.as_ref() {
243            StateSnapshot::load_json(path)
244                .map(|snapshot| snapshot.into_state())
245                .map_err(DebugSessionError::Snapshot)
246        } else {
247            fallback().map_err(DebugSessionError::fallback)
248        }
249    }
250
251    pub async fn load_state_or_else_async<S, F, Fut, E>(&self, fallback: F) -> DebugSessionResult<S>
252    where
253        S: DeserializeOwned,
254        F: FnOnce() -> Fut,
255        Fut: Future<Output = Result<S, E>>,
256        E: Error + Send + Sync + 'static,
257    {
258        if let Some(path) = self.args.state_in.as_ref() {
259            StateSnapshot::load_json(path)
260                .map(|snapshot| snapshot.into_state())
261                .map_err(DebugSessionError::Snapshot)
262        } else {
263            fallback().await.map_err(DebugSessionError::fallback)
264        }
265    }
266
267    pub fn load_state_or<S, F>(&self, fallback: F) -> DebugSessionResult<S>
268    where
269        S: DeserializeOwned,
270        F: FnOnce() -> S,
271    {
272        self.load_state_or_else(|| Ok::<S, std::convert::Infallible>(fallback()))
273    }
274
275    /// Load replay items from `--debug-actions-in`.
276    ///
277    /// This auto-detects the format: either a simple `Vec<A>` or `Vec<ReplayItem<A>>`.
278    /// Both formats are supported for backwards compatibility.
279    pub fn load_replay_items<A>(&self) -> DebugSessionResult<Vec<ReplayItem<A>>>
280    where
281        A: DeserializeOwned,
282    {
283        let Some(path) = self.args.actions_in.as_ref() else {
284            return Ok(Vec::new());
285        };
286
287        let contents =
288            std::fs::read_to_string(path).map_err(|e| DebugSessionError::Snapshot(e.into()))?;
289
290        // Try parsing as Vec<ReplayItem<A>> first (supports await markers)
291        if let Ok(items) = serde_json::from_str::<Vec<ReplayItem<A>>>(&contents) {
292            return Ok(items);
293        }
294
295        // Fall back to Vec<A> (simple action list, wrap in ReplayItem::Action)
296        let actions: Vec<A> = serde_json::from_str(&contents)
297            .map_err(|e| DebugSessionError::Snapshot(SnapshotError::Json(e)))?;
298        Ok(actions.into_iter().map(ReplayItem::Action).collect())
299    }
300
301    /// Load actions from `--debug-actions-in` (legacy API, ignores await markers).
302    #[deprecated(note = "Use load_replay_items instead")]
303    pub fn load_actions<A>(&self) -> DebugSessionResult<Vec<A>>
304    where
305        A: DeserializeOwned,
306    {
307        self.load_replay_items().map(|items| {
308            items
309                .into_iter()
310                .filter_map(|item| item.into_action())
311                .collect()
312        })
313    }
314
315    pub fn action_recorder<A: Action>(&self) -> Option<DebugActionRecorder<A>> {
316        self.args
317            .actions_out
318            .as_ref()
319            .map(|_| DebugActionRecorder::new(self.action_filter()))
320    }
321
322    pub fn middleware_with_recorder<A: Action>(
323        &self,
324    ) -> (ComposedMiddleware<A>, Option<DebugActionRecorder<A>>) {
325        let mut middleware = ComposedMiddleware::new();
326        let recorder = self.action_recorder();
327        if let Some(recorder) = recorder.clone() {
328            middleware.add(recorder);
329        }
330        (middleware, recorder)
331    }
332
333    pub fn save_actions<A>(
334        &self,
335        recorder: Option<&DebugActionRecorder<A>>,
336    ) -> DebugSessionResult<()>
337    where
338        A: Clone + Serialize,
339    {
340        let Some(path) = self.args.actions_out.as_ref() else {
341            return Ok(());
342        };
343        let Some(recorder) = recorder else {
344            return Err(DebugSessionError::MissingActionRecorder {
345                path: path.to_path_buf(),
346            });
347        };
348        ActionSnapshot::new(recorder.actions())
349            .save_json(path)
350            .map_err(DebugSessionError::Snapshot)
351    }
352
353    /// Save JSON schema for the state type if `--debug-state-schema-out` was set.
354    #[cfg(feature = "json-schema")]
355    pub fn save_state_schema<S>(&self) -> DebugSessionResult<()>
356    where
357        S: crate::JsonSchema,
358    {
359        if let Some(path) = self.args.state_schema_out.as_ref() {
360            crate::save_schema::<S, _>(path).map_err(DebugSessionError::Snapshot)
361        } else {
362            Ok(())
363        }
364    }
365
366    /// Save JSON schema for replay items (actions + await markers).
367    ///
368    /// This generates a schema for `Vec<ReplayItem<A>>` which includes:
369    /// - All action variants from `A`
370    /// - `_await` and `_await_any` markers for async coordination
371    /// - An `$defs.awaitable_actions` list of Did* action names
372    #[cfg(feature = "json-schema")]
373    pub fn save_actions_schema<A>(&self) -> DebugSessionResult<()>
374    where
375        A: crate::JsonSchema,
376    {
377        if let Some(path) = self.args.actions_schema_out.as_ref() {
378            crate::save_replay_schema::<A, _>(path).map_err(DebugSessionError::Snapshot)
379        } else {
380            Ok(())
381        }
382    }
383
384    /// Get the replay timeout duration from CLI args.
385    pub fn replay_timeout(&self) -> Duration {
386        Duration::from_secs(self.args.replay_timeout)
387    }
388
389    #[allow(clippy::too_many_arguments)]
390    pub async fn run_effect_app<B, S, A, E, St, FInit, FRender, FEvent, FQuit, FEffect, R>(
391        &self,
392        terminal: &mut Terminal<B>,
393        mut store: St,
394        debug_layer: DebugLayer<A>,
395        replay_items: Vec<ReplayItem<A>>,
396        auto_action: Option<A>,
397        quit_action: Option<A>,
398        init_runtime: FInit,
399        mut render: FRender,
400        mut map_event: FEvent,
401        mut should_quit: FQuit,
402        mut handle_effect: FEffect,
403    ) -> io::Result<DebugRunOutput<S>>
404    where
405        B: Backend,
406        S: Clone + DebugState + Serialize + 'static,
407        A: Action + ActionParams,
408        St: EffectStoreLike<S, A, E>,
409        FInit: FnOnce(&mut EffectRuntime<S, A, E, St>),
410        FRender: FnMut(&mut ratatui::Frame, Rect, &S, RenderContext),
411        FEvent: FnMut(&EventKind, &S) -> R,
412        R: Into<EventOutcome<A>>,
413        FQuit: FnMut(&A) -> bool,
414        FEffect: FnMut(E, &mut EffectContext<A>),
415    {
416        let size = terminal.size().unwrap_or_else(|_| Size::new(80, 24));
417        let width = size.width.max(1);
418        let height = size.height.max(1);
419        let auto_action = auto_action;
420
421        // Check if replay items contain any await markers
422        let has_awaits = replay_items.iter().any(|item| item.is_await());
423        let replay_timeout = self.replay_timeout();
424
425        if self.args.render_once {
426            let final_state = if has_awaits {
427                // Need runtime for effects when replay has await markers
428                let runtime = EffectRuntime::from_store(store);
429                let mut action_rx = runtime.subscribe_actions();
430                let action_tx = runtime.action_tx();
431
432                // Spawn replay task that processes items with await support
433                let replay_items_clone = replay_items;
434                let auto_action_clone = auto_action.clone();
435                let auto_fetch = self.auto_fetch();
436                let replay_handle = tokio::spawn(async move {
437                    for item in replay_items_clone {
438                        match item {
439                            ReplayItem::Action(action) => {
440                                let _ = action_tx.send(action);
441                            }
442                            ReplayItem::AwaitOne { _await: pattern } => {
443                                wait_for_action(&mut action_rx, &[pattern], replay_timeout).await?;
444                            }
445                            ReplayItem::AwaitAny {
446                                _await_any: patterns,
447                            } => {
448                                wait_for_action(&mut action_rx, &patterns, replay_timeout).await?;
449                            }
450                        }
451                    }
452                    if auto_fetch {
453                        if let Some(action) = auto_action_clone {
454                            let _ = action_tx.send(action);
455                        }
456                    }
457                    Ok::<(), ReplayError>(())
458                });
459
460                let mut runtime = runtime;
461                init_runtime(&mut runtime);
462
463                let quit_action = quit_action.ok_or_else(|| {
464                    io::Error::new(
465                        io::ErrorKind::InvalidInput,
466                        "replay with await markers requires a quit action",
467                    )
468                })?;
469
470                // Send quit after replay completes
471                let action_tx = runtime.action_tx();
472                let quit = quit_action.clone();
473                tokio::spawn(async move {
474                    // Wait for replay to complete
475                    let _ = replay_handle.await;
476                    // Small delay to let final effects settle
477                    tokio::time::sleep(Duration::from_millis(100)).await;
478                    let _ = action_tx.send(quit);
479                });
480
481                let backend = TestBackend::new(width, height);
482                let mut test_terminal = Terminal::new(backend)?;
483                runtime
484                    .run(
485                        &mut test_terminal,
486                        |_frame, _area, _state, _ctx| {},
487                        |_event, _state| EventOutcome::<A>::ignored(),
488                        |action| should_quit(action),
489                        |effect, ctx| handle_effect(effect, ctx),
490                    )
491                    .await?;
492
493                runtime.state().clone()
494            } else {
495                // Simple dispatch mode (no effects, ignores awaits)
496                for item in replay_items {
497                    if let ReplayItem::Action(action) = item {
498                        let _ = store.dispatch(action);
499                    }
500                }
501                if self.auto_fetch() {
502                    if let Some(action) = auto_action.clone() {
503                        let _ = store.dispatch(action);
504                    }
505                }
506                store.state().clone()
507            };
508
509            let mut harness = RenderHarness::new(width, height);
510            let output = harness.render_to_string_plain(|frame| {
511                render(frame, frame.area(), &final_state, RenderContext::default());
512            });
513
514            return Ok(DebugRunOutput::new(final_state, Some(output)));
515        }
516
517        // Normal interactive mode - just enqueue actions (ignore awaits)
518        let debug_layer = debug_layer
519            .with_state_snapshots::<S>()
520            .active(self.args.enabled);
521        let mut runtime = EffectRuntime::from_store(store).with_debug(debug_layer);
522        init_runtime(&mut runtime);
523
524        for item in replay_items {
525            if let ReplayItem::Action(action) = item {
526                runtime.enqueue(action);
527            }
528        }
529        if self.auto_fetch() {
530            if let Some(action) = auto_action {
531                runtime.enqueue(action);
532            }
533        }
534
535        let result = runtime
536            .run(
537                terminal,
538                |frame, area, state, render_ctx| {
539                    render(frame, area, state, render_ctx);
540                },
541                |event, state| map_event(event, state),
542                |action| should_quit(action),
543                |effect, ctx| handle_effect(effect, ctx),
544            )
545            .await;
546
547        match result {
548            Ok(()) => Ok(DebugRunOutput::new(runtime.state().clone(), None)),
549            Err(err) => Err(err),
550        }
551    }
552
553    #[allow(clippy::too_many_arguments)]
554    pub async fn run_effect_app_with_bus<B, S, A, E, St, Id, Ctx, FInit, FRender, FQuit, FEffect>(
555        &self,
556        terminal: &mut Terminal<B>,
557        mut store: St,
558        debug_layer: DebugLayer<A>,
559        replay_items: Vec<ReplayItem<A>>,
560        auto_action: Option<A>,
561        quit_action: Option<A>,
562        init_runtime: FInit,
563        bus: &mut EventBus<S, A, Id, Ctx>,
564        keybindings: &Keybindings<Ctx>,
565        mut render: FRender,
566        mut should_quit: FQuit,
567        mut handle_effect: FEffect,
568    ) -> io::Result<DebugRunOutput<S>>
569    where
570        B: Backend,
571        S: Clone + DebugState + Serialize + EventRoutingState<Id, Ctx> + 'static,
572        A: Action + ActionParams,
573        St: EffectStoreLike<S, A, E>,
574        Id: ComponentId + 'static,
575        Ctx: BindingContext + 'static,
576        FInit: FnOnce(&mut EffectRuntime<S, A, E, St>),
577        FRender: FnMut(&mut ratatui::Frame, Rect, &S, RenderContext, &mut EventContext<Id>),
578        FQuit: FnMut(&A) -> bool,
579        FEffect: FnMut(E, &mut EffectContext<A>),
580    {
581        let size = terminal.size().unwrap_or_else(|_| Size::new(80, 24));
582        let width = size.width.max(1);
583        let height = size.height.max(1);
584        let auto_action = auto_action;
585
586        // Check if replay items contain any await markers
587        let has_awaits = replay_items.iter().any(|item| item.is_await());
588        let replay_timeout = self.replay_timeout();
589
590        if self.args.render_once {
591            let final_state = if has_awaits {
592                // Need runtime for effects when replay has await markers
593                let runtime = EffectRuntime::from_store(store);
594                let mut action_rx = runtime.subscribe_actions();
595                let action_tx = runtime.action_tx();
596
597                // Spawn replay task that processes items with await support
598                let replay_items_clone = replay_items;
599                let auto_action_clone = auto_action.clone();
600                let auto_fetch = self.auto_fetch();
601                let replay_handle = tokio::spawn(async move {
602                    for item in replay_items_clone {
603                        match item {
604                            ReplayItem::Action(action) => {
605                                let _ = action_tx.send(action);
606                            }
607                            ReplayItem::AwaitOne { _await: pattern } => {
608                                wait_for_action(&mut action_rx, &[pattern], replay_timeout).await?;
609                            }
610                            ReplayItem::AwaitAny {
611                                _await_any: patterns,
612                            } => {
613                                wait_for_action(&mut action_rx, &patterns, replay_timeout).await?;
614                            }
615                        }
616                    }
617                    if auto_fetch {
618                        if let Some(action) = auto_action_clone {
619                            let _ = action_tx.send(action);
620                        }
621                    }
622                    Ok::<(), ReplayError>(())
623                });
624
625                let mut runtime = runtime;
626                init_runtime(&mut runtime);
627
628                let quit_action = quit_action.ok_or_else(|| {
629                    io::Error::new(
630                        io::ErrorKind::InvalidInput,
631                        "replay with await markers requires a quit action",
632                    )
633                })?;
634
635                // Send quit after replay completes
636                let action_tx = runtime.action_tx();
637                let quit = quit_action.clone();
638                tokio::spawn(async move {
639                    // Wait for replay to complete
640                    let _ = replay_handle.await;
641                    // Small delay to let final effects settle
642                    tokio::time::sleep(Duration::from_millis(100)).await;
643                    let _ = action_tx.send(quit);
644                });
645
646                let backend = TestBackend::new(width, height);
647                let mut test_terminal = Terminal::new(backend)?;
648                runtime
649                    .run(
650                        &mut test_terminal,
651                        |_frame, _area, _state, _ctx| {},
652                        |_event, _state| EventOutcome::<A>::ignored(),
653                        |action| should_quit(action),
654                        |effect, ctx| handle_effect(effect, ctx),
655                    )
656                    .await?;
657
658                runtime.state().clone()
659            } else {
660                // Simple dispatch mode (no effects, ignores awaits)
661                for item in replay_items {
662                    if let ReplayItem::Action(action) = item {
663                        let _ = store.dispatch(action);
664                    }
665                }
666                if self.auto_fetch() {
667                    if let Some(action) = auto_action.clone() {
668                        let _ = store.dispatch(action);
669                    }
670                }
671                store.state().clone()
672            };
673
674            let mut harness = RenderHarness::new(width, height);
675            let output = harness.render_to_string_plain(|frame| {
676                let mut event_ctx = EventContext::<Id>::default();
677                render(
678                    frame,
679                    frame.area(),
680                    &final_state,
681                    RenderContext::default(),
682                    &mut event_ctx,
683                );
684            });
685
686            return Ok(DebugRunOutput::new(final_state, Some(output)));
687        }
688
689        // Normal interactive mode - just enqueue actions (ignore awaits)
690        let debug_layer = debug_layer
691            .with_state_snapshots::<S>()
692            .active(self.args.enabled);
693        let mut runtime = EffectRuntime::from_store(store).with_debug(debug_layer);
694        init_runtime(&mut runtime);
695
696        for item in replay_items {
697            if let ReplayItem::Action(action) = item {
698                runtime.enqueue(action);
699            }
700        }
701        if self.auto_fetch() {
702            if let Some(action) = auto_action {
703                runtime.enqueue(action);
704            }
705        }
706
707        let result = runtime
708            .run_with_bus(
709                terminal,
710                bus,
711                keybindings,
712                |frame, area, state, render_ctx, event_ctx| {
713                    render(frame, area, state, render_ctx, event_ctx);
714                },
715                |action| should_quit(action),
716                |effect, ctx| handle_effect(effect, ctx),
717            )
718            .await;
719
720        match result {
721            Ok(()) => Ok(DebugRunOutput::new(runtime.state().clone(), None)),
722            Err(err) => Err(err),
723        }
724    }
725}