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