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#[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
59pub 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#[derive(Debug)]
135pub enum ReplayError {
136 Timeout { pattern: String },
138 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
155async 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 for pattern in patterns {
175 if glob_match(pattern, &action_name) {
176 return Ok(());
177 }
178 }
179 }
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 continue;
187 }
188 Err(_) => {
189 return Err(ReplayError::Timeout {
191 pattern: patterns.join(" | "),
192 });
193 }
194 }
195 }
196}
197
198#[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 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 if let Ok(items) = serde_json::from_str::<Vec<ReplayItem<A>>>(&contents) {
289 return Ok(items);
290 }
291
292 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 #[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 #[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 #[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 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 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 let runtime = EffectRuntime::from_store(store);
426 let mut action_rx = runtime.subscribe_actions();
427 let action_tx = runtime.action_tx();
428
429 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 let action_tx = runtime.action_tx();
469 let quit = quit_action.clone();
470 tokio::spawn(async move {
471 let _ = replay_handle.await;
473 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 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 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}