tui_dispatch_core/debug/
action_logger.rs

1//! Action logging with pattern-based filtering
2//!
3//! Provides configurable action logging using glob patterns to include/exclude
4//! specific actions from logs.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use tui_dispatch_core::debug::{ActionLoggerConfig, ActionLoggerMiddleware};
10//!
11//! // Log all actions except Tick and Render
12//! let config = ActionLoggerConfig::default();
13//!
14//! // Log only Search* and Connect* actions
15//! let config = ActionLoggerConfig::new(Some("Search*,Connect*"), None);
16//!
17//! // Log Did* actions but exclude DidFail*
18//! let config = ActionLoggerConfig::new(Some("Did*"), Some("DidFail*"));
19//!
20//! let middleware = ActionLoggerMiddleware::new(config);
21//! ```
22
23use crate::store::Middleware;
24use crate::Action;
25
26/// Configuration for action logging with glob pattern filtering.
27///
28/// Patterns support:
29/// - `*` matches any sequence of characters
30/// - `?` matches any single character
31/// - Literal text matches exactly
32///
33/// # Examples
34///
35/// - `Search*` matches SearchAddChar, SearchDeleteChar, etc.
36/// - `Did*` matches DidConnect, DidScanKeys, etc.
37/// - `*Error*` matches any action containing "Error"
38/// - `Tick` matches only Tick
39#[derive(Debug, Clone)]
40pub struct ActionLoggerConfig {
41    /// If non-empty, only log actions matching these patterns
42    pub include_patterns: Vec<String>,
43    /// Exclude actions matching these patterns (applied after include)
44    pub exclude_patterns: Vec<String>,
45}
46
47impl Default for ActionLoggerConfig {
48    fn default() -> Self {
49        Self {
50            include_patterns: Vec::new(),
51            // By default, exclude noisy high-frequency actions
52            exclude_patterns: vec!["Tick".to_string(), "Render".to_string()],
53        }
54    }
55}
56
57impl ActionLoggerConfig {
58    /// Create a new config from comma-separated pattern strings
59    ///
60    /// # Arguments
61    /// - `include`: comma-separated glob patterns (or None for all)
62    /// - `exclude`: comma-separated glob patterns (or None for default excludes)
63    ///
64    /// # Example
65    /// ```
66    /// use tui_dispatch_core::debug::ActionLoggerConfig;
67    ///
68    /// let config = ActionLoggerConfig::new(Some("Search*,Connect"), Some("Tick,Render"));
69    /// assert!(config.should_log("SearchAddChar"));
70    /// assert!(config.should_log("Connect"));
71    /// assert!(!config.should_log("Tick"));
72    /// ```
73    pub fn new(include: Option<&str>, exclude: Option<&str>) -> Self {
74        let include_patterns = include
75            .map(|s| s.split(',').map(|p| p.trim().to_string()).collect())
76            .unwrap_or_default();
77
78        let exclude_patterns = exclude
79            .map(|s| s.split(',').map(|p| p.trim().to_string()).collect())
80            .unwrap_or_else(|| vec!["Tick".to_string(), "Render".to_string()]);
81
82        Self {
83            include_patterns,
84            exclude_patterns,
85        }
86    }
87
88    /// Create a config with specific pattern vectors
89    pub fn with_patterns(include: Vec<String>, exclude: Vec<String>) -> Self {
90        Self {
91            include_patterns: include,
92            exclude_patterns: exclude,
93        }
94    }
95
96    /// Check if an action name should be logged based on include/exclude patterns
97    pub fn should_log(&self, action_name: &str) -> bool {
98        // If include patterns specified, action must match at least one
99        if !self.include_patterns.is_empty() {
100            let matches_include = self
101                .include_patterns
102                .iter()
103                .any(|p| glob_match(p, action_name));
104            if !matches_include {
105                return false;
106            }
107        }
108
109        // Check exclude patterns
110        let matches_exclude = self
111            .exclude_patterns
112            .iter()
113            .any(|p| glob_match(p, action_name));
114
115        !matches_exclude
116    }
117}
118
119/// Middleware that logs actions with configurable pattern filtering.
120///
121/// Uses `tracing::debug!` for output, so actions are only logged when
122/// the tracing subscriber is configured to capture debug level messages.
123///
124/// # Example
125///
126/// ```ignore
127/// use tui_dispatch_core::debug::{ActionLoggerConfig, ActionLoggerMiddleware};
128/// use tui_dispatch_core::{Store, StoreWithMiddleware};
129///
130/// let config = ActionLoggerConfig::new(Some("User*"), None);
131/// let middleware = ActionLoggerMiddleware::new(config);
132/// let store = StoreWithMiddleware::new(state, reducer, middleware);
133/// ```
134#[derive(Debug, Clone)]
135pub struct ActionLoggerMiddleware {
136    config: ActionLoggerConfig,
137}
138
139impl ActionLoggerMiddleware {
140    /// Create a new action logger middleware with the given config
141    pub fn new(config: ActionLoggerConfig) -> Self {
142        Self { config }
143    }
144
145    /// Create with default config (excludes Tick and Render)
146    pub fn default_filtering() -> Self {
147        Self::new(ActionLoggerConfig::default())
148    }
149
150    /// Create with no filtering (logs all actions)
151    pub fn log_all() -> Self {
152        Self::new(ActionLoggerConfig::with_patterns(vec![], vec![]))
153    }
154
155    /// Get a reference to the config
156    pub fn config(&self) -> &ActionLoggerConfig {
157        &self.config
158    }
159
160    /// Get a mutable reference to the config
161    pub fn config_mut(&mut self) -> &mut ActionLoggerConfig {
162        &mut self.config
163    }
164}
165
166impl<A: Action> Middleware<A> for ActionLoggerMiddleware {
167    fn before(&mut self, action: &A) {
168        let name = action.name();
169        if self.config.should_log(name) {
170            tracing::debug!(action = %name, "action");
171        }
172    }
173
174    fn after(&mut self, _action: &A, _state_changed: bool) {
175        // No-op - we log before dispatch only
176    }
177}
178
179/// Simple glob pattern matching supporting `*` and `?`.
180///
181/// - `*` matches zero or more characters
182/// - `?` matches exactly one character
183pub fn glob_match(pattern: &str, text: &str) -> bool {
184    let pattern: Vec<char> = pattern.chars().collect();
185    let text: Vec<char> = text.chars().collect();
186    glob_match_impl(&pattern, &text)
187}
188
189fn glob_match_impl(pattern: &[char], text: &[char]) -> bool {
190    let mut pi = 0;
191    let mut ti = 0;
192    let mut star_pi = None;
193    let mut star_ti = 0;
194
195    while ti < text.len() {
196        if pi < pattern.len() && (pattern[pi] == '?' || pattern[pi] == text[ti]) {
197            pi += 1;
198            ti += 1;
199        } else if pi < pattern.len() && pattern[pi] == '*' {
200            star_pi = Some(pi);
201            star_ti = ti;
202            pi += 1;
203        } else if let Some(spi) = star_pi {
204            pi = spi + 1;
205            star_ti += 1;
206            ti = star_ti;
207        } else {
208            return false;
209        }
210    }
211
212    while pi < pattern.len() && pattern[pi] == '*' {
213        pi += 1;
214    }
215
216    pi == pattern.len()
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_glob_match_exact() {
225        assert!(glob_match("Tick", "Tick"));
226        assert!(!glob_match("Tick", "Tock"));
227        assert!(!glob_match("Tick", "TickTock"));
228    }
229
230    #[test]
231    fn test_glob_match_star() {
232        assert!(glob_match("Search*", "SearchAddChar"));
233        assert!(glob_match("Search*", "SearchDeleteChar"));
234        assert!(glob_match("Search*", "Search"));
235        assert!(!glob_match("Search*", "StartSearch"));
236
237        assert!(glob_match("*Search", "StartSearch"));
238        assert!(glob_match("*Search*", "StartSearchNow"));
239
240        assert!(glob_match("Did*", "DidConnect"));
241        assert!(glob_match("Did*", "DidScanKeys"));
242    }
243
244    #[test]
245    fn test_glob_match_question() {
246        assert!(glob_match("Tick?", "Ticks"));
247        assert!(!glob_match("Tick?", "Tick"));
248        assert!(!glob_match("Tick?", "Tickss"));
249    }
250
251    #[test]
252    fn test_glob_match_combined() {
253        assert!(glob_match("*Add*", "SearchAddChar"));
254        assert!(glob_match("Connection*Add*", "ConnectionFormAddChar"));
255    }
256
257    #[test]
258    fn test_action_logger_config_include() {
259        let config = ActionLoggerConfig::new(Some("Search*,Connect"), None);
260        assert!(config.should_log("SearchAddChar"));
261        assert!(config.should_log("Connect"));
262        assert!(!config.should_log("Tick"));
263        assert!(!config.should_log("LoadKeys"));
264    }
265
266    #[test]
267    fn test_action_logger_config_exclude() {
268        let config = ActionLoggerConfig::new(None, Some("Tick,Render,LoadValue*"));
269        assert!(!config.should_log("Tick"));
270        assert!(!config.should_log("Render"));
271        assert!(!config.should_log("LoadValueDebounced"));
272        assert!(config.should_log("SearchAddChar"));
273        assert!(config.should_log("Connect"));
274    }
275
276    #[test]
277    fn test_action_logger_config_include_and_exclude() {
278        // Include Did* but exclude DidFail*
279        let config = ActionLoggerConfig::new(Some("Did*"), Some("DidFail*"));
280        assert!(config.should_log("DidConnect"));
281        assert!(config.should_log("DidScanKeys"));
282        assert!(!config.should_log("DidFailConnect"));
283        assert!(!config.should_log("DidFailScanKeys"));
284        assert!(!config.should_log("SearchAddChar")); // Not in include
285    }
286
287    #[test]
288    fn test_action_logger_config_default() {
289        let config = ActionLoggerConfig::default();
290        assert!(!config.should_log("Tick"));
291        assert!(!config.should_log("Render"));
292        assert!(config.should_log("Connect"));
293        assert!(config.should_log("SearchAddChar"));
294    }
295}