Skip to main content

ai_agent/utils/
debug_filter.rs

1//! Debug filter utilities
2//!
3//! Translated from openclaudecode/src/utils/debugFilter.ts
4
5use once_cell::sync::Lazy;
6use regex::Regex;
7
8/// Debug filter configuration
9#[derive(Debug, Clone)]
10pub struct DebugFilter {
11    pub include: Vec<String>,
12    pub exclude: Vec<String>,
13    pub is_exclusive: bool,
14}
15
16/// Parse debug filter string into a filter configuration
17/// Examples:
18/// - "api,hooks" -> include only api and hooks categories
19/// - "!1p,!file" -> exclude logging and file categories
20/// - None/empty -> no filtering (show all)
21pub fn parse_debug_filter(filter_string: Option<&str>) -> Option<DebugFilter> {
22    let filter_string = filter_string?.trim();
23    if filter_string.is_empty() {
24        return None;
25    }
26
27    let filters: Vec<&str> = filter_string
28        .split(',')
29        .map(|f| f.trim())
30        .filter(|f| !f.is_empty())
31        .collect();
32
33    // If no valid filters remain, return None
34    if filters.is_empty() {
35        return None;
36    }
37
38    // Check for mixed inclusive/exclusive filters
39    let has_exclusive: bool = filters.iter().any(|f| f.starts_with('!'));
40    let has_inclusive: bool = filters.iter().any(|f| !f.starts_with('!'));
41
42    if has_exclusive && has_inclusive {
43        // Mixed filters - show all messages
44        return None;
45    }
46
47    // Clean up filters (remove ! prefix) and normalize
48    let clean_filters: Vec<String> = filters
49        .iter()
50        .map(|f| f.trim_start_matches('!').to_lowercase())
51        .collect();
52
53    Some(DebugFilter {
54        include: if has_exclusive {
55            vec![]
56        } else {
57            clean_filters.clone()
58        },
59        exclude: if has_exclusive { clean_filters } else { vec![] },
60        is_exclusive: has_exclusive,
61    })
62}
63
64/// Extract debug categories from a message
65/// Supports multiple patterns:
66/// - "category: message" -> ["category"]
67/// - "[CATEGORY] message" -> ["category"]
68/// - "MCP server \"name\": message" -> ["mcp", "name"]
69/// - "[ANT-ONLY] 1P event: tengu_timer" -> ["ant-only", "1p"]
70///
71/// Returns lowercase categories for case-insensitive matching
72pub fn extract_debug_categories(message: &str) -> Vec<String> {
73    let mut categories: Vec<String> = Vec::new();
74
75    // Pattern 3: MCP server "servername" - Check this first to avoid false positives
76    static MCP_REGEX: Lazy<Regex> =
77        Lazy::new(|| Regex::new(r#"^MCP server ["']([^"']+)["']"#).unwrap());
78    if let Some(mcp_match) = MCP_REGEX.captures(message) {
79        if let Some(mcp_name) = mcp_match.get(1) {
80            categories.push("mcp".to_string());
81            categories.push(mcp_name.as_str().to_lowercase());
82        }
83    } else {
84        // Pattern 1: "category: message" (simple prefix) - only if not MCP pattern
85        static PREFIX_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^([^:[]+):").unwrap());
86        if let Some(prefix_match) = PREFIX_REGEX.captures(message) {
87            if let Some(prefix) = prefix_match.get(1) {
88                categories.push(prefix.as_str().trim().to_lowercase());
89            }
90        }
91    }
92
93    // Pattern 2: [CATEGORY] at the start
94    static BRACKET_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\[([^\]]+)]").unwrap());
95    if let Some(bracket_match) = BRACKET_REGEX.captures(message) {
96        if let Some(bracket) = bracket_match.get(1) {
97            categories.push(bracket.as_str().trim().to_lowercase());
98        }
99    }
100
101    // Pattern 4: Check for additional categories in the message
102    if message.to_lowercase().contains("1p event:") {
103        categories.push("1p".to_string());
104    }
105
106    // Pattern 5: Look for secondary categories after the first pattern
107    static SECONDARY_REGEX: Lazy<Regex> =
108        Lazy::new(|| Regex::new(r":\s*([^:]+?)(?:\s+(?:type|mode|status|event))?:").unwrap());
109    if let Some(secondary_match) = SECONDARY_REGEX.captures(message) {
110        if let Some(secondary) = secondary_match.get(1) {
111            let secondary = secondary.as_str().trim().to_lowercase();
112            // Only add if it's a reasonable category name (not too long, no spaces)
113            if secondary.len() < 30 && !secondary.contains(' ') {
114                categories.push(secondary);
115            }
116        }
117    }
118
119    // Remove duplicates
120    categories.sort();
121    categories.dedup();
122    categories
123}
124
125/// Check if debug message should be shown based on filter
126pub fn should_show_debug_categories(categories: &[String], filter: &Option<DebugFilter>) -> bool {
127    // No filter means show everything
128    let filter = match filter {
129        Some(f) => f,
130        None => return true,
131    };
132
133    // If no categories found, handle based on filter mode
134    if categories.is_empty() {
135        // In exclusive mode, uncategorized messages are excluded by default for security
136        // In inclusive mode, uncategorized messages are excluded (must match a category)
137        return false;
138    }
139
140    if filter.is_exclusive {
141        // Exclusive mode: show if none of the categories are in the exclude list
142        !categories.iter().any(|cat| filter.exclude.contains(cat))
143    } else {
144        // Inclusive mode: show if any of the categories are in the include list
145        categories.iter().any(|cat| filter.include.contains(cat))
146    }
147}
148
149/// Main function to check if a debug message should be shown
150/// Combines extraction and filtering
151pub fn should_show_debug_message(message: &str, filter: &Option<DebugFilter>) -> bool {
152    // Fast path: no filter means show everything
153    if filter.is_none() {
154        return true;
155    }
156
157    // Only extract categories if we have a filter
158    let categories = extract_debug_categories(message);
159    should_show_debug_categories(&categories, filter)
160}