use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
pub fn normalize_signature(sig: &str) -> String {
if let Some(cmd) = sig.strip_prefix("bash:") {
normalize_bash(cmd)
} else {
sig.to_string()
}
}
const SEARCH_BINS: &[&str] = &["rg", "grep", "ag", "ack", "fgrep", "egrep"];
fn normalize_bash(cmd: &str) -> String {
let core = [" || ", " && ", " ; ", " | "]
.iter()
.fold(cmd, |acc, sep| acc.split(sep).next().unwrap_or(acc))
.trim();
let tokens: Vec<&str> = core.split_whitespace().collect();
if tokens.is_empty() {
return "bash:".into();
}
let bin = tokens[0];
let args: Vec<String> = tokens[1..]
.iter()
.filter(|t| !t.starts_with('-'))
.map(|t| {
t.trim_matches(|c: char| c == '\'' || c == '"')
.trim_end_matches('/')
.to_string()
})
.filter(|s| !s.is_empty())
.collect();
if SEARCH_BINS.contains(&bin) {
format!("bash-search:{}", args.join(" "))
} else if args.is_empty() {
format!("bash:{}", bin)
} else {
format!("bash:{}:{}", bin, args.join(" "))
}
}
struct ConsecutiveTracker {
last: Option<String>,
count: usize,
}
impl ConsecutiveTracker {
fn new() -> Self {
Self {
last: None,
count: 0,
}
}
fn record(&mut self, value: &str) -> usize {
if self.last.as_deref() == Some(value) {
self.count += 1;
} else {
self.last = Some(value.to_string());
self.count = 1;
}
self.count
}
fn reset(&mut self) {
self.last = None;
self.count = 0;
}
fn count(&self) -> usize {
self.count
}
}
struct HashTracker {
last_hash: Option<u64>,
count: usize,
}
impl HashTracker {
fn new() -> Self {
Self {
last_hash: None,
count: 0,
}
}
fn record(&mut self, value: &str) -> usize {
let mut hasher = DefaultHasher::new();
value.hash(&mut hasher);
let hash = hasher.finish();
if self.last_hash == Some(hash) {
self.count += 1;
} else {
self.last_hash = Some(hash);
self.count = 1;
}
self.count
}
fn reset(&mut self) {
self.last_hash = None;
self.count = 0;
}
fn count(&self) -> usize {
self.count
}
}
struct FrequencyTracker {
window: Vec<String>,
window_size: usize,
}
impl FrequencyTracker {
fn new(window_size: usize) -> Self {
Self {
window: Vec::new(),
window_size,
}
}
fn record(&mut self, value: &str) -> usize {
self.window.push(value.to_string());
if self.window.len() > self.window_size {
self.window.remove(0);
}
self.max_frequency()
}
fn max_frequency(&self) -> usize {
if self.window.is_empty() {
return 0;
}
let mut counts = std::collections::HashMap::<&str, usize>::new();
for v in &self.window {
*counts.entry(v.as_str()).or_insert(0) += 1;
}
counts.values().copied().max().unwrap_or(0)
}
fn reset(&mut self) {
self.window.clear();
}
}
pub struct LoopDetector {
exact: ConsecutiveTracker,
category: ConsecutiveTracker,
output: HashTracker,
frequency: FrequencyTracker,
abort_threshold: usize,
warn_threshold: usize,
}
#[derive(Debug, PartialEq)]
pub enum LoopStatus {
Ok,
Warning(usize),
Abort(usize),
}
impl LoopDetector {
pub fn new(abort_threshold: usize) -> Self {
Self {
exact: ConsecutiveTracker::new(),
category: ConsecutiveTracker::new(),
output: HashTracker::new(),
frequency: FrequencyTracker::new(abort_threshold * 2),
abort_threshold,
warn_threshold: abort_threshold.div_ceil(2),
}
}
pub fn with_thresholds(warn_threshold: usize, abort_threshold: usize) -> Self {
Self {
exact: ConsecutiveTracker::new(),
category: ConsecutiveTracker::new(),
output: HashTracker::new(),
frequency: FrequencyTracker::new(abort_threshold * 2),
abort_threshold,
warn_threshold,
}
}
pub fn check(&mut self, signature: &str) -> LoopStatus {
self.check_with_category(signature, signature)
}
pub fn check_with_category(&mut self, signature: &str, category: &str) -> LoopStatus {
let exact_n = self.exact.record(signature);
let cat_n = self.category.record(category);
let freq_n = self.frequency.record(category);
let max_n = exact_n.max(cat_n).max(freq_n);
if max_n >= self.abort_threshold {
LoopStatus::Abort(max_n)
} else if max_n >= self.warn_threshold {
LoopStatus::Warning(max_n)
} else {
LoopStatus::Ok
}
}
pub fn record_output(&mut self, output: &str) -> LoopStatus {
let n = self.output.record(output);
if n >= self.abort_threshold {
LoopStatus::Abort(n)
} else if n >= self.warn_threshold {
LoopStatus::Warning(n)
} else {
LoopStatus::Ok
}
}
pub fn reset(&mut self) {
self.exact.reset();
self.category.reset();
self.output.reset();
self.frequency.reset();
}
pub fn repeat_count(&self) -> usize {
self.exact
.count()
.max(self.category.count())
.max(self.output.count())
.max(self.frequency.max_frequency())
}
pub fn exact_count(&self) -> usize {
self.exact.count()
}
pub fn category_count(&self) -> usize {
self.category.count()
}
pub fn output_count(&self) -> usize {
self.output.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_bash_search_strips_flags_and_quotes() {
assert_eq!(
normalize_signature("bash:rg -n 'TODO|FIXME' crates/src/"),
"bash-search:TODO|FIXME crates/src"
);
}
#[test]
fn normalize_bash_search_double_quotes() {
assert_eq!(
normalize_signature("bash:rg -Hn \"TODO|FIXME\" crates/src/"),
"bash-search:TODO|FIXME crates/src"
);
}
#[test]
fn normalize_bash_search_strips_fallback() {
assert_eq!(
normalize_signature("bash:rg 'TODO' dir/ || echo 'not found'"),
"bash-search:TODO dir"
);
}
#[test]
fn normalize_bash_grep_same_as_rg() {
assert_eq!(
normalize_signature("bash:grep -rnE 'TODO|FIXME' src/"),
"bash-search:TODO|FIXME src"
);
}
#[test]
fn normalize_bash_complex_fallback() {
assert_eq!(
normalize_signature("bash:rg 'TODO' dir/ || (echo 'fail' && ls -la dir/)"),
"bash-search:TODO dir"
);
}
#[test]
fn normalize_non_bash_unchanged() {
assert_eq!(normalize_signature("read:src/main.rs"), "read:src/main.rs");
assert_eq!(
normalize_signature("write:config.toml"),
"write:config.toml"
);
assert_eq!(normalize_signature("edit:src/lib.rs"), "edit:src/lib.rs");
}
#[test]
fn normalize_bash_non_search_command() {
assert_eq!(normalize_signature("bash:cargo test"), "bash:cargo:test");
assert_eq!(normalize_signature("bash:ls -la /tmp"), "bash:ls:/tmp");
assert_eq!(normalize_signature("bash:cat file.rs"), "bash:cat:file.rs");
}
#[test]
fn normalize_all_rg_variants_equal() {
let variants = [
"bash:rg -n 'TODO|FIXME' crates/baml-agent/src/",
"bash:rg 'TODO|FIXME' crates/baml-agent/src/",
"bash:rg -i 'TODO|FIXME' crates/baml-agent/src/",
"bash:rg -Hn \"TODO|FIXME\" crates/baml-agent/src/",
"bash:rg -n \"TODO|FIXME\" crates/baml-agent/src/ || echo 'No matches'",
"bash:rg 'TODO|FIXME' crates/baml-agent/src/ || (echo 'fail' && ls -la)",
];
let normalized: Vec<String> = variants.iter().map(|v| normalize_signature(v)).collect();
let expected = "bash-search:TODO|FIXME crates/baml-agent/src";
for (i, n) in normalized.iter().enumerate() {
assert_eq!(n, expected, "variant {} failed: {}", i, variants[i]);
}
}
#[test]
fn no_loop_different_sigs() {
let mut d = LoopDetector::new(6);
assert_eq!(d.check("a"), LoopStatus::Ok);
assert_eq!(d.check("b"), LoopStatus::Ok);
assert_eq!(d.check("c"), LoopStatus::Ok);
}
#[test]
fn warn_then_abort() {
let mut d = LoopDetector::new(6);
assert_eq!(d.check("x"), LoopStatus::Ok);
assert_eq!(d.check("x"), LoopStatus::Ok); assert_eq!(d.check("x"), LoopStatus::Warning(3)); assert_eq!(d.check("x"), LoopStatus::Warning(4));
assert_eq!(d.check("x"), LoopStatus::Warning(5));
assert_eq!(d.check("x"), LoopStatus::Abort(6)); }
#[test]
fn reset_clears() {
let mut d = LoopDetector::new(4);
d.check("x");
d.check("x");
d.check("x"); d.reset();
assert_eq!(d.check("x"), LoopStatus::Ok); }
#[test]
fn different_sig_resets_consecutive_count() {
let mut d = LoopDetector::new(6);
d.check("x");
d.check("x");
d.check("x"); let status = d.check("y");
assert_eq!(status, LoopStatus::Warning(3));
assert_eq!(d.exact_count(), 1); }
#[test]
fn category_catches_semantic_loop() {
let mut d = LoopDetector::new(4); let sigs = [
"bash:rg -n 'TODO' src/",
"bash:rg 'TODO' src/",
"bash:rg -i 'TODO' src/",
"bash:grep -rn 'TODO' src/",
];
let results: Vec<LoopStatus> = sigs
.iter()
.map(|sig| {
let cat = normalize_signature(sig);
d.check_with_category(sig, &cat)
})
.collect();
assert_eq!(results[0], LoopStatus::Ok); assert_eq!(results[1], LoopStatus::Warning(2)); assert_eq!(results[2], LoopStatus::Warning(3)); assert_eq!(results[3], LoopStatus::Abort(4)); }
#[test]
fn different_categories_reset() {
let mut d = LoopDetector::new(4);
d.check_with_category("bash:rg 'A' src/", "bash-search:A src");
d.check_with_category("bash:rg 'A' src/", "bash-search:A src"); d.check_with_category("bash:cargo test", "bash:cargo:test");
assert_eq!(d.category.count(), 1);
}
#[test]
fn output_stagnation_detected() {
let mut d = LoopDetector::new(4); assert_eq!(d.record_output("No matches found"), LoopStatus::Ok);
assert_eq!(d.record_output("No matches found"), LoopStatus::Warning(2));
assert_eq!(d.record_output("No matches found"), LoopStatus::Warning(3));
assert_eq!(d.record_output("No matches found"), LoopStatus::Abort(4));
}
#[test]
fn output_different_resets() {
let mut d = LoopDetector::new(4);
d.record_output("result A");
d.record_output("result A"); assert_eq!(d.record_output("result B"), LoopStatus::Ok); }
#[test]
fn frequency_catches_alternating_pattern() {
let mut d = LoopDetector::new(6);
let sigs = [
("bash:cat src/types/index.ts", "bash:cat:src/types/index.ts"),
("bash:pwd", "bash:pwd"),
("bash:cat src/types/index.ts", "bash:cat:src/types/index.ts"),
("bash:pwd", "bash:pwd"),
("bash:cat src/types/index.ts", "bash:cat:src/types/index.ts"),
("bash:pwd", "bash:pwd"),
];
let mut statuses = Vec::new();
for (sig, cat) in &sigs {
statuses.push(d.check_with_category(sig, cat));
}
assert_eq!(statuses[0], LoopStatus::Ok);
assert_eq!(statuses[1], LoopStatus::Ok);
assert_eq!(statuses[2], LoopStatus::Ok); assert_eq!(statuses[3], LoopStatus::Ok); assert_eq!(statuses[4], LoopStatus::Warning(3)); assert_eq!(statuses[5], LoopStatus::Warning(3)); }
#[test]
fn frequency_aborts_heavy_churn() {
let mut d = LoopDetector::new(4);
for i in 0..8 {
let (sig, cat) = if i % 2 == 0 {
("bash:cat file.ts", "bash:cat:file.ts")
} else {
("bash:pwd", "bash:pwd")
};
let status = d.check_with_category(sig, cat);
if i == 7 {
assert_eq!(status, LoopStatus::Abort(4));
}
}
}
#[test]
fn semantic_loop_caught_within_threshold() {
let mut d = LoopDetector::new(6);
let steps: Vec<(&str, &str)> = vec![
("bash:rg \"TODO|FIXME\" crates/baml-agent/src/", ""),
("bash:rg -n 'TODO|FIXME' crates/baml-agent/src/", ""),
(
"bash:rg -n \"TODO|FIXME\" crates/baml-agent/src/ || echo 'No'",
"No TODO or FIXME found",
),
(
"bash:rg 'TODO|FIXME' crates/baml-agent/src/ || (echo && ls)",
"Search failed...",
),
(
"bash:rg 'TODO|FIXME' crates/baml-agent/src/",
"No TODO or FIXME found",
),
(
"bash:rg -n 'TODO|FIXME' crates/baml-agent/src/ || echo 'No'",
"No TODO or FIXME found",
),
];
let mut first_warning = None;
let mut abort_at = None;
for (i, (sig, output)) in steps.iter().enumerate() {
let cat = normalize_signature(sig);
match d.check_with_category(sig, &cat) {
LoopStatus::Warning(n) => {
if first_warning.is_none() {
first_warning = Some(i + 1);
}
let _ = n;
}
LoopStatus::Abort(_) => {
abort_at = Some(i + 1);
break;
}
LoopStatus::Ok => {}
}
d.record_output(output);
}
assert_eq!(first_warning, Some(3), "should warn at step 3");
assert_eq!(abort_at, Some(6), "should abort at step 6");
}
}