use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct YankDecision {
pub confirmed_dead: Vec<String>,
pub suspicious: Vec<String>,
pub healthy: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct YankGuard {
grace_cycles: u32,
miss_counts: HashMap<String, u32>,
}
impl YankGuard {
pub fn new(grace_cycles: u32) -> Self {
Self {
grace_cycles,
miss_counts: HashMap::new(),
}
}
pub fn classify(
&mut self,
current_entries: &[String],
still_present: &HashSet<String>,
) -> YankDecision {
let current_set: HashSet<String> = current_entries
.iter()
.map(|s| s.to_lowercase())
.collect();
let mut confirmed_dead = Vec::new();
let mut suspicious = Vec::new();
let mut healthy = Vec::new();
let mut observed: HashSet<String> = HashSet::new();
for entry in current_entries {
let key = entry.to_lowercase();
if observed.contains(&key) {
continue;
}
observed.insert(key.clone());
if still_present.contains(&key) {
self.miss_counts.remove(&key);
healthy.push(entry.clone());
} else {
let count = self.miss_counts.entry(key.clone()).or_insert(0);
*count += 1;
if *count > self.grace_cycles {
confirmed_dead.push(entry.clone());
} else {
suspicious.push(entry.clone());
}
}
}
self.miss_counts
.retain(|key, _| current_set.contains(key));
YankDecision {
confirmed_dead,
suspicious,
healthy,
}
}
pub fn suspicious_count(&self) -> usize {
self.miss_counts.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
fn set(items: &[&str]) -> HashSet<String> {
items.iter().map(|s| s.to_lowercase()).collect()
}
#[test]
fn entry_present_is_healthy_and_never_removed() {
let mut guard = YankGuard::new(2);
let present = set(&["c:\\tools"]);
let decision = guard.classify(&["C:\\Tools".to_string()], &present);
assert_eq!(decision.healthy, vec!["C:\\Tools".to_string()]);
assert!(decision.confirmed_dead.is_empty());
assert!(decision.suspicious.is_empty());
}
#[test]
fn entry_missing_within_grace_is_suspicious_not_dead() {
let mut guard = YankGuard::new(3);
let present = set(&[]);
let d1 = guard.classify(&["Z:\\Drive".to_string()], &present);
assert_eq!(d1.suspicious, vec!["Z:\\Drive".to_string()]);
assert!(d1.confirmed_dead.is_empty());
let d2 = guard.classify(&["Z:\\Drive".to_string()], &present);
assert_eq!(d2.suspicious, vec!["Z:\\Drive".to_string()]);
assert!(d2.confirmed_dead.is_empty());
let d3 = guard.classify(&["Z:\\Drive".to_string()], &present);
assert_eq!(d3.suspicious, vec!["Z:\\Drive".to_string()]);
assert!(d3.confirmed_dead.is_empty());
}
#[test]
fn entry_missing_past_grace_is_confirmed_dead() {
let mut guard = YankGuard::new(2);
let present = set(&[]);
guard.classify(&["Z:\\Drive".to_string()], &present);
guard.classify(&["Z:\\Drive".to_string()], &present);
let d3 = guard.classify(&["Z:\\Drive".to_string()], &present);
assert_eq!(d3.confirmed_dead, vec!["Z:\\Drive".to_string()]);
}
#[test]
fn grace_cycles_zero_removes_immediately() {
let mut guard = YankGuard::new(0);
let present = set(&[]);
let d = guard.classify(&["Z:\\Drive".to_string()], &present);
assert_eq!(d.confirmed_dead, vec!["Z:\\Drive".to_string()]);
assert!(d.suspicious.is_empty());
}
#[test]
fn reappearance_resets_counter() {
let mut guard = YankGuard::new(2);
let absent = set(&[]);
let present = set(&["z:\\drive"]);
guard.classify(&["Z:\\Drive".to_string()], &absent);
guard.classify(&["Z:\\Drive".to_string()], &absent);
let d = guard.classify(&["Z:\\Drive".to_string()], &present);
assert_eq!(d.healthy, vec!["Z:\\Drive".to_string()]);
assert!(d.confirmed_dead.is_empty());
assert!(d.suspicious.is_empty());
assert_eq!(guard.suspicious_count(), 0);
}
proptest! {
#[test]
fn classify_partitions_entries(
ref entries in prop::collection::vec("[a-zA-Z]:\\\\[a-zA-Z0-9_]+", 0..8),
grace_cycles in 0u32..5,
ref present_flags in prop::collection::vec(proptest::bool::ANY, 0..8),
) {
let present_set: HashSet<String> = entries.iter()
.enumerate()
.filter(|(i, _)| present_flags.get(*i).copied().unwrap_or(false))
.map(|(_, e)| e.to_lowercase())
.collect();
let mut guard = YankGuard::new(grace_cycles);
let d = guard.classify(entries, &present_set);
let dead_set: HashSet<String> = d.confirmed_dead.iter().map(|e| e.to_lowercase()).collect();
let susp_set: HashSet<String> = d.suspicious.iter().map(|e| e.to_lowercase()).collect();
let healthy_set: HashSet<String> = d.healthy.iter().map(|e| e.to_lowercase()).collect();
let dead_susp_intersection: HashSet<&String> = dead_set.intersection(&susp_set).collect();
let dead_healthy_intersection: HashSet<&String> = dead_set.intersection(&healthy_set).collect();
let susp_healthy_intersection: HashSet<&String> = susp_set.intersection(&healthy_set).collect();
prop_assert!(dead_susp_intersection.is_empty(), "confirmed_dead and suspicious overlap");
prop_assert!(dead_healthy_intersection.is_empty(), "confirmed_dead and healthy overlap");
prop_assert!(susp_healthy_intersection.is_empty(), "suspicious and healthy overlap");
}
#[test]
fn classify_with_grace_zero_confirms_immediately(
ref entries in prop::collection::vec("[a-zA-Z]:\\\\[a-zA-Z0-9_]+", 1..8),
) {
let present_set = HashSet::new();
let mut guard = YankGuard::new(0);
let d = guard.classify(entries, &present_set);
prop_assert!(
d.suspicious.is_empty(),
"grace_cycles=0 must not produce suspicious entries"
);
let entry_set: HashSet<String> = entries.iter().map(|e| e.to_lowercase()).collect();
let dead_set: HashSet<String> = d.confirmed_dead.iter().map(|e| e.to_lowercase()).collect();
prop_assert_eq!(
entry_set, dead_set,
"grace_cycles=0: all entries should be confirmed dead"
);
}
#[test]
fn healthy_entry_clears_miss_counter(
ref entry in "[a-zA-Z]:\\\\[a-zA-Z0-9_]+",
grace_cycles in 1u32..5,
) {
let absent = HashSet::new();
let present = set(&[entry]);
let mut guard = YankGuard::new(grace_cycles);
let _ = guard.classify(std::slice::from_ref(entry), &absent);
let d2 = guard.classify(std::slice::from_ref(entry), &present);
prop_assert!(
d2.healthy.iter().any(|h| h.eq_ignore_ascii_case(entry)),
"healthy entry must appear in healthy vec"
);
prop_assert!(d2.confirmed_dead.is_empty(), "healthy entry must not be confirmed dead");
prop_assert!(d2.suspicious.is_empty(), "healthy entry must not be suspicious");
prop_assert_eq!(guard.suspicious_count(), 0, "suspicious_count must be 0 after healthy");
}
}
}