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<S, A: ActionParams> Middleware<S, A> for ActionLoggerMiddleware {
415    fn before(&mut self, action: &A, _state: &S) -> bool {
416        // Inactive: no-op
417        if !self.active {
418            return true;
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        true
434    }
435
436    fn after(&mut self, _action: &A, _state_changed: bool, _state: &S) -> Vec<A> {
437        vec![]
438    }
439}
440
441/// Simple glob pattern matching supporting `*` and `?`.
442///
443/// - `*` matches zero or more characters
444/// - `?` matches exactly one character
445pub fn glob_match(pattern: &str, text: &str) -> bool {
446    let pattern: Vec<char> = pattern.chars().collect();
447    let text: Vec<char> = text.chars().collect();
448    glob_match_impl(&pattern, &text)
449}
450
451fn glob_match_impl(pattern: &[char], text: &[char]) -> bool {
452    let mut pi = 0;
453    let mut ti = 0;
454    let mut star_pi = None;
455    let mut star_ti = 0;
456
457    while ti < text.len() {
458        if pi < pattern.len() && (pattern[pi] == '?' || pattern[pi] == text[ti]) {
459            pi += 1;
460            ti += 1;
461        } else if pi < pattern.len() && pattern[pi] == '*' {
462            star_pi = Some(pi);
463            star_ti = ti;
464            pi += 1;
465        } else if let Some(spi) = star_pi {
466            pi = spi + 1;
467            star_ti += 1;
468            ti = star_ti;
469        } else {
470            return false;
471        }
472    }
473
474    while pi < pattern.len() && pattern[pi] == '*' {
475        pi += 1;
476    }
477
478    pi == pattern.len()
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn test_glob_match_exact() {
487        assert!(glob_match("Tick", "Tick"));
488        assert!(!glob_match("Tick", "Tock"));
489        assert!(!glob_match("Tick", "TickTock"));
490    }
491
492    #[test]
493    fn test_glob_match_star() {
494        assert!(glob_match("Search*", "SearchAddChar"));
495        assert!(glob_match("Search*", "SearchDeleteChar"));
496        assert!(glob_match("Search*", "Search"));
497        assert!(!glob_match("Search*", "StartSearch"));
498
499        assert!(glob_match("*Search", "StartSearch"));
500        assert!(glob_match("*Search*", "StartSearchNow"));
501
502        assert!(glob_match("Did*", "DidConnect"));
503        assert!(glob_match("Did*", "DidScanKeys"));
504    }
505
506    #[test]
507    fn test_glob_match_question() {
508        assert!(glob_match("Tick?", "Ticks"));
509        assert!(!glob_match("Tick?", "Tick"));
510        assert!(!glob_match("Tick?", "Tickss"));
511    }
512
513    #[test]
514    fn test_glob_match_combined() {
515        assert!(glob_match("*Add*", "SearchAddChar"));
516        assert!(glob_match("Connection*Add*", "ConnectionFormAddChar"));
517    }
518
519    #[test]
520    fn test_action_logger_config_include() {
521        let config = ActionLoggerConfig::new(Some("Search*,Connect"), None);
522        assert!(config.should_log("SearchAddChar"));
523        assert!(config.should_log("Connect"));
524        assert!(!config.should_log("Tick"));
525        assert!(!config.should_log("LoadKeys"));
526    }
527
528    #[test]
529    fn test_action_logger_config_exclude() {
530        let config = ActionLoggerConfig::new(None, Some("Tick,Render,LoadValue*"));
531        assert!(!config.should_log("Tick"));
532        assert!(!config.should_log("Render"));
533        assert!(!config.should_log("LoadValueDebounced"));
534        assert!(config.should_log("SearchAddChar"));
535        assert!(config.should_log("Connect"));
536    }
537
538    #[test]
539    fn test_action_logger_config_include_and_exclude() {
540        // Include Did* but exclude DidFail*
541        let config = ActionLoggerConfig::new(Some("Did*"), Some("DidFail*"));
542        assert!(config.should_log("DidConnect"));
543        assert!(config.should_log("DidScanKeys"));
544        assert!(!config.should_log("DidFailConnect"));
545        assert!(!config.should_log("DidFailScanKeys"));
546        assert!(!config.should_log("SearchAddChar")); // Not in include
547    }
548
549    #[test]
550    fn test_action_logger_config_default() {
551        let config = ActionLoggerConfig::default();
552        assert!(!config.should_log("Tick"));
553        assert!(!config.should_log("Render"));
554        assert!(config.should_log("Connect"));
555        assert!(config.should_log("SearchAddChar"));
556    }
557
558    // Test action for ActionLog tests
559    #[derive(Clone, Debug)]
560    enum TestAction {
561        Tick,
562        Connect,
563    }
564
565    impl tui_dispatch_core::Action for TestAction {
566        fn name(&self) -> &'static str {
567            match self {
568                TestAction::Tick => "Tick",
569                TestAction::Connect => "Connect",
570            }
571        }
572    }
573
574    impl tui_dispatch_core::ActionParams for TestAction {
575        fn params(&self) -> String {
576            String::new()
577        }
578    }
579
580    #[test]
581    fn test_action_log_basic() {
582        let mut log = ActionLog::default();
583        assert!(log.is_empty());
584
585        log.log(&TestAction::Connect);
586        assert_eq!(log.len(), 1);
587
588        let entry = log.entries().next().unwrap();
589        assert_eq!(entry.name, "Connect");
590        assert_eq!(entry.sequence, 0);
591    }
592
593    #[test]
594    fn test_action_log_filtering() {
595        let mut log = ActionLog::default(); // Default excludes Tick
596
597        log.log(&TestAction::Tick);
598        assert!(log.is_empty()); // Tick should be filtered
599
600        log.log(&TestAction::Connect);
601        assert_eq!(log.len(), 1);
602    }
603
604    #[test]
605    fn test_action_log_capacity() {
606        let config = ActionLogConfig::new(
607            3,
608            ActionLoggerConfig::with_patterns(vec![], vec![]), // No filtering
609        );
610        let mut log = ActionLog::new(config);
611
612        log.log(&TestAction::Connect);
613        log.log(&TestAction::Connect);
614        log.log(&TestAction::Connect);
615        assert_eq!(log.len(), 3);
616
617        log.log(&TestAction::Connect);
618        assert_eq!(log.len(), 3); // Still 3, oldest was removed
619
620        // First entry should now be sequence 1 (sequence 0 was removed)
621        assert_eq!(log.entries().next().unwrap().sequence, 1);
622    }
623
624    #[test]
625    fn test_action_log_recent() {
626        let config = ActionLogConfig::new(10, ActionLoggerConfig::with_patterns(vec![], vec![]));
627        let mut log = ActionLog::new(config);
628
629        for _ in 0..5 {
630            log.log(&TestAction::Connect);
631        }
632
633        // Recent should return newest first
634        let recent: Vec<_> = log.recent(3).collect();
635        assert_eq!(recent.len(), 3);
636        assert_eq!(recent[0].sequence, 4); // Newest
637        assert_eq!(recent[1].sequence, 3);
638        assert_eq!(recent[2].sequence, 2);
639    }
640
641    #[test]
642    fn test_action_log_entry_elapsed() {
643        let entry = ActionLogEntry::new(
644            "Test",
645            "test_params".to_string(),
646            "test_params".to_string(),
647            0,
648        );
649        // Should have "0ms" as default elapsed
650        assert_eq!(entry.elapsed, "0ms");
651    }
652
653    #[test]
654    fn test_middleware_filtering() {
655        use tui_dispatch_core::store::Middleware;
656
657        // Default config filters out "Tick"
658        let mut middleware = ActionLoggerMiddleware::with_default_log();
659
660        // Log a Connect action
661        middleware.before(&TestAction::Connect, &());
662        middleware.after(&TestAction::Connect, true, &());
663
664        // Verify Connect was logged
665        let log = middleware.log().unwrap();
666        assert_eq!(log.len(), 1);
667
668        // Now dispatch a Tick (filtered out by default)
669        middleware.before(&TestAction::Tick, &());
670        middleware.after(&TestAction::Tick, false, &());
671
672        // Log should still have only 1 entry (Tick was filtered)
673        let log = middleware.log().unwrap();
674        assert_eq!(log.len(), 1);
675    }
676}