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;
32use tui_dispatch_shared::infer_action_category;
33
34use crate::pattern_utils::split_patterns_csv;
35
36/// Configuration for action logging with include/exclude filtering.
37///
38/// Patterns support:
39/// - `*` matches any sequence of characters
40/// - `?` matches any single character
41/// - Literal text matches exactly
42/// - `cat:<pattern>` / `category:<pattern>` matches inferred action category
43/// - `name:<ActionName>` matches exact action name (no wildcards)
44///
45/// # Examples
46///
47/// - `Search*` matches SearchAddChar, SearchDeleteChar, etc.
48/// - `Did*` matches DidConnect, DidScanKeys, etc.
49/// - `*Error*` matches any action containing "Error"
50/// - `Tick` matches only Tick
51/// - `cat:search` matches `SearchStart`, `SearchSubmit`, etc.
52/// - `name:WeatherDidLoad` matches only `WeatherDidLoad`
53/// - `WeatherDid*` uses normal glob name matching (without `name:` prefix)
54#[derive(Debug, Clone)]
55pub struct ActionLoggerConfig {
56    /// If non-empty, only log actions matching these patterns
57    pub include_patterns: Vec<String>,
58    /// Exclude actions matching these patterns (applied after include)
59    pub exclude_patterns: Vec<String>,
60}
61
62impl Default for ActionLoggerConfig {
63    fn default() -> Self {
64        Self {
65            include_patterns: Vec::new(),
66            // By default, exclude noisy high-frequency actions
67            exclude_patterns: vec!["Tick".to_string(), "Render".to_string()],
68        }
69    }
70}
71
72impl ActionLoggerConfig {
73    /// Create a new config from comma-separated pattern strings
74    ///
75    /// # Arguments
76    /// - `include`: comma-separated glob patterns (or None for all)
77    /// - `exclude`: comma-separated glob patterns (or None for default excludes)
78    ///
79    /// # Example
80    /// ```
81    /// use tui_dispatch_debug::debug::ActionLoggerConfig;
82    ///
83    /// let config = ActionLoggerConfig::new(Some("Search*,Connect"), Some("Tick,Render"));
84    /// assert!(config.should_log("SearchAddChar"));
85    /// assert!(config.should_log("Connect"));
86    /// assert!(!config.should_log("Tick"));
87    /// ```
88    pub fn new(include: Option<&str>, exclude: Option<&str>) -> Self {
89        let include_patterns = include.map(split_patterns_csv).unwrap_or_default();
90
91        let exclude_patterns = exclude
92            .map(split_patterns_csv)
93            .unwrap_or_else(|| vec!["Tick".to_string(), "Render".to_string()]);
94
95        Self {
96            include_patterns,
97            exclude_patterns,
98        }
99    }
100
101    /// Create a config with specific pattern vectors
102    pub fn with_patterns(include: Vec<String>, exclude: Vec<String>) -> Self {
103        Self {
104            include_patterns: include,
105            exclude_patterns: exclude,
106        }
107    }
108
109    /// Check if an action name should be logged based on include/exclude patterns
110    pub fn should_log(&self, action_name: &str) -> bool {
111        // If include patterns specified, action must match at least one
112        if !self.include_patterns.is_empty() {
113            let matches_include = self
114                .include_patterns
115                .iter()
116                .any(|p| filter_match(p, action_name));
117            if !matches_include {
118                return false;
119            }
120        }
121
122        // Check exclude patterns
123        let matches_exclude = self
124            .exclude_patterns
125            .iter()
126            .any(|p| filter_match(p, action_name));
127
128        !matches_exclude
129    }
130}
131
132fn filter_match(pattern: &str, action_name: &str) -> bool {
133    let pattern = pattern.trim();
134    if pattern.is_empty() {
135        return false;
136    }
137
138    if let Some(category_pattern) =
139        strip_filter_prefix(pattern, "cat:").or_else(|| strip_filter_prefix(pattern, "category:"))
140    {
141        let category_pattern = category_pattern.trim();
142        if category_pattern.is_empty() {
143            return false;
144        }
145        return infer_action_category(action_name)
146            .as_deref()
147            .is_some_and(|category| glob_match(category_pattern, category));
148    }
149
150    if let Some(action_name_pattern) = strip_filter_prefix(pattern, "name:") {
151        let action_name_pattern = action_name_pattern.trim();
152        if action_name_pattern.is_empty() {
153            return false;
154        }
155        // `name:` is intentionally exact-match only; for wildcard name matching,
156        // use a plain glob pattern without the prefix.
157        return action_name_pattern == action_name;
158    }
159
160    glob_match(pattern, action_name)
161}
162
163fn strip_filter_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
164    if value.len() < prefix.len() {
165        return None;
166    }
167    let (head, tail) = value.split_at(prefix.len());
168    if head.eq_ignore_ascii_case(prefix) {
169        Some(tail)
170    } else {
171        None
172    }
173}
174
175// ============================================================================
176// In-Memory Action Log
177// ============================================================================
178
179/// An entry in the action log
180#[derive(Debug, Clone)]
181pub struct ActionLogEntry {
182    /// Action name (from Action::name())
183    pub name: &'static str,
184    /// Action parameters (from ActionParams::params())
185    pub params: String,
186    /// Pretty action parameters (from ActionParams::params_pretty())
187    pub params_pretty: String,
188    /// Timestamp when the action was logged
189    pub timestamp: Instant,
190    /// Elapsed time display, frozen at creation time
191    pub elapsed: String,
192    /// Sequence number for ordering
193    pub sequence: u64,
194}
195
196impl ActionLogEntry {
197    /// Create a new log entry
198    pub fn new(name: &'static str, params: String, params_pretty: String, sequence: u64) -> Self {
199        Self {
200            name,
201            params,
202            params_pretty,
203            timestamp: Instant::now(),
204            elapsed: "0ms".to_string(),
205            sequence,
206        }
207    }
208}
209
210/// Format elapsed time for display (e.g., "2.3s", "150ms")
211fn format_elapsed(elapsed: std::time::Duration) -> String {
212    if elapsed.as_secs() >= 1 {
213        format!("{:.1}s", elapsed.as_secs_f64())
214    } else {
215        format!("{}ms", elapsed.as_millis())
216    }
217}
218
219/// Configuration for the action log ring buffer
220#[derive(Debug, Clone)]
221pub struct ActionLogConfig {
222    /// Maximum number of entries to keep
223    pub capacity: usize,
224    /// Filter config (reuses existing ActionLoggerConfig)
225    pub filter: ActionLoggerConfig,
226}
227
228impl Default for ActionLogConfig {
229    fn default() -> Self {
230        Self {
231            capacity: 100,
232            filter: ActionLoggerConfig::default(),
233        }
234    }
235}
236
237impl ActionLogConfig {
238    /// Create with custom capacity
239    pub fn with_capacity(capacity: usize) -> Self {
240        Self {
241            capacity,
242            ..Default::default()
243        }
244    }
245
246    /// Create with custom capacity and filter
247    pub fn new(capacity: usize, filter: ActionLoggerConfig) -> Self {
248        Self { capacity, filter }
249    }
250}
251
252/// In-memory ring buffer for storing recent actions
253///
254/// Stores actions with timestamps and parameters for display in debug overlays.
255/// Older entries are automatically discarded when capacity is reached.
256#[derive(Debug, Clone)]
257pub struct ActionLog {
258    entries: VecDeque<ActionLogEntry>,
259    config: ActionLogConfig,
260    next_sequence: u64,
261    /// Time when the log was created (for relative elapsed times)
262    start_time: Instant,
263}
264
265impl Default for ActionLog {
266    fn default() -> Self {
267        Self::new(ActionLogConfig::default())
268    }
269}
270
271impl ActionLog {
272    /// Create a new action log with configuration
273    pub fn new(config: ActionLogConfig) -> Self {
274        Self {
275            entries: VecDeque::with_capacity(config.capacity),
276            config,
277            next_sequence: 0,
278            start_time: Instant::now(),
279        }
280    }
281
282    /// Log an action (if it passes the filter)
283    ///
284    /// Returns the entry if it was logged, None if filtered out.
285    pub fn log<A: ActionParams>(&mut self, action: &A) -> Option<&ActionLogEntry> {
286        let name = action.name();
287
288        if !self.config.filter.should_log(name) {
289            return None;
290        }
291
292        let params = action.params();
293        let params_pretty = action.params_pretty();
294        let mut entry = ActionLogEntry::new(name, params, params_pretty, self.next_sequence);
295        // Freeze the elapsed time at creation
296        entry.elapsed = format_elapsed(self.start_time.elapsed());
297        self.next_sequence += 1;
298
299        // Maintain capacity
300        if self.entries.len() >= self.config.capacity {
301            self.entries.pop_front();
302        }
303
304        self.entries.push_back(entry);
305        self.entries.back()
306    }
307
308    /// Get all entries (oldest first)
309    pub fn entries(&self) -> impl Iterator<Item = &ActionLogEntry> {
310        self.entries.iter()
311    }
312
313    /// Get entries in reverse order (newest first)
314    pub fn entries_rev(&self) -> impl Iterator<Item = &ActionLogEntry> {
315        self.entries.iter().rev()
316    }
317
318    /// Get the most recent N entries (newest first)
319    pub fn recent(&self, count: usize) -> impl Iterator<Item = &ActionLogEntry> {
320        self.entries.iter().rev().take(count)
321    }
322
323    /// Number of entries currently stored
324    pub fn len(&self) -> usize {
325        self.entries.len()
326    }
327
328    /// Whether the log is empty
329    pub fn is_empty(&self) -> bool {
330        self.entries.is_empty()
331    }
332
333    /// Clear all entries
334    pub fn clear(&mut self) {
335        self.entries.clear();
336    }
337
338    /// Get configuration
339    pub fn config(&self) -> &ActionLogConfig {
340        &self.config
341    }
342
343    /// Get mutable configuration
344    pub fn config_mut(&mut self) -> &mut ActionLogConfig {
345        &mut self.config
346    }
347}
348
349// ============================================================================
350// Middleware
351// ============================================================================
352
353/// Middleware that logs actions with configurable pattern filtering.
354///
355/// Supports two modes:
356/// - **Tracing only** (default): logs via `tracing::debug!()`
357/// - **With storage**: also stores in ActionLog ring buffer for overlay display
358///
359/// # Example
360///
361/// ```ignore
362/// use tui_dispatch_debug::debug::{ActionLoggerConfig, ActionLoggerMiddleware, ActionLogConfig};
363/// use tui_dispatch_core::{Store, StoreWithMiddleware};
364///
365/// // Tracing only
366/// let middleware = ActionLoggerMiddleware::new(ActionLoggerConfig::default());
367///
368/// // With in-memory storage
369/// let middleware = ActionLoggerMiddleware::with_log(ActionLogConfig::default());
370///
371/// // Access the log for display
372/// if let Some(log) = middleware.log() {
373///     for entry in log.recent(10) {
374///         println!("{}", entry.params);
375///     }
376/// }
377/// ```
378#[derive(Debug, Clone)]
379pub struct ActionLoggerMiddleware {
380    config: ActionLoggerConfig,
381    log: Option<ActionLog>,
382    /// Whether the middleware is active (processes actions)
383    /// When false, all methods become no-ops for zero overhead.
384    active: bool,
385}
386
387impl ActionLoggerMiddleware {
388    /// Create a new action logger middleware with tracing only (no in-memory storage)
389    pub fn new(config: ActionLoggerConfig) -> Self {
390        Self {
391            config,
392            log: None,
393            active: true,
394        }
395    }
396
397    /// Create middleware with in-memory storage
398    pub fn with_log(config: ActionLogConfig) -> Self {
399        Self {
400            config: config.filter.clone(),
401            log: Some(ActionLog::new(config)),
402            active: true,
403        }
404    }
405
406    /// Create with default config and in-memory storage
407    pub fn with_default_log() -> Self {
408        Self::with_log(ActionLogConfig::default())
409    }
410
411    /// Create with default config (excludes Tick and Render), tracing only
412    pub fn default_filtering() -> Self {
413        Self::new(ActionLoggerConfig::default())
414    }
415
416    /// Create with no filtering (logs all actions), tracing only
417    pub fn log_all() -> Self {
418        Self::new(ActionLoggerConfig::with_patterns(vec![], vec![]))
419    }
420
421    /// Set whether the middleware is active.
422    ///
423    /// When inactive (`false`), all methods become no-ops with zero overhead.
424    /// This is useful for conditional logging based on CLI flags.
425    ///
426    /// # Example
427    ///
428    /// ```ignore
429    /// let middleware = ActionLoggerMiddleware::default_filtering()
430    ///     .active(args.debug);  // Only log if --debug flag passed
431    /// ```
432    pub fn active(mut self, active: bool) -> Self {
433        self.active = active;
434        self
435    }
436
437    /// Check if the middleware is active.
438    pub fn is_active(&self) -> bool {
439        self.active
440    }
441
442    /// Get the action log (if storage is enabled)
443    pub fn log(&self) -> Option<&ActionLog> {
444        self.log.as_ref()
445    }
446
447    /// Get mutable action log
448    pub fn log_mut(&mut self) -> Option<&mut ActionLog> {
449        self.log.as_mut()
450    }
451
452    /// Get a reference to the config
453    pub fn config(&self) -> &ActionLoggerConfig {
454        &self.config
455    }
456
457    /// Get a mutable reference to the config
458    pub fn config_mut(&mut self) -> &mut ActionLoggerConfig {
459        &mut self.config
460    }
461}
462
463impl<S, A: ActionParams> Middleware<S, A> for ActionLoggerMiddleware {
464    fn before(&mut self, action: &A, _state: &S) -> bool {
465        // Inactive: no-op
466        if !self.active {
467            return true;
468        }
469
470        let name = action.name();
471
472        // Always log to tracing if filter passes
473        if self.config.should_log(name) {
474            tracing::debug!(action = %name, "action");
475        }
476
477        // Log to in-memory buffer if enabled
478        if let Some(ref mut log) = self.log {
479            log.log(action);
480        }
481
482        true
483    }
484
485    fn after(&mut self, _action: &A, _state_changed: bool, _state: &S) -> Vec<A> {
486        vec![]
487    }
488}
489
490/// Simple glob pattern matching supporting `*` and `?`.
491///
492/// - `*` matches zero or more characters
493/// - `?` matches exactly one character
494pub fn glob_match(pattern: &str, text: &str) -> bool {
495    let pattern: Vec<char> = pattern.chars().collect();
496    let text: Vec<char> = text.chars().collect();
497    glob_match_impl(&pattern, &text)
498}
499
500fn glob_match_impl(pattern: &[char], text: &[char]) -> bool {
501    let mut pi = 0;
502    let mut ti = 0;
503    let mut star_pi = None;
504    let mut star_ti = 0;
505
506    while ti < text.len() {
507        if pi < pattern.len() && (pattern[pi] == '?' || pattern[pi] == text[ti]) {
508            pi += 1;
509            ti += 1;
510        } else if pi < pattern.len() && pattern[pi] == '*' {
511            star_pi = Some(pi);
512            star_ti = ti;
513            pi += 1;
514        } else if let Some(spi) = star_pi {
515            pi = spi + 1;
516            star_ti += 1;
517            ti = star_ti;
518        } else {
519            return false;
520        }
521    }
522
523    while pi < pattern.len() && pattern[pi] == '*' {
524        pi += 1;
525    }
526
527    pi == pattern.len()
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn test_glob_match_exact() {
536        assert!(glob_match("Tick", "Tick"));
537        assert!(!glob_match("Tick", "Tock"));
538        assert!(!glob_match("Tick", "TickTock"));
539    }
540
541    #[test]
542    fn test_glob_match_star() {
543        assert!(glob_match("Search*", "SearchAddChar"));
544        assert!(glob_match("Search*", "SearchDeleteChar"));
545        assert!(glob_match("Search*", "Search"));
546        assert!(!glob_match("Search*", "StartSearch"));
547
548        assert!(glob_match("*Search", "StartSearch"));
549        assert!(glob_match("*Search*", "StartSearchNow"));
550
551        assert!(glob_match("Did*", "DidConnect"));
552        assert!(glob_match("Did*", "DidScanKeys"));
553    }
554
555    #[test]
556    fn test_glob_match_question() {
557        assert!(glob_match("Tick?", "Ticks"));
558        assert!(!glob_match("Tick?", "Tick"));
559        assert!(!glob_match("Tick?", "Tickss"));
560    }
561
562    #[test]
563    fn test_glob_match_combined() {
564        assert!(glob_match("*Add*", "SearchAddChar"));
565        assert!(glob_match("Connection*Add*", "ConnectionFormAddChar"));
566    }
567
568    #[test]
569    fn test_action_logger_config_include() {
570        let config = ActionLoggerConfig::new(Some("Search*,Connect"), None);
571        assert!(config.should_log("SearchAddChar"));
572        assert!(config.should_log("Connect"));
573        assert!(!config.should_log("Tick"));
574        assert!(!config.should_log("LoadKeys"));
575    }
576
577    #[test]
578    fn test_action_logger_config_exclude() {
579        let config = ActionLoggerConfig::new(None, Some("Tick,Render,LoadValue*"));
580        assert!(!config.should_log("Tick"));
581        assert!(!config.should_log("Render"));
582        assert!(!config.should_log("LoadValueDebounced"));
583        assert!(config.should_log("SearchAddChar"));
584        assert!(config.should_log("Connect"));
585    }
586
587    #[test]
588    fn test_action_logger_config_include_and_exclude() {
589        // Include Did* but exclude DidFail*
590        let config = ActionLoggerConfig::new(Some("Did*"), Some("DidFail*"));
591        assert!(config.should_log("DidConnect"));
592        assert!(config.should_log("DidScanKeys"));
593        assert!(!config.should_log("DidFailConnect"));
594        assert!(!config.should_log("DidFailScanKeys"));
595        assert!(!config.should_log("SearchAddChar")); // Not in include
596    }
597
598    #[test]
599    fn test_action_logger_config_default() {
600        let config = ActionLoggerConfig::default();
601        assert!(!config.should_log("Tick"));
602        assert!(!config.should_log("Render"));
603        assert!(config.should_log("Connect"));
604        assert!(config.should_log("SearchAddChar"));
605    }
606
607    #[test]
608    fn test_action_logger_config_category_filters() {
609        let config = ActionLoggerConfig::new(
610            Some("cat:search,category:weather"),
611            Some("cat:search_query"),
612        );
613
614        assert!(config.should_log("SearchStart"));
615        assert!(config.should_log("WeatherDidLoad"));
616        assert!(!config.should_log("SearchQuerySubmit"));
617        assert!(!config.should_log("Connect"));
618    }
619
620    #[test]
621    fn test_action_logger_config_action_name_filters() {
622        let config = ActionLoggerConfig::new(
623            Some("name:Connect,name:SearchStart"),
624            Some("name:SearchStart"),
625        );
626
627        assert!(config.should_log("Connect"));
628        assert!(!config.should_log("SearchStart"));
629        assert!(!config.should_log("SearchAddChar"));
630    }
631
632    #[test]
633    fn test_action_logger_name_filter_is_case_sensitive() {
634        let lowercase = ActionLoggerConfig::new(Some("name:searchstart"), None);
635        let exact = ActionLoggerConfig::new(Some("name:SearchStart"), None);
636
637        assert!(!lowercase.should_log("SearchStart"));
638        assert!(exact.should_log("SearchStart"));
639    }
640
641    #[test]
642    fn test_action_logger_name_filter_no_action_alias() {
643        let config = ActionLoggerConfig::new(Some("action:SearchStart"), None);
644        assert!(!config.should_log("SearchStart"));
645    }
646
647    #[test]
648    fn test_action_logger_category_inference_edges() {
649        let async_result = ActionLoggerConfig::new(Some("cat:async_result"), None);
650        assert!(async_result.should_log("DidLoad"));
651        assert!(!async_result.should_log("Tick"));
652
653        let acronym = ActionLoggerConfig::new(Some("cat:api"), None);
654        assert!(acronym.should_log("APIFetchStart"));
655        assert!(!acronym.should_log("OpenConnectionForm"));
656    }
657
658    #[test]
659    fn test_action_logger_config_category_glob_and_case_insensitive_prefix() {
660        let config = ActionLoggerConfig::new(Some("CAT:search*"), None);
661        assert!(config.should_log("SearchStart"));
662        assert!(config.should_log("SearchQuerySubmit"));
663        assert!(!config.should_log("WeatherDidLoad"));
664    }
665
666    // Test action for ActionLog tests
667    #[derive(Clone, Debug)]
668    enum TestAction {
669        Tick,
670        Connect,
671        SearchStart,
672    }
673
674    impl tui_dispatch_core::Action for TestAction {
675        fn name(&self) -> &'static str {
676            match self {
677                TestAction::Tick => "Tick",
678                TestAction::Connect => "Connect",
679                TestAction::SearchStart => "SearchStart",
680            }
681        }
682    }
683
684    impl tui_dispatch_core::ActionParams for TestAction {
685        fn params(&self) -> String {
686            String::new()
687        }
688    }
689
690    #[test]
691    fn test_action_log_basic() {
692        let mut log = ActionLog::default();
693        assert!(log.is_empty());
694
695        log.log(&TestAction::Connect);
696        assert_eq!(log.len(), 1);
697
698        let entry = log.entries().next().unwrap();
699        assert_eq!(entry.name, "Connect");
700        assert_eq!(entry.sequence, 0);
701    }
702
703    #[test]
704    fn test_action_log_filtering() {
705        let mut log = ActionLog::default(); // Default excludes Tick
706
707        log.log(&TestAction::Tick);
708        assert!(log.is_empty()); // Tick should be filtered
709
710        log.log(&TestAction::Connect);
711        assert_eq!(log.len(), 1);
712    }
713
714    #[test]
715    fn test_action_log_capacity() {
716        let config = ActionLogConfig::new(
717            3,
718            ActionLoggerConfig::with_patterns(vec![], vec![]), // No filtering
719        );
720        let mut log = ActionLog::new(config);
721
722        log.log(&TestAction::Connect);
723        log.log(&TestAction::Connect);
724        log.log(&TestAction::Connect);
725        assert_eq!(log.len(), 3);
726
727        log.log(&TestAction::Connect);
728        assert_eq!(log.len(), 3); // Still 3, oldest was removed
729
730        // First entry should now be sequence 1 (sequence 0 was removed)
731        assert_eq!(log.entries().next().unwrap().sequence, 1);
732    }
733
734    #[test]
735    fn test_action_log_recent() {
736        let config = ActionLogConfig::new(10, ActionLoggerConfig::with_patterns(vec![], vec![]));
737        let mut log = ActionLog::new(config);
738
739        for _ in 0..5 {
740            log.log(&TestAction::Connect);
741        }
742
743        // Recent should return newest first
744        let recent: Vec<_> = log.recent(3).collect();
745        assert_eq!(recent.len(), 3);
746        assert_eq!(recent[0].sequence, 4); // Newest
747        assert_eq!(recent[1].sequence, 3);
748        assert_eq!(recent[2].sequence, 2);
749    }
750
751    #[test]
752    fn test_action_log_entry_elapsed() {
753        let entry = ActionLogEntry::new(
754            "Test",
755            "test_params".to_string(),
756            "test_params".to_string(),
757            0,
758        );
759        // Should have "0ms" as default elapsed
760        assert_eq!(entry.elapsed, "0ms");
761    }
762
763    #[test]
764    fn test_middleware_filtering() {
765        use tui_dispatch_core::store::Middleware;
766
767        // Default config filters out "Tick"
768        let mut middleware = ActionLoggerMiddleware::with_default_log();
769
770        // Log a Connect action
771        middleware.before(&TestAction::Connect, &());
772        middleware.after(&TestAction::Connect, true, &());
773
774        // Verify Connect was logged
775        let log = middleware.log().unwrap();
776        assert_eq!(log.len(), 1);
777
778        // Now dispatch a Tick (filtered out by default)
779        middleware.before(&TestAction::Tick, &());
780        middleware.after(&TestAction::Tick, false, &());
781
782        // Log should still have only 1 entry (Tick was filtered)
783        let log = middleware.log().unwrap();
784        assert_eq!(log.len(), 1);
785    }
786
787    #[test]
788    fn test_action_log_include_category_with_exclude_name_can_filter_all() {
789        let config = ActionLogConfig::new(
790            10,
791            ActionLoggerConfig::new(Some("cat:search"), Some("name:SearchStart")),
792        );
793        let mut log = ActionLog::new(config);
794
795        log.log(&TestAction::SearchStart);
796        log.log(&TestAction::Connect);
797
798        // SearchStart is included by category but excluded by exact name.
799        // Connect doesn't match include category, so nothing is logged.
800        assert!(log.is_empty());
801    }
802}