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, entry.params);
24//!     }
25//! }
26//! ```
27
28use crate::action::ActionParams;
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    /// Action parameters (from ActionParams::params())
136    pub params: String,
137    /// Timestamp when the action was logged
138    pub timestamp: Instant,
139    /// Elapsed time display, frozen at creation time
140    pub elapsed: String,
141    /// Sequence number for ordering
142    pub sequence: u64,
143}
144
145impl ActionLogEntry {
146    /// Create a new log entry
147    pub fn new(name: &'static str, params: String, sequence: u64) -> Self {
148        Self {
149            name,
150            params,
151            timestamp: Instant::now(),
152            elapsed: "0ms".to_string(),
153            sequence,
154        }
155    }
156}
157
158/// Format elapsed time for display (e.g., "2.3s", "150ms")
159fn format_elapsed(elapsed: std::time::Duration) -> String {
160    if elapsed.as_secs() >= 1 {
161        format!("{:.1}s", elapsed.as_secs_f64())
162    } else {
163        format!("{}ms", elapsed.as_millis())
164    }
165}
166
167/// Configuration for the action log ring buffer
168#[derive(Debug, Clone)]
169pub struct ActionLogConfig {
170    /// Maximum number of entries to keep
171    pub capacity: usize,
172    /// Filter config (reuses existing ActionLoggerConfig)
173    pub filter: ActionLoggerConfig,
174}
175
176impl Default for ActionLogConfig {
177    fn default() -> Self {
178        Self {
179            capacity: 100,
180            filter: ActionLoggerConfig::default(),
181        }
182    }
183}
184
185impl ActionLogConfig {
186    /// Create with custom capacity
187    pub fn with_capacity(capacity: usize) -> Self {
188        Self {
189            capacity,
190            ..Default::default()
191        }
192    }
193
194    /// Create with custom capacity and filter
195    pub fn new(capacity: usize, filter: ActionLoggerConfig) -> Self {
196        Self { capacity, filter }
197    }
198}
199
200/// In-memory ring buffer for storing recent actions
201///
202/// Stores actions with timestamps and parameters for display in debug overlays.
203/// Older entries are automatically discarded when capacity is reached.
204#[derive(Debug, Clone)]
205pub struct ActionLog {
206    entries: VecDeque<ActionLogEntry>,
207    config: ActionLogConfig,
208    next_sequence: u64,
209    /// Time when the log was created (for relative elapsed times)
210    start_time: Instant,
211}
212
213impl Default for ActionLog {
214    fn default() -> Self {
215        Self::new(ActionLogConfig::default())
216    }
217}
218
219impl ActionLog {
220    /// Create a new action log with configuration
221    pub fn new(config: ActionLogConfig) -> Self {
222        Self {
223            entries: VecDeque::with_capacity(config.capacity),
224            config,
225            next_sequence: 0,
226            start_time: Instant::now(),
227        }
228    }
229
230    /// Log an action (if it passes the filter)
231    ///
232    /// Returns the entry if it was logged, None if filtered out.
233    pub fn log<A: ActionParams>(&mut self, action: &A) -> Option<&ActionLogEntry> {
234        let name = action.name();
235
236        if !self.config.filter.should_log(name) {
237            return None;
238        }
239
240        let params = action.params();
241        let mut entry = ActionLogEntry::new(name, params, self.next_sequence);
242        // Freeze the elapsed time at creation
243        entry.elapsed = format_elapsed(self.start_time.elapsed());
244        self.next_sequence += 1;
245
246        // Maintain capacity
247        if self.entries.len() >= self.config.capacity {
248            self.entries.pop_front();
249        }
250
251        self.entries.push_back(entry);
252        self.entries.back()
253    }
254
255    /// Get all entries (oldest first)
256    pub fn entries(&self) -> impl Iterator<Item = &ActionLogEntry> {
257        self.entries.iter()
258    }
259
260    /// Get entries in reverse order (newest first)
261    pub fn entries_rev(&self) -> impl Iterator<Item = &ActionLogEntry> {
262        self.entries.iter().rev()
263    }
264
265    /// Get the most recent N entries (newest first)
266    pub fn recent(&self, count: usize) -> impl Iterator<Item = &ActionLogEntry> {
267        self.entries.iter().rev().take(count)
268    }
269
270    /// Number of entries currently stored
271    pub fn len(&self) -> usize {
272        self.entries.len()
273    }
274
275    /// Whether the log is empty
276    pub fn is_empty(&self) -> bool {
277        self.entries.is_empty()
278    }
279
280    /// Clear all entries
281    pub fn clear(&mut self) {
282        self.entries.clear();
283    }
284
285    /// Get configuration
286    pub fn config(&self) -> &ActionLogConfig {
287        &self.config
288    }
289
290    /// Get mutable configuration
291    pub fn config_mut(&mut self) -> &mut ActionLogConfig {
292        &mut self.config
293    }
294}
295
296// ============================================================================
297// Middleware
298// ============================================================================
299
300/// Middleware that logs actions with configurable pattern filtering.
301///
302/// Supports two modes:
303/// - **Tracing only** (default): logs via `tracing::debug!()`
304/// - **With storage**: also stores in ActionLog ring buffer for overlay display
305///
306/// # Example
307///
308/// ```ignore
309/// use tui_dispatch_core::debug::{ActionLoggerConfig, ActionLoggerMiddleware, ActionLogConfig};
310/// use tui_dispatch_core::{Store, StoreWithMiddleware};
311///
312/// // Tracing only
313/// let middleware = ActionLoggerMiddleware::new(ActionLoggerConfig::default());
314///
315/// // With in-memory storage
316/// let middleware = ActionLoggerMiddleware::with_log(ActionLogConfig::default());
317///
318/// // Access the log for display
319/// if let Some(log) = middleware.log() {
320///     for entry in log.recent(10) {
321///         println!("{}", entry.params);
322///     }
323/// }
324/// ```
325#[derive(Debug, Clone)]
326pub struct ActionLoggerMiddleware {
327    config: ActionLoggerConfig,
328    log: Option<ActionLog>,
329    /// Whether the middleware is active (processes actions)
330    /// When false, all methods become no-ops for zero overhead.
331    active: bool,
332}
333
334impl ActionLoggerMiddleware {
335    /// Create a new action logger middleware with tracing only (no in-memory storage)
336    pub fn new(config: ActionLoggerConfig) -> Self {
337        Self {
338            config,
339            log: None,
340            active: true,
341        }
342    }
343
344    /// Create middleware with in-memory storage
345    pub fn with_log(config: ActionLogConfig) -> Self {
346        Self {
347            config: config.filter.clone(),
348            log: Some(ActionLog::new(config)),
349            active: true,
350        }
351    }
352
353    /// Create with default config and in-memory storage
354    pub fn with_default_log() -> Self {
355        Self::with_log(ActionLogConfig::default())
356    }
357
358    /// Create with default config (excludes Tick and Render), tracing only
359    pub fn default_filtering() -> Self {
360        Self::new(ActionLoggerConfig::default())
361    }
362
363    /// Create with no filtering (logs all actions), tracing only
364    pub fn log_all() -> Self {
365        Self::new(ActionLoggerConfig::with_patterns(vec![], vec![]))
366    }
367
368    /// Set whether the middleware is active.
369    ///
370    /// When inactive (`false`), all methods become no-ops with zero overhead.
371    /// This is useful for conditional logging based on CLI flags.
372    ///
373    /// # Example
374    ///
375    /// ```ignore
376    /// let middleware = ActionLoggerMiddleware::default_filtering()
377    ///     .active(args.debug);  // Only log if --debug flag passed
378    /// ```
379    pub fn active(mut self, active: bool) -> Self {
380        self.active = active;
381        self
382    }
383
384    /// Check if the middleware is active.
385    pub fn is_active(&self) -> bool {
386        self.active
387    }
388
389    /// Get the action log (if storage is enabled)
390    pub fn log(&self) -> Option<&ActionLog> {
391        self.log.as_ref()
392    }
393
394    /// Get mutable action log
395    pub fn log_mut(&mut self) -> Option<&mut ActionLog> {
396        self.log.as_mut()
397    }
398
399    /// Get a reference to the config
400    pub fn config(&self) -> &ActionLoggerConfig {
401        &self.config
402    }
403
404    /// Get a mutable reference to the config
405    pub fn config_mut(&mut self) -> &mut ActionLoggerConfig {
406        &mut self.config
407    }
408}
409
410impl<A: ActionParams> Middleware<A> for ActionLoggerMiddleware {
411    fn before(&mut self, action: &A) {
412        // Inactive: no-op
413        if !self.active {
414            return;
415        }
416
417        let name = action.name();
418
419        // Always log to tracing if filter passes
420        if self.config.should_log(name) {
421            tracing::debug!(action = %name, "action");
422        }
423
424        // Log to in-memory buffer if enabled
425        if let Some(ref mut log) = self.log {
426            log.log(action);
427        }
428    }
429
430    fn after(&mut self, _action: &A, _state_changed: bool) {
431        // No-op - we no longer track state_changed
432    }
433}
434
435/// Simple glob pattern matching supporting `*` and `?`.
436///
437/// - `*` matches zero or more characters
438/// - `?` matches exactly one character
439pub fn glob_match(pattern: &str, text: &str) -> bool {
440    let pattern: Vec<char> = pattern.chars().collect();
441    let text: Vec<char> = text.chars().collect();
442    glob_match_impl(&pattern, &text)
443}
444
445fn glob_match_impl(pattern: &[char], text: &[char]) -> bool {
446    let mut pi = 0;
447    let mut ti = 0;
448    let mut star_pi = None;
449    let mut star_ti = 0;
450
451    while ti < text.len() {
452        if pi < pattern.len() && (pattern[pi] == '?' || pattern[pi] == text[ti]) {
453            pi += 1;
454            ti += 1;
455        } else if pi < pattern.len() && pattern[pi] == '*' {
456            star_pi = Some(pi);
457            star_ti = ti;
458            pi += 1;
459        } else if let Some(spi) = star_pi {
460            pi = spi + 1;
461            star_ti += 1;
462            ti = star_ti;
463        } else {
464            return false;
465        }
466    }
467
468    while pi < pattern.len() && pattern[pi] == '*' {
469        pi += 1;
470    }
471
472    pi == pattern.len()
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn test_glob_match_exact() {
481        assert!(glob_match("Tick", "Tick"));
482        assert!(!glob_match("Tick", "Tock"));
483        assert!(!glob_match("Tick", "TickTock"));
484    }
485
486    #[test]
487    fn test_glob_match_star() {
488        assert!(glob_match("Search*", "SearchAddChar"));
489        assert!(glob_match("Search*", "SearchDeleteChar"));
490        assert!(glob_match("Search*", "Search"));
491        assert!(!glob_match("Search*", "StartSearch"));
492
493        assert!(glob_match("*Search", "StartSearch"));
494        assert!(glob_match("*Search*", "StartSearchNow"));
495
496        assert!(glob_match("Did*", "DidConnect"));
497        assert!(glob_match("Did*", "DidScanKeys"));
498    }
499
500    #[test]
501    fn test_glob_match_question() {
502        assert!(glob_match("Tick?", "Ticks"));
503        assert!(!glob_match("Tick?", "Tick"));
504        assert!(!glob_match("Tick?", "Tickss"));
505    }
506
507    #[test]
508    fn test_glob_match_combined() {
509        assert!(glob_match("*Add*", "SearchAddChar"));
510        assert!(glob_match("Connection*Add*", "ConnectionFormAddChar"));
511    }
512
513    #[test]
514    fn test_action_logger_config_include() {
515        let config = ActionLoggerConfig::new(Some("Search*,Connect"), None);
516        assert!(config.should_log("SearchAddChar"));
517        assert!(config.should_log("Connect"));
518        assert!(!config.should_log("Tick"));
519        assert!(!config.should_log("LoadKeys"));
520    }
521
522    #[test]
523    fn test_action_logger_config_exclude() {
524        let config = ActionLoggerConfig::new(None, Some("Tick,Render,LoadValue*"));
525        assert!(!config.should_log("Tick"));
526        assert!(!config.should_log("Render"));
527        assert!(!config.should_log("LoadValueDebounced"));
528        assert!(config.should_log("SearchAddChar"));
529        assert!(config.should_log("Connect"));
530    }
531
532    #[test]
533    fn test_action_logger_config_include_and_exclude() {
534        // Include Did* but exclude DidFail*
535        let config = ActionLoggerConfig::new(Some("Did*"), Some("DidFail*"));
536        assert!(config.should_log("DidConnect"));
537        assert!(config.should_log("DidScanKeys"));
538        assert!(!config.should_log("DidFailConnect"));
539        assert!(!config.should_log("DidFailScanKeys"));
540        assert!(!config.should_log("SearchAddChar")); // Not in include
541    }
542
543    #[test]
544    fn test_action_logger_config_default() {
545        let config = ActionLoggerConfig::default();
546        assert!(!config.should_log("Tick"));
547        assert!(!config.should_log("Render"));
548        assert!(config.should_log("Connect"));
549        assert!(config.should_log("SearchAddChar"));
550    }
551
552    // Test action for ActionLog tests
553    #[derive(Clone, Debug)]
554    enum TestAction {
555        Tick,
556        Connect,
557    }
558
559    impl crate::Action for TestAction {
560        fn name(&self) -> &'static str {
561            match self {
562                TestAction::Tick => "Tick",
563                TestAction::Connect => "Connect",
564            }
565        }
566    }
567
568    impl crate::ActionParams for TestAction {
569        fn params(&self) -> String {
570            String::new()
571        }
572    }
573
574    #[test]
575    fn test_action_log_basic() {
576        let mut log = ActionLog::default();
577        assert!(log.is_empty());
578
579        log.log(&TestAction::Connect);
580        assert_eq!(log.len(), 1);
581
582        let entry = log.entries().next().unwrap();
583        assert_eq!(entry.name, "Connect");
584        assert_eq!(entry.sequence, 0);
585    }
586
587    #[test]
588    fn test_action_log_filtering() {
589        let mut log = ActionLog::default(); // Default excludes Tick
590
591        log.log(&TestAction::Tick);
592        assert!(log.is_empty()); // Tick should be filtered
593
594        log.log(&TestAction::Connect);
595        assert_eq!(log.len(), 1);
596    }
597
598    #[test]
599    fn test_action_log_capacity() {
600        let config = ActionLogConfig::new(
601            3,
602            ActionLoggerConfig::with_patterns(vec![], vec![]), // No filtering
603        );
604        let mut log = ActionLog::new(config);
605
606        log.log(&TestAction::Connect);
607        log.log(&TestAction::Connect);
608        log.log(&TestAction::Connect);
609        assert_eq!(log.len(), 3);
610
611        log.log(&TestAction::Connect);
612        assert_eq!(log.len(), 3); // Still 3, oldest was removed
613
614        // First entry should now be sequence 1 (sequence 0 was removed)
615        assert_eq!(log.entries().next().unwrap().sequence, 1);
616    }
617
618    #[test]
619    fn test_action_log_recent() {
620        let config = ActionLogConfig::new(10, ActionLoggerConfig::with_patterns(vec![], vec![]));
621        let mut log = ActionLog::new(config);
622
623        for _ in 0..5 {
624            log.log(&TestAction::Connect);
625        }
626
627        // Recent should return newest first
628        let recent: Vec<_> = log.recent(3).collect();
629        assert_eq!(recent.len(), 3);
630        assert_eq!(recent[0].sequence, 4); // Newest
631        assert_eq!(recent[1].sequence, 3);
632        assert_eq!(recent[2].sequence, 2);
633    }
634
635    #[test]
636    fn test_action_log_entry_elapsed() {
637        let entry = ActionLogEntry::new("Test", "test_params".to_string(), 0);
638        // Should have "0ms" as default elapsed
639        assert_eq!(entry.elapsed, "0ms");
640    }
641
642    #[test]
643    fn test_middleware_filtering() {
644        use crate::store::Middleware;
645
646        // Default config filters out "Tick"
647        let mut middleware = ActionLoggerMiddleware::with_default_log();
648
649        // Log a Connect action
650        middleware.before(&TestAction::Connect);
651        middleware.after(&TestAction::Connect, true);
652
653        // Verify Connect was logged
654        let log = middleware.log().unwrap();
655        assert_eq!(log.len(), 1);
656
657        // Now dispatch a Tick (filtered out by default)
658        middleware.before(&TestAction::Tick);
659        middleware.after(&TestAction::Tick, false);
660
661        // Log should still have only 1 entry (Tick was filtered)
662        let log = middleware.log().unwrap();
663        assert_eq!(log.len(), 1);
664    }
665}