use std::collections::VecDeque;
use std::time::Instant;
use tui_dispatch_core::action::ActionParams;
use tui_dispatch_core::store::Middleware;
use tui_dispatch_shared::infer_action_category;
use crate::pattern_utils::split_patterns_csv;
#[derive(Debug, Clone)]
pub struct ActionLoggerConfig {
pub include_patterns: Vec<String>,
pub exclude_patterns: Vec<String>,
}
impl Default for ActionLoggerConfig {
fn default() -> Self {
Self {
include_patterns: Vec::new(),
exclude_patterns: vec!["Tick".to_string(), "Render".to_string()],
}
}
}
impl ActionLoggerConfig {
pub fn new(include: Option<&str>, exclude: Option<&str>) -> Self {
let include_patterns = include.map(split_patterns_csv).unwrap_or_default();
let exclude_patterns = exclude
.map(split_patterns_csv)
.unwrap_or_else(|| vec!["Tick".to_string(), "Render".to_string()]);
Self {
include_patterns,
exclude_patterns,
}
}
pub fn with_patterns(include: Vec<String>, exclude: Vec<String>) -> Self {
Self {
include_patterns: include,
exclude_patterns: exclude,
}
}
pub fn should_log(&self, action_name: &str) -> bool {
if !self.include_patterns.is_empty() {
let matches_include = self
.include_patterns
.iter()
.any(|p| filter_match(p, action_name));
if !matches_include {
return false;
}
}
let matches_exclude = self
.exclude_patterns
.iter()
.any(|p| filter_match(p, action_name));
!matches_exclude
}
}
fn filter_match(pattern: &str, action_name: &str) -> bool {
let pattern = pattern.trim();
if pattern.is_empty() {
return false;
}
if let Some(category_pattern) =
strip_filter_prefix(pattern, "cat:").or_else(|| strip_filter_prefix(pattern, "category:"))
{
let category_pattern = category_pattern.trim();
if category_pattern.is_empty() {
return false;
}
return infer_action_category(action_name)
.as_deref()
.is_some_and(|category| glob_match(category_pattern, category));
}
if let Some(action_name_pattern) = strip_filter_prefix(pattern, "name:") {
let action_name_pattern = action_name_pattern.trim();
if action_name_pattern.is_empty() {
return false;
}
return action_name_pattern == action_name;
}
glob_match(pattern, action_name)
}
fn strip_filter_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
if value.len() < prefix.len() {
return None;
}
let (head, tail) = value.split_at(prefix.len());
if head.eq_ignore_ascii_case(prefix) {
Some(tail)
} else {
None
}
}
#[derive(Debug, Clone)]
pub struct ActionLogEntry {
pub name: &'static str,
pub params: String,
pub params_pretty: String,
pub timestamp: Instant,
pub elapsed: String,
pub sequence: u64,
}
impl ActionLogEntry {
pub fn new(name: &'static str, params: String, params_pretty: String, sequence: u64) -> Self {
Self {
name,
params,
params_pretty,
timestamp: Instant::now(),
elapsed: "0ms".to_string(),
sequence,
}
}
}
fn format_elapsed(elapsed: std::time::Duration) -> String {
if elapsed.as_secs() >= 1 {
format!("{:.1}s", elapsed.as_secs_f64())
} else {
format!("{}ms", elapsed.as_millis())
}
}
#[derive(Debug, Clone)]
pub struct ActionLogConfig {
pub capacity: usize,
pub filter: ActionLoggerConfig,
}
impl Default for ActionLogConfig {
fn default() -> Self {
Self {
capacity: 100,
filter: ActionLoggerConfig::default(),
}
}
}
impl ActionLogConfig {
pub fn with_capacity(capacity: usize) -> Self {
Self {
capacity,
..Default::default()
}
}
pub fn new(capacity: usize, filter: ActionLoggerConfig) -> Self {
Self { capacity, filter }
}
}
#[derive(Debug, Clone)]
pub struct ActionLog {
entries: VecDeque<ActionLogEntry>,
config: ActionLogConfig,
next_sequence: u64,
start_time: Instant,
}
impl Default for ActionLog {
fn default() -> Self {
Self::new(ActionLogConfig::default())
}
}
impl ActionLog {
pub fn new(config: ActionLogConfig) -> Self {
Self {
entries: VecDeque::with_capacity(config.capacity),
config,
next_sequence: 0,
start_time: Instant::now(),
}
}
pub fn log<A: ActionParams>(&mut self, action: &A) -> Option<&ActionLogEntry> {
let name = action.name();
if !self.config.filter.should_log(name) {
return None;
}
let params = action.params();
let params_pretty = action.params_pretty();
let mut entry = ActionLogEntry::new(name, params, params_pretty, self.next_sequence);
entry.elapsed = format_elapsed(self.start_time.elapsed());
self.next_sequence += 1;
if self.entries.len() >= self.config.capacity {
self.entries.pop_front();
}
self.entries.push_back(entry);
self.entries.back()
}
pub fn entries(&self) -> impl Iterator<Item = &ActionLogEntry> {
self.entries.iter()
}
pub fn entries_rev(&self) -> impl Iterator<Item = &ActionLogEntry> {
self.entries.iter().rev()
}
pub fn recent(&self, count: usize) -> impl Iterator<Item = &ActionLogEntry> {
self.entries.iter().rev().take(count)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn clear(&mut self) {
self.entries.clear();
}
pub fn config(&self) -> &ActionLogConfig {
&self.config
}
pub fn config_mut(&mut self) -> &mut ActionLogConfig {
&mut self.config
}
}
#[derive(Debug, Clone)]
pub struct ActionLoggerMiddleware {
config: ActionLoggerConfig,
log: Option<ActionLog>,
active: bool,
}
impl ActionLoggerMiddleware {
pub fn new(config: ActionLoggerConfig) -> Self {
Self {
config,
log: None,
active: true,
}
}
pub fn with_log(config: ActionLogConfig) -> Self {
Self {
config: config.filter.clone(),
log: Some(ActionLog::new(config)),
active: true,
}
}
pub fn with_default_log() -> Self {
Self::with_log(ActionLogConfig::default())
}
pub fn default_filtering() -> Self {
Self::new(ActionLoggerConfig::default())
}
pub fn log_all() -> Self {
Self::new(ActionLoggerConfig::with_patterns(vec![], vec![]))
}
pub fn active(mut self, active: bool) -> Self {
self.active = active;
self
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn log(&self) -> Option<&ActionLog> {
self.log.as_ref()
}
pub fn log_mut(&mut self) -> Option<&mut ActionLog> {
self.log.as_mut()
}
pub fn config(&self) -> &ActionLoggerConfig {
&self.config
}
pub fn config_mut(&mut self) -> &mut ActionLoggerConfig {
&mut self.config
}
}
impl<S, A: ActionParams> Middleware<S, A> for ActionLoggerMiddleware {
fn before(&mut self, action: &A, _state: &S) -> bool {
if !self.active {
return true;
}
let name = action.name();
if self.config.should_log(name) {
tracing::debug!(action = %name, "action");
}
if let Some(ref mut log) = self.log {
log.log(action);
}
true
}
fn after(&mut self, _action: &A, _state_changed: bool, _state: &S) -> Vec<A> {
vec![]
}
}
pub fn glob_match(pattern: &str, text: &str) -> bool {
let pattern: Vec<char> = pattern.chars().collect();
let text: Vec<char> = text.chars().collect();
glob_match_impl(&pattern, &text)
}
fn glob_match_impl(pattern: &[char], text: &[char]) -> bool {
let mut pi = 0;
let mut ti = 0;
let mut star_pi = None;
let mut star_ti = 0;
while ti < text.len() {
if pi < pattern.len() && (pattern[pi] == '?' || pattern[pi] == text[ti]) {
pi += 1;
ti += 1;
} else if pi < pattern.len() && pattern[pi] == '*' {
star_pi = Some(pi);
star_ti = ti;
pi += 1;
} else if let Some(spi) = star_pi {
pi = spi + 1;
star_ti += 1;
ti = star_ti;
} else {
return false;
}
}
while pi < pattern.len() && pattern[pi] == '*' {
pi += 1;
}
pi == pattern.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_glob_match_exact() {
assert!(glob_match("Tick", "Tick"));
assert!(!glob_match("Tick", "Tock"));
assert!(!glob_match("Tick", "TickTock"));
}
#[test]
fn test_glob_match_star() {
assert!(glob_match("Search*", "SearchAddChar"));
assert!(glob_match("Search*", "SearchDeleteChar"));
assert!(glob_match("Search*", "Search"));
assert!(!glob_match("Search*", "StartSearch"));
assert!(glob_match("*Search", "StartSearch"));
assert!(glob_match("*Search*", "StartSearchNow"));
assert!(glob_match("Did*", "DidConnect"));
assert!(glob_match("Did*", "DidScanKeys"));
}
#[test]
fn test_glob_match_question() {
assert!(glob_match("Tick?", "Ticks"));
assert!(!glob_match("Tick?", "Tick"));
assert!(!glob_match("Tick?", "Tickss"));
}
#[test]
fn test_glob_match_combined() {
assert!(glob_match("*Add*", "SearchAddChar"));
assert!(glob_match("Connection*Add*", "ConnectionFormAddChar"));
}
#[test]
fn test_action_logger_config_include() {
let config = ActionLoggerConfig::new(Some("Search*,Connect"), None);
assert!(config.should_log("SearchAddChar"));
assert!(config.should_log("Connect"));
assert!(!config.should_log("Tick"));
assert!(!config.should_log("LoadKeys"));
}
#[test]
fn test_action_logger_config_exclude() {
let config = ActionLoggerConfig::new(None, Some("Tick,Render,LoadValue*"));
assert!(!config.should_log("Tick"));
assert!(!config.should_log("Render"));
assert!(!config.should_log("LoadValueDebounced"));
assert!(config.should_log("SearchAddChar"));
assert!(config.should_log("Connect"));
}
#[test]
fn test_action_logger_config_include_and_exclude() {
let config = ActionLoggerConfig::new(Some("Did*"), Some("DidFail*"));
assert!(config.should_log("DidConnect"));
assert!(config.should_log("DidScanKeys"));
assert!(!config.should_log("DidFailConnect"));
assert!(!config.should_log("DidFailScanKeys"));
assert!(!config.should_log("SearchAddChar")); }
#[test]
fn test_action_logger_config_default() {
let config = ActionLoggerConfig::default();
assert!(!config.should_log("Tick"));
assert!(!config.should_log("Render"));
assert!(config.should_log("Connect"));
assert!(config.should_log("SearchAddChar"));
}
#[test]
fn test_action_logger_config_category_filters() {
let config = ActionLoggerConfig::new(
Some("cat:search,category:weather"),
Some("cat:search_query"),
);
assert!(config.should_log("SearchStart"));
assert!(config.should_log("WeatherDidLoad"));
assert!(!config.should_log("SearchQuerySubmit"));
assert!(!config.should_log("Connect"));
}
#[test]
fn test_action_logger_config_action_name_filters() {
let config = ActionLoggerConfig::new(
Some("name:Connect,name:SearchStart"),
Some("name:SearchStart"),
);
assert!(config.should_log("Connect"));
assert!(!config.should_log("SearchStart"));
assert!(!config.should_log("SearchAddChar"));
}
#[test]
fn test_action_logger_name_filter_is_case_sensitive() {
let lowercase = ActionLoggerConfig::new(Some("name:searchstart"), None);
let exact = ActionLoggerConfig::new(Some("name:SearchStart"), None);
assert!(!lowercase.should_log("SearchStart"));
assert!(exact.should_log("SearchStart"));
}
#[test]
fn test_action_logger_name_filter_no_action_alias() {
let config = ActionLoggerConfig::new(Some("action:SearchStart"), None);
assert!(!config.should_log("SearchStart"));
}
#[test]
fn test_action_logger_category_inference_edges() {
let async_result = ActionLoggerConfig::new(Some("cat:async_result"), None);
assert!(async_result.should_log("DidLoad"));
assert!(!async_result.should_log("Tick"));
let acronym = ActionLoggerConfig::new(Some("cat:api"), None);
assert!(acronym.should_log("APIFetchStart"));
assert!(!acronym.should_log("OpenConnectionForm"));
}
#[test]
fn test_action_logger_config_category_glob_and_case_insensitive_prefix() {
let config = ActionLoggerConfig::new(Some("CAT:search*"), None);
assert!(config.should_log("SearchStart"));
assert!(config.should_log("SearchQuerySubmit"));
assert!(!config.should_log("WeatherDidLoad"));
}
#[derive(Clone, Debug)]
enum TestAction {
Tick,
Connect,
SearchStart,
}
impl tui_dispatch_core::Action for TestAction {
fn name(&self) -> &'static str {
match self {
TestAction::Tick => "Tick",
TestAction::Connect => "Connect",
TestAction::SearchStart => "SearchStart",
}
}
}
impl tui_dispatch_core::ActionParams for TestAction {
fn params(&self) -> String {
String::new()
}
}
#[test]
fn test_action_log_basic() {
let mut log = ActionLog::default();
assert!(log.is_empty());
log.log(&TestAction::Connect);
assert_eq!(log.len(), 1);
let entry = log.entries().next().unwrap();
assert_eq!(entry.name, "Connect");
assert_eq!(entry.sequence, 0);
}
#[test]
fn test_action_log_filtering() {
let mut log = ActionLog::default();
log.log(&TestAction::Tick);
assert!(log.is_empty());
log.log(&TestAction::Connect);
assert_eq!(log.len(), 1);
}
#[test]
fn test_action_log_capacity() {
let config = ActionLogConfig::new(
3,
ActionLoggerConfig::with_patterns(vec![], vec![]), );
let mut log = ActionLog::new(config);
log.log(&TestAction::Connect);
log.log(&TestAction::Connect);
log.log(&TestAction::Connect);
assert_eq!(log.len(), 3);
log.log(&TestAction::Connect);
assert_eq!(log.len(), 3);
assert_eq!(log.entries().next().unwrap().sequence, 1);
}
#[test]
fn test_action_log_recent() {
let config = ActionLogConfig::new(10, ActionLoggerConfig::with_patterns(vec![], vec![]));
let mut log = ActionLog::new(config);
for _ in 0..5 {
log.log(&TestAction::Connect);
}
let recent: Vec<_> = log.recent(3).collect();
assert_eq!(recent.len(), 3);
assert_eq!(recent[0].sequence, 4); assert_eq!(recent[1].sequence, 3);
assert_eq!(recent[2].sequence, 2);
}
#[test]
fn test_action_log_entry_elapsed() {
let entry = ActionLogEntry::new(
"Test",
"test_params".to_string(),
"test_params".to_string(),
0,
);
assert_eq!(entry.elapsed, "0ms");
}
#[test]
fn test_middleware_filtering() {
use tui_dispatch_core::store::Middleware;
let mut middleware = ActionLoggerMiddleware::with_default_log();
middleware.before(&TestAction::Connect, &());
middleware.after(&TestAction::Connect, true, &());
let log = middleware.log().unwrap();
assert_eq!(log.len(), 1);
middleware.before(&TestAction::Tick, &());
middleware.after(&TestAction::Tick, false, &());
let log = middleware.log().unwrap();
assert_eq!(log.len(), 1);
}
#[test]
fn test_action_log_include_category_with_exclude_name_can_filter_all() {
let config = ActionLogConfig::new(
10,
ActionLoggerConfig::new(Some("cat:search"), Some("name:SearchStart")),
);
let mut log = ActionLog::new(config);
log.log(&TestAction::SearchStart);
log.log(&TestAction::Connect);
assert!(log.is_empty());
}
}