Skip to main content

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