use serde_json::{json, Value};
use tool_loop_guard::{default_key_fn, ConfigError, LoopDetectedError, LoopGuard};
#[test]
fn default_key_includes_tool_name() {
let args = json!({"q": "x"});
let a = default_key_fn("search", &args);
let b = default_key_fn("fetch", &args);
assert_ne!(a, b);
}
#[test]
fn default_key_canonicalizes_arg_order() {
let a = default_key_fn("t", &json!({"a": 1, "b": 2}));
let b = default_key_fn("t", &json!({"b": 2, "a": 1}));
assert_eq!(a, b);
}
#[test]
fn default_key_null_args_is_empty_object() {
let a = default_key_fn("t", &Value::Null);
let b = default_key_fn("t", &json!({}));
assert_eq!(a, b);
assert_eq!(a.1, "{}");
}
#[test]
fn default_key_nested_canonicalization() {
let a = default_key_fn("t", &json!({"o": {"a": 1, "b": 2}, "p": [3, {"d": 4, "c": 5}]}));
let b = default_key_fn("t", &json!({"p": [3, {"c": 5, "d": 4}], "o": {"b": 2, "a": 1}}));
assert_eq!(a, b);
}
#[test]
fn records_under_threshold_does_not_raise() {
let mut guard = LoopGuard::with_capacity(4, 3);
let args = json!({"q": "x"});
guard.record("search", &args).unwrap();
guard.record("search", &args).unwrap();
assert_eq!(guard.len(), 2);
}
#[test]
fn third_match_raises_loop_detected() {
let mut guard = LoopGuard::with_capacity(4, 3);
let args = json!({"q": "x"});
guard.record("search", &args).unwrap();
guard.record("search", &args).unwrap();
let err = guard.record("search", &args).unwrap_err();
assert_eq!(err.tool_name, "search");
assert_eq!(err.count, 3);
assert_eq!(err.window, 4);
assert_eq!(err.threshold, 3);
}
#[test]
fn different_args_do_not_count() {
let mut guard = LoopGuard::with_capacity(4, 3);
guard.record("search", &json!({"q": "a"})).unwrap();
guard.record("search", &json!({"q": "b"})).unwrap();
guard.record("search", &json!({"q": "c"})).unwrap();
}
#[test]
fn different_tools_do_not_count() {
let mut guard = LoopGuard::with_capacity(4, 3);
let args = json!({"q": "x"});
guard.record("search", &args).unwrap();
guard.record("fetch", &args).unwrap();
guard.record("write", &args).unwrap();
}
#[test]
fn eviction_keeps_unrelated_loops_safe() {
let mut guard = LoopGuard::with_capacity(4, 3);
let q = json!({"q": "x"});
let empty = json!({});
guard.record("search", &q).unwrap();
guard.record("fill_a", &empty).unwrap();
guard.record("fill_b", &empty).unwrap();
guard.record("fill_c", &empty).unwrap();
guard.record("search", &q).unwrap();
assert_eq!(guard.len(), 4);
}
#[test]
fn old_match_evicted_before_threshold() {
let mut guard = LoopGuard::with_capacity(3, 2);
let q = json!({"q": "x"});
guard.record("search", &q).unwrap();
guard.record("filler", &json!({"k": 1})).unwrap();
guard.record("filler", &json!({"k": 2})).unwrap();
guard.record("search", &q).unwrap();
assert_eq!(guard.len(), 3);
}
#[test]
fn would_raise_predicts_next_record_outcome() {
let mut guard = LoopGuard::with_capacity(4, 3);
let x = json!({"q": "x"});
let y = json!({"q": "y"});
guard.record("search", &x).unwrap();
guard.record("search", &x).unwrap();
assert!(guard.would_raise("search", &x));
assert!(!guard.would_raise("search", &y));
}
#[test]
fn would_raise_does_not_mutate_buffer() {
let mut guard = LoopGuard::with_capacity(4, 3);
let x = json!({"q": "x"});
guard.record("search", &x).unwrap();
guard.record("search", &x).unwrap();
let len_before = guard.len();
let _ = guard.would_raise("search", &x);
let _ = guard.would_raise("search", &json!({"q": "y"}));
assert_eq!(guard.len(), len_before);
}
#[test]
fn would_raise_accounts_for_full_window_eviction() {
let mut guard = LoopGuard::with_capacity(3, 2);
guard.record("a", &Value::Null).unwrap();
guard.record("b", &Value::Null).unwrap();
guard.record("c", &Value::Null).unwrap();
assert!(!guard.would_raise("fresh", &Value::Null));
}
#[test]
fn reset_clears_buffer() {
let mut guard = LoopGuard::with_capacity(4, 3);
let x = json!({"q": "x"});
guard.record("search", &x).unwrap();
guard.record("search", &x).unwrap();
guard.reset();
assert_eq!(guard.len(), 0);
assert!(guard.is_empty());
guard.record("search", &x).unwrap();
guard.record("search", &x).unwrap();
}
#[test]
fn recent_keys_returns_oldest_first() {
let mut guard = LoopGuard::with_capacity(4, 4);
guard.record("a", &Value::Null).unwrap();
guard.record("b", &Value::Null).unwrap();
guard.record("c", &Value::Null).unwrap();
let names: Vec<&str> = guard.recent_keys().iter().map(|k| k.0.as_str()).collect();
assert_eq!(names, vec!["a", "b", "c"]);
}
#[test]
fn custom_key_fn_can_ignore_noisy_field() {
let stable_key = |tool_name: &str, args: &Value| {
let cleaned = if let Some(map) = args.as_object() {
let mut clone = map.clone();
clone.remove("request_id");
Value::Object(clone)
} else {
args.clone()
};
default_key_fn(tool_name, &cleaned)
};
let mut guard = LoopGuard::with_key_fn(4, 3, stable_key);
guard
.record("search", &json!({"q": "x", "request_id": "r1"}))
.unwrap();
guard
.record("search", &json!({"q": "x", "request_id": "r2"}))
.unwrap();
let err = guard
.record("search", &json!({"q": "x", "request_id": "r3"}))
.unwrap_err();
assert_eq!(err.count, 3);
}
#[test]
fn invalid_window_rejected() {
for &w in &[0usize, 1usize] {
assert!(matches!(
LoopGuard::try_new(w, 2),
Err(ConfigError::WindowTooSmall { .. })
));
}
}
#[test]
fn invalid_threshold_rejected() {
for &t in &[0usize, 1usize] {
assert!(matches!(
LoopGuard::try_new(5, t),
Err(ConfigError::ThresholdTooSmall { .. })
));
}
}
#[test]
fn threshold_larger_than_window_rejected() {
let err = LoopGuard::try_new(3, 4).unwrap_err();
assert!(matches!(
err,
ConfigError::ThresholdExceedsWindow {
window: 3,
threshold: 4
}
));
}
#[test]
#[should_panic(expected = "invalid LoopGuard configuration")]
fn with_capacity_panics_on_invalid_window() {
let _ = LoopGuard::with_capacity(1, 2);
}
#[test]
fn loop_detected_error_carries_full_context() {
let mut guard = LoopGuard::with_capacity(3, 2);
let args = json!({"q": "x"});
guard.record("search", &args).unwrap();
let err = guard.record("search", &args).unwrap_err();
assert_eq!(err.tool_name, "search");
assert_eq!(err.tool_args, args);
assert_eq!(err.count, 2);
assert_eq!(err.window, 3);
assert_eq!(err.threshold, 2);
}
#[test]
fn loop_detected_error_display_includes_tool_name() {
let err = LoopDetectedError {
tool_name: "search".into(),
tool_args: json!({"q": "x"}),
count: 3,
window: 5,
threshold: 3,
};
let s = err.to_string();
assert!(s.contains("search"), "missing tool name in: {s}");
assert!(s.contains("threshold=3"), "missing threshold in: {s}");
}
#[test]
fn loop_detected_error_is_std_error() {
fn assert_error<E: std::error::Error>() {}
assert_error::<LoopDetectedError>();
}