use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
pub tool: String,
pub input_key: String,
pub out_hash: u64,
pub out_len: usize,
pub err: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Class {
Repeat,
Oscillation,
Spiral,
ErrorStorm,
}
impl Class {
pub fn as_str(&self) -> &'static str {
match self {
Class::Repeat => "repeat",
Class::Oscillation => "oscillation",
Class::Spiral => "spiral",
Class::ErrorStorm => "error_storm",
}
}
}
pub const SPIRAL_FACTOR: f64 = 2.5;
pub const SPIRAL_MIN_BASE: usize = 1024;
const OSC_MIN: usize = 3;
pub fn asks_enabled() -> bool {
!matches!(
std::env::var("OCSERVE_LOOP_GUARD")
.unwrap_or_default()
.as_str(),
"0" | "off" | "false"
)
}
pub fn input_key(arguments: &str) -> String {
match serde_json::from_str::<serde_json::Value>(arguments) {
Ok(v) => v.to_string(),
Err(_) => arguments.to_string(),
}
}
pub fn output_hash(output: &str) -> u64 {
let mut h = DefaultHasher::new();
output.hash(&mut h);
h.finish()
}
pub fn self_throttled(arguments: &str) -> bool {
arguments.contains("sleep ") || arguments.contains("tail -f") || arguments.contains("watch -")
}
pub fn entry(tool: &str, arguments: &str, output: &str, err: bool) -> Entry {
Entry {
tool: tool.to_string(),
input_key: input_key(arguments),
out_hash: output_hash(output),
out_len: output.len(),
err,
}
}
pub fn check_pre(win: &[Entry], tool: &str, input_key: &str) -> Option<Class> {
if win.len() >= 2 {
let a = &win[win.len() - 2];
let b = &win[win.len() - 1];
if a.tool == tool && b.tool == tool && a.input_key == input_key && b.input_key == input_key
{
return Some(Class::Repeat);
}
}
if win.len() >= OSC_MIN {
let n = win.len();
let a1 = &win[n - 3];
let b1 = &win[n - 2];
let a2 = &win[n - 1];
let key_a = (a1.tool.clone(), a1.input_key.clone());
let key_b = (b1.tool.clone(), b1.input_key.clone());
let current = (tool.to_string(), input_key.to_string());
if key_a == (a2.tool.clone(), a2.input_key.clone()) && key_a != key_b && current == key_b {
return Some(Class::Oscillation);
}
}
None
}
pub fn check_post(win: &[Entry]) -> Option<Class> {
if win.len() >= 3 {
let n = win.len();
let b2 = &win[n - 3].out_len;
let b1 = &win[n - 2].out_len;
let b0 = &win[n - 1].out_len;
if *b2 >= SPIRAL_MIN_BASE
&& (*b1 as f64) >= (*b2 as f64) * SPIRAL_FACTOR
&& (*b0 as f64) >= (*b1 as f64) * SPIRAL_FACTOR
{
return Some(Class::Spiral);
}
}
if win.len() >= 3 {
let n = win.len();
if win[n - 1].err && win[n - 2].err && win[n - 3].err {
return Some(Class::ErrorStorm);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn e(tool: &str, input: &str, len: usize, err: bool) -> Entry {
Entry {
tool: tool.into(),
input_key: input.into(),
out_hash: 0,
out_len: len,
err,
}
}
#[test]
fn input_key_normalizes_whitespace_and_key_order_is_raw() {
assert_eq!(
input_key(r#"{"a":1,"b":2}"#),
input_key(r#" { "a" : 1 , "b" : 2 } "#)
);
assert_ne!(input_key(r#"{"a":1,"b":2}"#), input_key(r#"{"b":2,"a":1}"#));
}
#[test]
fn repeat_negative_control_fires_on_third_not_second() {
let w = vec![e("bash", "k", 10, false)];
assert_eq!(
check_pre(&w, "bash", "k"),
None,
"2nd identical must NOT fire"
);
let w = vec![e("bash", "k", 10, false), e("bash", "k", 10, false)];
assert_eq!(
check_pre(&w, "bash", "k"),
Some(Class::Repeat),
"3rd identical fires (upstream parity)"
);
let w = vec![e("bash", "k", 10, false), e("bash", "k2", 10, false)];
assert_eq!(check_pre(&w, "bash", "k"), None, "changed input resets");
}
#[test]
fn oscillation_fires_abab_not_aba_or_abc() {
let w = vec![
e("bash", "x", 1, false),
e("bash", "y", 1, false),
e("bash", "x", 1, false),
];
assert_eq!(check_pre(&w, "bash", "y"), Some(Class::Oscillation));
let w = vec![
e("bash", "x", 1, false),
e("bash", "y", 1, false),
e("bash", "z", 1, false),
];
assert_eq!(
check_pre(&w, "bash", "w"),
None,
"non-alternating must not fire"
);
let w = vec![
e("bash", "x", 1, false),
e("bash", "y", 1, false),
e("bash", "x", 1, false),
];
assert_eq!(check_pre(&w, "bash", "x"), None);
}
#[test]
fn spiral_needs_two_consecutive_growth_steps_over_material_base() {
let w = vec![
e("bash", "a", 1024, false),
e("bash", "b", 3072, false),
e("bash", "c", 8000, false),
];
assert_eq!(check_post(&w), Some(Class::Spiral));
let w = vec![
e("bash", "a", 1024, false),
e("bash", "b", 4096, false),
e("bash", "c", 4100, false),
];
assert_eq!(check_post(&w), None, "one jump must not fire");
let w = vec![
e("bash", "a", 10, false),
e("bash", "b", 40, false),
e("bash", "c", 200, false),
];
assert_eq!(check_post(&w), None, "sub-1KiB base must not fire");
}
#[test]
fn error_storm_three_consecutive_errors() {
let w = vec![
e("bash", "a", 5, true),
e("bash", "b", 5, true),
e("bash", "c", 5, true),
];
assert_eq!(check_post(&w), Some(Class::ErrorStorm));
let w = vec![
e("bash", "a", 5, true),
e("bash", "b", 5, false),
e("bash", "c", 5, true),
];
assert_eq!(check_post(&w), None, "recovered run breaks the storm");
}
#[test]
fn kill_switch_env() {
unsafe {
std::env::remove_var("OCSERVE_LOOP_GUARD");
}
assert!(asks_enabled(), "default must be enabled");
unsafe {
std::env::set_var("OCSERVE_LOOP_GUARD", "0");
}
assert!(!asks_enabled(), "OCSERVE_LOOP_GUARD=0 disables asks");
unsafe {
std::env::remove_var("OCSERVE_LOOP_GUARD");
}
}
}
#[cfg(test)]
mod throttle_tests {
use super::*;
#[test]
fn self_throttled_detects_sleeping_polls() {
assert!(self_throttled(r#"{"command":"sleep 570; tail -n 50 log"}"#));
assert!(self_throttled(r#"{"command":"tail -f /var/log/x.log"}"#));
assert!(self_throttled(r#"{"command":"watch -n10 df -h"}"#));
assert!(!self_throttled(r#"{"command":"cargo test --workspace"}"#));
assert!(!self_throttled(r#"{"command":"grep -rn TODO src"}"#));
assert!(!self_throttled(
r#"{"command":"while true; do date; done"}"#
));
}
#[test]
fn throttled_entries_are_excluded_from_repeat_windows() {
let hot = entry("bash", r#"{"command":"cargo test"}"#, "ok", false);
let win = vec![hot.clone(), hot.clone()];
assert_eq!(
check_pre(&win, "bash", &input_key(r#"{"command":"cargo test"}"#)),
Some(Class::Repeat),
"hot repeat still detected"
);
assert!(self_throttled(r#"{"command":"sleep 60"}"#));
}
}