use std::collections::{HashMap, HashSet};
use crate::config::SweepConfig;
use crate::deferred::DeferredDoc;
pub fn unchecked_count(doc: &DeferredDoc) -> usize {
doc.items.iter().filter(|item| !item.done).count()
}
pub fn should_run_deferred_sweep(
deferred: &DeferredDoc,
sweep_cfg: &SweepConfig,
consecutive_sweeps: u32,
) -> bool {
if !sweep_cfg.enabled {
return false;
}
if consecutive_sweeps >= sweep_cfg.max_consecutive {
return false;
}
let pending = unchecked_count(deferred) as u32;
pending >= sweep_cfg.trigger_min_items
}
pub fn update_sweep_staleness(
attempts: &mut HashMap<String, u32>,
pre_texts: &HashSet<String>,
post_unchecked_texts: &HashSet<String>,
escalate_after: u32,
) -> Vec<(String, u32)> {
let threshold = escalate_after.max(1);
let mut crossed: Vec<(String, u32)> = Vec::new();
for text in pre_texts.intersection(post_unchecked_texts) {
let prev = attempts.get(text).copied().unwrap_or(0);
let new_count = prev.saturating_add(1);
attempts.insert(text.clone(), new_count);
if prev < threshold && new_count >= threshold {
crossed.push((text.clone(), new_count));
}
}
attempts.retain(|k, _| post_unchecked_texts.contains(k));
crossed
}
#[cfg(test)]
mod tests {
use super::*;
use crate::deferred::{DeferredItem, DeferredPhase};
use crate::plan::PhaseId;
fn pid(s: &str) -> PhaseId {
PhaseId::parse(s).unwrap()
}
fn doc_with_pending(n: usize) -> DeferredDoc {
DeferredDoc {
items: (0..n)
.map(|i| DeferredItem {
text: format!("pending item {i}"),
done: false,
})
.collect(),
phases: Vec::new(),
}
}
#[test]
fn unchecked_count_ignores_completed_and_phase_blocks() {
let doc = DeferredDoc {
items: vec![
DeferredItem {
text: "pending one".into(),
done: false,
},
DeferredItem {
text: "done one".into(),
done: true,
},
DeferredItem {
text: "pending two".into(),
done: false,
},
],
phases: vec![DeferredPhase {
source_phase: pid("07"),
title: "rework".into(),
body: "body".into(),
}],
};
assert_eq!(unchecked_count(&doc), 2);
}
#[test]
fn unchecked_count_zero_for_empty_doc() {
assert_eq!(unchecked_count(&DeferredDoc::empty()), 0);
}
#[test]
fn trigger_fires_at_threshold() {
let cfg = SweepConfig::default();
assert!(!should_run_deferred_sweep(
&doc_with_pending(cfg.trigger_min_items as usize - 1),
&cfg,
0
));
assert!(should_run_deferred_sweep(
&doc_with_pending(cfg.trigger_min_items as usize),
&cfg,
0
));
assert!(should_run_deferred_sweep(
&doc_with_pending(cfg.trigger_min_items as usize + 3),
&cfg,
0
));
}
#[test]
fn trigger_short_circuits_when_disabled() {
let cfg = SweepConfig {
enabled: false,
..SweepConfig::default()
};
assert!(!should_run_deferred_sweep(
&doc_with_pending(cfg.trigger_min_items as usize * 4),
&cfg,
0
));
}
#[test]
fn trigger_clamps_at_max_consecutive() {
let cfg = SweepConfig::default();
assert_eq!(cfg.max_consecutive, 1);
assert!(should_run_deferred_sweep(
&doc_with_pending(cfg.trigger_min_items as usize),
&cfg,
0
));
assert!(!should_run_deferred_sweep(
&doc_with_pending(cfg.trigger_min_items as usize),
&cfg,
cfg.max_consecutive
));
assert!(!should_run_deferred_sweep(
&doc_with_pending(cfg.trigger_min_items as usize),
&cfg,
cfg.max_consecutive + 5
));
}
#[test]
fn trigger_respects_higher_max_consecutive() {
let cfg = SweepConfig {
max_consecutive: 3,
..SweepConfig::default()
};
assert!(should_run_deferred_sweep(
&doc_with_pending(cfg.trigger_min_items as usize),
&cfg,
0
));
assert!(should_run_deferred_sweep(
&doc_with_pending(cfg.trigger_min_items as usize),
&cfg,
2
));
assert!(!should_run_deferred_sweep(
&doc_with_pending(cfg.trigger_min_items as usize),
&cfg,
3
));
}
fn texts<I: IntoIterator<Item = &'static str>>(items: I) -> HashSet<String> {
items.into_iter().map(str::to_string).collect()
}
#[test]
fn staleness_increments_intersection_only() {
let mut map = HashMap::new();
let pre = texts(["a", "b", "c"]);
let post = texts(["a", "b"]);
let crossed = update_sweep_staleness(&mut map, &pre, &post, 3);
assert_eq!(map.get("a").copied(), Some(1));
assert_eq!(map.get("b").copied(), Some(1));
assert!(!map.contains_key("c"));
assert!(crossed.is_empty());
}
#[test]
fn staleness_prunes_resolved_items() {
let mut map = HashMap::new();
map.insert("a".to_string(), 2);
map.insert("b".to_string(), 1);
let pre = texts(["a", "b"]);
let post = texts(["b"]);
update_sweep_staleness(&mut map, &pre, &post, 3);
assert!(!map.contains_key("a"));
assert_eq!(map.get("b").copied(), Some(2));
}
#[test]
fn staleness_emits_threshold_crossing_only_on_transition() {
let mut map = HashMap::new();
map.insert("survivor".to_string(), 2);
let pre = texts(["survivor"]);
let post = texts(["survivor"]);
let crossed = update_sweep_staleness(&mut map, &pre, &post, 3);
assert_eq!(
crossed,
vec![("survivor".to_string(), 3)],
"expected 2→3 crossing"
);
let crossed = update_sweep_staleness(&mut map, &pre, &post, 3);
assert!(
crossed.is_empty(),
"items already at/above threshold must not re-emit; got {crossed:?}"
);
assert_eq!(map.get("survivor").copied(), Some(4));
}
#[test]
fn staleness_text_rewrite_prunes_old_key_then_starts_fresh() {
let mut map = HashMap::new();
map.insert("old text".to_string(), 2);
let pre = texts(["old text"]);
let post = texts(["new text"]);
update_sweep_staleness(&mut map, &pre, &post, 3);
assert!(
!map.contains_key("old text"),
"rewritten item's old key must be pruned"
);
assert!(
!map.contains_key("new text"),
"the rewrite is one sweep early — no entry yet"
);
let pre = texts(["new text"]);
let post = texts(["new text"]);
update_sweep_staleness(&mut map, &pre, &post, 3);
assert_eq!(map.get("new text").copied(), Some(1));
}
#[test]
fn staleness_zero_escalate_after_clamps_to_one() {
let mut map = HashMap::new();
let pre = texts(["a"]);
let post = texts(["a"]);
let crossed = update_sweep_staleness(&mut map, &pre, &post, 0);
assert_eq!(crossed, vec![("a".to_string(), 1)]);
}
#[test]
fn trigger_max_items_does_not_gate() {
let cfg = SweepConfig::default();
let huge = doc_with_pending(cfg.trigger_max_items as usize * 4);
assert!(should_run_deferred_sweep(&huge, &cfg, 0));
}
}