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