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#[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
62pub 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#[derive(Debug)]
138pub enum ReplayError {
139 Timeout { pattern: String },
141 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
158async 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 for pattern in patterns {
178 if glob_match(pattern, &action_name) {
179 return Ok(());
180 }
181 }
182 }
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 continue;
190 }
191 Err(_) => {
192 return Err(ReplayError::Timeout {
194 pattern: patterns.join(" | "),
195 });
196 }
197 }
198 }
199}
200
201#[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 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 if let Ok(items) = serde_json::from_str::<Vec<ReplayItem<A>>>(&contents) {
292 return Ok(items);
293 }
294
295 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 #[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 #[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 #[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 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 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 let runtime = EffectRuntime::from_store(store);
429 let mut action_rx = runtime.subscribe_actions();
430 let action_tx = runtime.action_tx();
431
432 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 let action_tx = runtime.action_tx();
472 let quit = quit_action.clone();
473 tokio::spawn(async move {
474 let _ = replay_handle.await;
476 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 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 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 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 let runtime = EffectRuntime::from_store(store);
594 let mut action_rx = runtime.subscribe_actions();
595 let action_tx = runtime.action_tx();
596
597 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 let action_tx = runtime.action_tx();
637 let quit = quit_action.clone();
638 tokio::spawn(async move {
639 let _ = replay_handle.await;
641 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 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 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}