tui_dispatch_core/debug/
action_logger.rs

1//! Action logging with pattern-based filtering and in-memory storage
2//!
3//! Provides configurable action logging using glob patterns to include/exclude
4//! specific actions from logs. Supports both tracing output and in-memory
5//! ring buffer storage for display in debug overlays.
6//!
7//! # Example
8//!
9//! ```ignore
10//! use tui_dispatch_core::debug::{ActionLoggerConfig, ActionLoggerMiddleware, ActionLogConfig};
11//!
12//! // Log all actions except Tick and Render (tracing only)
13//! let config = ActionLoggerConfig::default();
14//! let middleware = ActionLoggerMiddleware::new(config);
15//!
16//! // Log with in-memory storage for debug overlay display
17//! let config = ActionLogConfig::default();
18//! let middleware = ActionLoggerMiddleware::with_log(config);
19//!
20//! // Access the action log
21//! if let Some(log) = middleware.log() {
22//!     for entry in log.recent(10) {
23//!         println!("{}: {}", entry.elapsed_display(), entry.summary);
24//!     }
25//! }
26//! ```
27
28use crate::action::ActionSummary;
29use crate::store::Middleware;
30use std::collections::VecDeque;
31use std::time::Instant;
32
33/// Configuration for action logging with glob pattern filtering.
34///
35/// Patterns support:
36/// - `*` matches any sequence of characters
37/// - `?` matches any single character
38/// - Literal text matches exactly
39///
40/// # Examples
41///
42/// - `Search*` matches SearchAddChar, SearchDeleteChar, etc.
43/// - `Did*` matches DidConnect, DidScanKeys, etc.
44/// - `*Error*` matches any action containing "Error"
45/// - `Tick` matches only Tick
46#[derive(Debug, Clone)]
47pub struct ActionLoggerConfig {
48    /// If non-empty, only log actions matching these patterns
49    pub include_patterns: Vec<String>,
50    /// Exclude actions matching these patterns (applied after include)
51    pub exclude_patterns: Vec<String>,
52}
53
54impl Default for ActionLoggerConfig {
55    fn default() -> Self {
56        Self {
57            include_patterns: Vec::new(),
58            // By default, exclude noisy high-frequency actions
59            exclude_patterns: vec!["Tick".to_string(), "Render".to_string()],
60        }
61    }
62}
63
64impl ActionLoggerConfig {
65    /// Create a new config from comma-separated pattern strings
66    ///
67    /// # Arguments
68    /// - `include`: comma-separated glob patterns (or None for all)
69    /// - `exclude`: comma-separated glob patterns (or None for default excludes)
70    ///
71    /// # Example
72    /// ```
73    /// use tui_dispatch_core::debug::ActionLoggerConfig;
74    ///
75    /// let config = ActionLoggerConfig::new(Some("Search*,Connect"), Some("Tick,Render"));
76    /// assert!(config.should_log("SearchAddChar"));
77    /// assert!(config.should_log("Connect"));
78    /// assert!(!config.should_log("Tick"));
79    /// ```
80    pub fn new(include: Option<&str>, exclude: Option<&str>) -> Self {
81        let include_patterns = include
82            .map(|s| s.split(',').map(|p| p.trim().to_string()).collect())
83            .unwrap_or_default();
84
85        let exclude_patterns = exclude
86            .map(|s| s.split(',').map(|p| p.trim().to_string()).collect())
87            .unwrap_or_else(|| vec!["Tick".to_string(), "Render".to_string()]);
88
89        Self {
90            include_patterns,
91            exclude_patterns,
92        }
93    }
94
95    /// Create a config with specific pattern vectors
96    pub fn with_patterns(include: Vec<String>, exclude: Vec<String>) -> Self {
97        Self {
98            include_patterns: include,
99            exclude_patterns: exclude,
100        }
101    }
102
103    /// Check if an action name should be logged based on include/exclude patterns
104    pub fn should_log(&self, action_name: &str) -> bool {
105        // If include patterns specified, action must match at least one
106        if !self.include_patterns.is_empty() {
107            let matches_include = self
108                .include_patterns
109                .iter()
110                .any(|p| glob_match(p, action_name));
111            if !matches_include {
112                return false;
113            }
114        }
115
116        // Check exclude patterns
117        let matches_exclude = self
118            .exclude_patterns
119            .iter()
120            .any(|p| glob_match(p, action_name));
121
122        !matches_exclude
123    }
124}
125
126// ============================================================================
127// In-Memory Action Log
128// ============================================================================
129
130/// An entry in the action log
131#[derive(Debug, Clone)]
132pub struct ActionLogEntry {
133    /// Action name (from Action::name())
134    pub name: &'static str,
135    /// Summary representation (from ActionSummary::summary())
136    pub summary: String,
137    /// Timestamp when the action was logged
138    pub timestamp: Instant,
139    /// Sequence number for ordering
140    pub sequence: u64,
141    /// Whether the action caused a state change (set after reducer runs)
142    pub state_changed: Option<bool>,
143}
144
145impl ActionLogEntry {
146    /// Create a new log entry
147    pub fn new(name: &'static str, summary: String, sequence: u64) -> Self {
148        Self {
149            name,
150            summary,
151            timestamp: Instant::now(),
152            sequence,
153            state_changed: None,
154        }
155    }
156
157    /// Time since this action was logged
158    pub fn elapsed(&self) -> std::time::Duration {
159        self.timestamp.elapsed()
160    }
161
162    /// Format the elapsed time for display (e.g., "2.3s", "150ms")
163    pub fn elapsed_display(&self) -> String {
164        let elapsed = self.elapsed();
165        if elapsed.as_secs() >= 1 {
166            format!("{:.1}s", elapsed.as_secs_f64())
167        } else {
168            format!("{}ms", elapsed.as_millis())
169        }
170    }
171}
172
173/// Configuration for the action log ring buffer
174#[derive(Debug, Clone)]
175pub struct ActionLogConfig {
176    /// Maximum number of entries to keep
177    pub capacity: usize,
178    /// Filter config (reuses existing ActionLoggerConfig)
179    pub filter: ActionLoggerConfig,
180}
181
182impl Default for ActionLogConfig {
183    fn default() -> Self {
184        Self {
185            capacity: 100,
186            filter: ActionLoggerConfig::default(),
187        }
188    }
189}
190
191impl ActionLogConfig {
192    /// Create with custom capacity
193    pub fn with_capacity(capacity: usize) -> Self {
194        Self {
195            capacity,
196            ..Default::default()
197        }
198    }
199
200    /// Create with custom capacity and filter
201    pub fn new(capacity: usize, filter: ActionLoggerConfig) -> Self {
202        Self { capacity, filter }
203    }
204}
205
206/// In-memory ring buffer for storing recent actions
207///
208/// Stores actions with timestamps and summaries for display in debug overlays.
209/// Older entries are automatically discarded when capacity is reached.
210#[derive(Debug, Clone)]
211pub struct ActionLog {
212    entries: VecDeque<ActionLogEntry>,
213    config: ActionLogConfig,
214    next_sequence: u64,
215}
216
217impl Default for ActionLog {
218    fn default() -> Self {
219        Self::new(ActionLogConfig::default())
220    }
221}
222
223impl ActionLog {
224    /// Create a new action log with configuration
225    pub fn new(config: ActionLogConfig) -> Self {
226        Self {
227            entries: VecDeque::with_capacity(config.capacity),
228            config,
229            next_sequence: 0,
230        }
231    }
232
233    /// Log an action (if it passes the filter)
234    ///
235    /// Returns the entry if it was logged, None if filtered out.
236    pub fn log<A: ActionSummary>(&mut self, action: &A) -> Option<&ActionLogEntry> {
237        let name = action.name();
238
239        if !self.config.filter.should_log(name) {
240            return None;
241        }
242
243        let summary = action.summary();
244        let entry = ActionLogEntry::new(name, summary, self.next_sequence);
245        self.next_sequence += 1;
246
247        // Maintain capacity
248        if self.entries.len() >= self.config.capacity {
249            self.entries.pop_front();
250        }
251
252        self.entries.push_back(entry);
253        self.entries.back()
254    }
255
256    /// Update the last entry with state_changed info (called after reducer)
257    pub fn update_last_state_changed(&mut self, changed: bool) {
258        if let Some(entry) = self.entries.back_mut() {
259            entry.state_changed = Some(changed);
260        }
261    }
262
263    /// Get all entries (oldest first)
264    pub fn entries(&self) -> impl Iterator<Item = &ActionLogEntry> {
265        self.entries.iter()
266    }
267
268    /// Get entries in reverse order (newest first)
269    pub fn entries_rev(&self) -> impl Iterator<Item = &ActionLogEntry> {
270        self.entries.iter().rev()
271    }
272
273    /// Get the most recent N entries (newest first)
274    pub fn recent(&self, count: usize) -> impl Iterator<Item = &ActionLogEntry> {
275        self.entries.iter().rev().take(count)
276    }
277
278    /// Number of entries currently stored
279    pub fn len(&self) -> usize {
280        self.entries.len()
281    }
282
283    /// Whether the log is empty
284    pub fn is_empty(&self) -> bool {
285        self.entries.is_empty()
286    }
287
288    /// Clear all entries
289    pub fn clear(&mut self) {
290        self.entries.clear();
291    }
292
293    /// Get configuration
294    pub fn config(&self) -> &ActionLogConfig {
295        &self.config
296    }
297
298    /// Get mutable configuration
299    pub fn config_mut(&mut self) -> &mut ActionLogConfig {
300        &mut self.config
301    }
302}
303
304// ============================================================================
305// Middleware
306// ============================================================================
307
308/// Middleware that logs actions with configurable pattern filtering.
309///
310/// Supports two modes:
311/// - **Tracing only** (default): logs via `tracing::debug!()`
312/// - **With storage**: also stores in ActionLog ring buffer for overlay display
313///
314/// # Example
315///
316/// ```ignore
317/// use tui_dispatch_core::debug::{ActionLoggerConfig, ActionLoggerMiddleware, ActionLogConfig};
318/// use tui_dispatch_core::{Store, StoreWithMiddleware};
319///
320/// // Tracing only
321/// let middleware = ActionLoggerMiddleware::new(ActionLoggerConfig::default());
322///
323/// // With in-memory storage
324/// let middleware = ActionLoggerMiddleware::with_log(ActionLogConfig::default());
325///
326/// // Access the log for display
327/// if let Some(log) = middleware.log() {
328///     for entry in log.recent(10) {
329///         println!("{}", entry.summary);
330///     }
331/// }
332/// ```
333#[derive(Debug, Clone)]
334pub struct ActionLoggerMiddleware {
335    config: ActionLoggerConfig,
336    log: Option<ActionLog>,
337    /// Tracks whether the last action was logged (for state_changed updates)
338    last_action_logged: bool,
339}
340
341impl ActionLoggerMiddleware {
342    /// Create a new action logger middleware with tracing only (no in-memory storage)
343    pub fn new(config: ActionLoggerConfig) -> Self {
344        Self {
345            config,
346            log: None,
347            last_action_logged: false,
348        }
349    }
350
351    /// Create middleware with in-memory storage
352    pub fn with_log(config: ActionLogConfig) -> Self {
353        Self {
354            config: config.filter.clone(),
355            log: Some(ActionLog::new(config)),
356            last_action_logged: false,
357        }
358    }
359
360    /// Create with default config and in-memory storage
361    pub fn with_default_log() -> Self {
362        Self::with_log(ActionLogConfig::default())
363    }
364
365    /// Create with default config (excludes Tick and Render), tracing only
366    pub fn default_filtering() -> Self {
367        Self::new(ActionLoggerConfig::default())
368    }
369
370    /// Create with no filtering (logs all actions), tracing only
371    pub fn log_all() -> Self {
372        Self::new(ActionLoggerConfig::with_patterns(vec![], vec![]))
373    }
374
375    /// Get the action log (if storage is enabled)
376    pub fn log(&self) -> Option<&ActionLog> {
377        self.log.as_ref()
378    }
379
380    /// Get mutable action log
381    pub fn log_mut(&mut self) -> Option<&mut ActionLog> {
382        self.log.as_mut()
383    }
384
385    /// Get a reference to the config
386    pub fn config(&self) -> &ActionLoggerConfig {
387        &self.config
388    }
389
390    /// Get a mutable reference to the config
391    pub fn config_mut(&mut self) -> &mut ActionLoggerConfig {
392        &mut self.config
393    }
394}
395
396impl<A: ActionSummary> Middleware<A> for ActionLoggerMiddleware {
397    fn before(&mut self, action: &A) {
398        let name = action.name();
399
400        // Always log to tracing if filter passes
401        if self.config.should_log(name) {
402            tracing::debug!(action = %name, "action");
403        }
404
405        // Log to in-memory buffer if enabled
406        self.last_action_logged = false;
407        if let Some(ref mut log) = self.log {
408            if log.log(action).is_some() {
409                self.last_action_logged = true;
410            }
411        }
412    }
413
414    fn after(&mut self, _action: &A, state_changed: bool) {
415        // Only update state_changed if this action was actually logged
416        if self.last_action_logged {
417            if let Some(ref mut log) = self.log {
418                log.update_last_state_changed(state_changed);
419            }
420        }
421    }
422}
423
424/// Simple glob pattern matching supporting `*` and `?`.
425///
426/// - `*` matches zero or more characters
427/// - `?` matches exactly one character
428pub fn glob_match(pattern: &str, text: &str) -> bool {
429    let pattern: Vec<char> = pattern.chars().collect();
430    let text: Vec<char> = text.chars().collect();
431    glob_match_impl(&pattern, &text)
432}
433
434fn glob_match_impl(pattern: &[char], text: &[char]) -> bool {
435    let mut pi = 0;
436    let mut ti = 0;
437    let mut star_pi = None;
438    let mut star_ti = 0;
439
440    while ti < text.len() {
441        if pi < pattern.len() && (pattern[pi] == '?' || pattern[pi] == text[ti]) {
442            pi += 1;
443            ti += 1;
444        } else if pi < pattern.len() && pattern[pi] == '*' {
445            star_pi = Some(pi);
446            star_ti = ti;
447            pi += 1;
448        } else if let Some(spi) = star_pi {
449            pi = spi + 1;
450            star_ti += 1;
451            ti = star_ti;
452        } else {
453            return false;
454        }
455    }
456
457    while pi < pattern.len() && pattern[pi] == '*' {
458        pi += 1;
459    }
460
461    pi == pattern.len()
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_glob_match_exact() {
470        assert!(glob_match("Tick", "Tick"));
471        assert!(!glob_match("Tick", "Tock"));
472        assert!(!glob_match("Tick", "TickTock"));
473    }
474
475    #[test]
476    fn test_glob_match_star() {
477        assert!(glob_match("Search*", "SearchAddChar"));
478        assert!(glob_match("Search*", "SearchDeleteChar"));
479        assert!(glob_match("Search*", "Search"));
480        assert!(!glob_match("Search*", "StartSearch"));
481
482        assert!(glob_match("*Search", "StartSearch"));
483        assert!(glob_match("*Search*", "StartSearchNow"));
484
485        assert!(glob_match("Did*", "DidConnect"));
486        assert!(glob_match("Did*", "DidScanKeys"));
487    }
488
489    #[test]
490    fn test_glob_match_question() {
491        assert!(glob_match("Tick?", "Ticks"));
492        assert!(!glob_match("Tick?", "Tick"));
493        assert!(!glob_match("Tick?", "Tickss"));
494    }
495
496    #[test]
497    fn test_glob_match_combined() {
498        assert!(glob_match("*Add*", "SearchAddChar"));
499        assert!(glob_match("Connection*Add*", "ConnectionFormAddChar"));
500    }
501
502    #[test]
503    fn test_action_logger_config_include() {
504        let config = ActionLoggerConfig::new(Some("Search*,Connect"), None);
505        assert!(config.should_log("SearchAddChar"));
506        assert!(config.should_log("Connect"));
507        assert!(!config.should_log("Tick"));
508        assert!(!config.should_log("LoadKeys"));
509    }
510
511    #[test]
512    fn test_action_logger_config_exclude() {
513        let config = ActionLoggerConfig::new(None, Some("Tick,Render,LoadValue*"));
514        assert!(!config.should_log("Tick"));
515        assert!(!config.should_log("Render"));
516        assert!(!config.should_log("LoadValueDebounced"));
517        assert!(config.should_log("SearchAddChar"));
518        assert!(config.should_log("Connect"));
519    }
520
521    #[test]
522    fn test_action_logger_config_include_and_exclude() {
523        // Include Did* but exclude DidFail*
524        let config = ActionLoggerConfig::new(Some("Did*"), Some("DidFail*"));
525        assert!(config.should_log("DidConnect"));
526        assert!(config.should_log("DidScanKeys"));
527        assert!(!config.should_log("DidFailConnect"));
528        assert!(!config.should_log("DidFailScanKeys"));
529        assert!(!config.should_log("SearchAddChar")); // Not in include
530    }
531
532    #[test]
533    fn test_action_logger_config_default() {
534        let config = ActionLoggerConfig::default();
535        assert!(!config.should_log("Tick"));
536        assert!(!config.should_log("Render"));
537        assert!(config.should_log("Connect"));
538        assert!(config.should_log("SearchAddChar"));
539    }
540
541    // Test action for ActionLog tests
542    #[derive(Clone, Debug)]
543    enum TestAction {
544        Tick,
545        Connect,
546    }
547
548    impl crate::Action for TestAction {
549        fn name(&self) -> &'static str {
550            match self {
551                TestAction::Tick => "Tick",
552                TestAction::Connect => "Connect",
553            }
554        }
555    }
556
557    // Use default summary (Debug)
558    impl crate::ActionSummary for TestAction {}
559
560    #[test]
561    fn test_action_log_basic() {
562        let mut log = ActionLog::default();
563        assert!(log.is_empty());
564
565        log.log(&TestAction::Connect);
566        assert_eq!(log.len(), 1);
567
568        let entry = log.entries().next().unwrap();
569        assert_eq!(entry.name, "Connect");
570        assert_eq!(entry.sequence, 0);
571    }
572
573    #[test]
574    fn test_action_log_filtering() {
575        let mut log = ActionLog::default(); // Default excludes Tick
576
577        log.log(&TestAction::Tick);
578        assert!(log.is_empty()); // Tick should be filtered
579
580        log.log(&TestAction::Connect);
581        assert_eq!(log.len(), 1);
582    }
583
584    #[test]
585    fn test_action_log_capacity() {
586        let config = ActionLogConfig::new(
587            3,
588            ActionLoggerConfig::with_patterns(vec![], vec![]), // No filtering
589        );
590        let mut log = ActionLog::new(config);
591
592        log.log(&TestAction::Connect);
593        log.log(&TestAction::Connect);
594        log.log(&TestAction::Connect);
595        assert_eq!(log.len(), 3);
596
597        log.log(&TestAction::Connect);
598        assert_eq!(log.len(), 3); // Still 3, oldest was removed
599
600        // First entry should now be sequence 1 (sequence 0 was removed)
601        assert_eq!(log.entries().next().unwrap().sequence, 1);
602    }
603
604    #[test]
605    fn test_action_log_state_changed() {
606        let mut log = ActionLog::default();
607
608        log.log(&TestAction::Connect);
609        log.update_last_state_changed(true);
610
611        let entry = log.entries().next().unwrap();
612        assert_eq!(entry.state_changed, Some(true));
613    }
614
615    #[test]
616    fn test_action_log_recent() {
617        let config = ActionLogConfig::new(10, ActionLoggerConfig::with_patterns(vec![], vec![]));
618        let mut log = ActionLog::new(config);
619
620        for _ in 0..5 {
621            log.log(&TestAction::Connect);
622        }
623
624        // Recent should return newest first
625        let recent: Vec<_> = log.recent(3).collect();
626        assert_eq!(recent.len(), 3);
627        assert_eq!(recent[0].sequence, 4); // Newest
628        assert_eq!(recent[1].sequence, 3);
629        assert_eq!(recent[2].sequence, 2);
630    }
631
632    #[test]
633    fn test_action_log_entry_elapsed_display() {
634        let entry = ActionLogEntry::new("Test", "Test".to_string(), 0);
635        // Should show "0ms" or similar for a fresh entry
636        let display = entry.elapsed_display();
637        assert!(display.ends_with("ms") || display.ends_with("s"));
638    }
639
640    #[test]
641    fn test_middleware_filtered_action_does_not_update_state_changed() {
642        use crate::store::Middleware;
643
644        // Default config filters out "Tick"
645        let mut middleware = ActionLoggerMiddleware::with_default_log();
646
647        // Log a Connect action first
648        middleware.before(&TestAction::Connect);
649        middleware.after(&TestAction::Connect, true);
650
651        // Verify Connect was logged with state_changed = true
652        let log = middleware.log().unwrap();
653        assert_eq!(log.len(), 1);
654        assert_eq!(log.entries().next().unwrap().state_changed, Some(true));
655
656        // Now dispatch a Tick (filtered out by default)
657        middleware.before(&TestAction::Tick);
658        middleware.after(&TestAction::Tick, false);
659
660        // Log should still have only 1 entry (Tick was filtered)
661        let log = middleware.log().unwrap();
662        assert_eq!(log.len(), 1);
663
664        // The Connect entry's state_changed should still be true
665        // (not overwritten by the filtered Tick's state_changed=false)
666        assert_eq!(log.entries().next().unwrap().state_changed, Some(true));
667    }
668}