tui_dispatch_debug/debug/
action_logger.rs1use 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#[derive(Debug, Clone)]
55pub struct ActionLoggerConfig {
56 pub include_patterns: Vec<String>,
58 pub exclude_patterns: Vec<String>,
60}
61
62impl Default for ActionLoggerConfig {
63 fn default() -> Self {
64 Self {
65 include_patterns: Vec::new(),
66 exclude_patterns: vec!["Tick".to_string(), "Render".to_string()],
68 }
69 }
70}
71
72impl ActionLoggerConfig {
73 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 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 pub fn should_log(&self, action_name: &str) -> bool {
111 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 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 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#[derive(Debug, Clone)]
181pub struct ActionLogEntry {
182 pub name: &'static str,
184 pub params: String,
186 pub params_pretty: String,
188 pub timestamp: Instant,
190 pub elapsed: String,
192 pub sequence: u64,
194}
195
196impl ActionLogEntry {
197 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
210fn 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#[derive(Debug, Clone)]
221pub struct ActionLogConfig {
222 pub capacity: usize,
224 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 pub fn with_capacity(capacity: usize) -> Self {
240 Self {
241 capacity,
242 ..Default::default()
243 }
244 }
245
246 pub fn new(capacity: usize, filter: ActionLoggerConfig) -> Self {
248 Self { capacity, filter }
249 }
250}
251
252#[derive(Debug, Clone)]
257pub struct ActionLog {
258 entries: VecDeque<ActionLogEntry>,
259 config: ActionLogConfig,
260 next_sequence: u64,
261 start_time: Instant,
263}
264
265impl Default for ActionLog {
266 fn default() -> Self {
267 Self::new(ActionLogConfig::default())
268 }
269}
270
271impl ActionLog {
272 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 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 entry.elapsed = format_elapsed(self.start_time.elapsed());
297 self.next_sequence += 1;
298
299 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 pub fn entries(&self) -> impl Iterator<Item = &ActionLogEntry> {
310 self.entries.iter()
311 }
312
313 pub fn entries_rev(&self) -> impl Iterator<Item = &ActionLogEntry> {
315 self.entries.iter().rev()
316 }
317
318 pub fn recent(&self, count: usize) -> impl Iterator<Item = &ActionLogEntry> {
320 self.entries.iter().rev().take(count)
321 }
322
323 pub fn len(&self) -> usize {
325 self.entries.len()
326 }
327
328 pub fn is_empty(&self) -> bool {
330 self.entries.is_empty()
331 }
332
333 pub fn clear(&mut self) {
335 self.entries.clear();
336 }
337
338 pub fn config(&self) -> &ActionLogConfig {
340 &self.config
341 }
342
343 pub fn config_mut(&mut self) -> &mut ActionLogConfig {
345 &mut self.config
346 }
347}
348
349#[derive(Debug, Clone)]
379pub struct ActionLoggerMiddleware {
380 config: ActionLoggerConfig,
381 log: Option<ActionLog>,
382 active: bool,
385}
386
387impl ActionLoggerMiddleware {
388 pub fn new(config: ActionLoggerConfig) -> Self {
390 Self {
391 config,
392 log: None,
393 active: true,
394 }
395 }
396
397 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 pub fn with_default_log() -> Self {
408 Self::with_log(ActionLogConfig::default())
409 }
410
411 pub fn default_filtering() -> Self {
413 Self::new(ActionLoggerConfig::default())
414 }
415
416 pub fn log_all() -> Self {
418 Self::new(ActionLoggerConfig::with_patterns(vec![], vec![]))
419 }
420
421 pub fn active(mut self, active: bool) -> Self {
433 self.active = active;
434 self
435 }
436
437 pub fn is_active(&self) -> bool {
439 self.active
440 }
441
442 pub fn log(&self) -> Option<&ActionLog> {
444 self.log.as_ref()
445 }
446
447 pub fn log_mut(&mut self) -> Option<&mut ActionLog> {
449 self.log.as_mut()
450 }
451
452 pub fn config(&self) -> &ActionLoggerConfig {
454 &self.config
455 }
456
457 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 if !self.active {
467 return true;
468 }
469
470 let name = action.name();
471
472 if self.config.should_log(name) {
474 tracing::debug!(action = %name, "action");
475 }
476
477 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
490pub 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 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")); }
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 #[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(); log.log(&TestAction::Tick);
708 assert!(log.is_empty()); 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![]), );
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); 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 let recent: Vec<_> = log.recent(3).collect();
745 assert_eq!(recent.len(), 3);
746 assert_eq!(recent[0].sequence, 4); 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 assert_eq!(entry.elapsed, "0ms");
761 }
762
763 #[test]
764 fn test_middleware_filtering() {
765 use tui_dispatch_core::store::Middleware;
766
767 let mut middleware = ActionLoggerMiddleware::with_default_log();
769
770 middleware.before(&TestAction::Connect, &());
772 middleware.after(&TestAction::Connect, true, &());
773
774 let log = middleware.log().unwrap();
776 assert_eq!(log.len(), 1);
777
778 middleware.before(&TestAction::Tick, &());
780 middleware.after(&TestAction::Tick, false, &());
781
782 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 assert!(log.is_empty());
801 }
802}