use regex::Regex;
use std::collections::VecDeque;
#[derive(Debug, Clone)]
pub struct CircuitBreakerConfig {
pub enabled: bool,
pub threshold: usize,
}
impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
enabled: true,
threshold: 5,
}
}
}
#[derive(Debug, Clone)]
pub struct ErrorHistory {
recent_errors: VecDeque<String>,
config: CircuitBreakerConfig,
}
impl ErrorHistory {
pub fn new(config: CircuitBreakerConfig) -> Self {
Self {
recent_errors: VecDeque::with_capacity(config.threshold),
config,
}
}
pub fn record_error(&mut self, error_msg: &str) {
let normalized = normalize_error_message(error_msg);
if self.recent_errors.len() >= self.config.threshold {
self.recent_errors.pop_front();
}
self.recent_errors.push_back(normalized);
}
pub fn detect_same_error(&self) -> bool {
if !self.config.enabled {
return false;
}
if self.recent_errors.len() < self.config.threshold {
return false;
}
if let Some(first) = self.recent_errors.front() {
self.recent_errors.iter().all(|e| e == first)
} else {
false
}
}
pub fn last_error(&self) -> Option<&str> {
self.recent_errors.back().map(|s| s.as_str())
}
}
fn normalize_error_message(msg: &str) -> String {
let json_field_regex = Regex::new(r#""[^"]+"\s*:\s*(?:false|true|null|"[^"]*"|\d+)"#)
.expect("Invalid JSON field regex");
let without_json_fields = json_field_regex.replace_all(msg, "");
let msg = without_json_fields.as_ref();
let path_regex = Regex::new(r"(/[a-zA-Z0-9_.\-/]+|[a-zA-Z]:\\[a-zA-Z0-9_.\-\\]+)")
.expect("Invalid path regex");
let msg = path_regex.replace_all(msg, "<PATH>");
let line_regex = Regex::new(r":\d+").expect("Invalid line regex");
let msg = line_regex.replace_all(&msg, ":<NUM>");
let timestamp_regex =
Regex::new(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}").expect("Invalid timestamp regex");
let msg = timestamp_regex.replace_all(&msg, "<TIMESTAMP>");
let number_regex = Regex::new(r"\b\d+\b").expect("Invalid number regex");
let msg = number_regex.replace_all(&msg, "<NUM>");
let whitespace_regex = Regex::new(r"\s+").expect("Invalid whitespace regex");
let normalized = whitespace_regex.replace_all(&msg, " ");
normalized.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_removes_paths() {
let msg = "File not found: /home/user/project/src/main.rs";
let normalized = normalize_error_message(msg);
assert_eq!(normalized, "File not found: <PATH>");
}
#[test]
fn test_normalize_removes_line_numbers() {
let msg = "Error at main.rs:42:15";
let normalized = normalize_error_message(msg);
assert_eq!(normalized, "Error at main.rs:<NUM>:<NUM>");
}
#[test]
fn test_normalize_removes_json_fields() {
let msg = r#"Error occurred: {"is_error": false, "status": "ok"}"#;
let normalized = normalize_error_message(msg);
assert!(!normalized.contains("is_error"));
assert!(!normalized.contains("false"));
assert!(normalized.contains("Error occurred"));
}
#[test]
fn test_normalize_handles_complex_json() {
let msg = r#"Response: {"is_error": false, "message": "OK", "count": 42}"#;
let normalized = normalize_error_message(msg);
assert!(!normalized.contains("is_error"));
assert!(!normalized.contains("message"));
assert!(!normalized.contains("count"));
}
#[test]
fn test_detect_same_error_with_threshold() {
let config = CircuitBreakerConfig {
enabled: true,
threshold: 3,
};
let mut history = ErrorHistory::new(config);
history.record_error("File not found: /path/a");
assert!(!history.detect_same_error());
history.record_error("File not found: /path/b");
assert!(!history.detect_same_error());
history.record_error("File not found: /path/c");
assert!(history.detect_same_error());
}
#[test]
fn test_detect_same_error_different_errors() {
let config = CircuitBreakerConfig {
enabled: true,
threshold: 3,
};
let mut history = ErrorHistory::new(config);
history.record_error("File not found: /path/a");
history.record_error("Permission denied: /path/b");
history.record_error("File not found: /path/c");
assert!(!history.detect_same_error());
}
#[test]
fn test_circuit_breaker_disabled() {
let config = CircuitBreakerConfig {
enabled: false,
threshold: 3,
};
let mut history = ErrorHistory::new(config);
history.record_error("Same error 1");
history.record_error("Same error 2");
history.record_error("Same error 3");
assert!(!history.detect_same_error());
}
#[test]
fn test_clear_history() {
let config = CircuitBreakerConfig::default();
let mut history = ErrorHistory::new(config);
history.record_error("Error 1");
history.record_error("Error 2");
assert_eq!(history.recent_errors.len(), 2);
history.recent_errors.clear();
assert_eq!(history.recent_errors.len(), 0);
assert!(!history.detect_same_error());
}
#[test]
fn test_last_error() {
let config = CircuitBreakerConfig::default();
let mut history = ErrorHistory::new(config);
assert!(history.last_error().is_none());
history.record_error("Error 1");
assert!(history.last_error().is_some());
history.record_error("Error 2");
let last = history.last_error().unwrap();
assert!(last.contains("Error"));
}
}