pub mod dispatch;
pub mod monitor;
pub mod reflect;
pub mod self_test;
use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
#[derive(Debug, Clone, Deserialize)]
pub struct Gap {
pub id: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub priority: String,
#[serde(default)]
pub effort: String,
#[serde(default)]
pub status: String,
#[serde(default)]
pub depends_on: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct GapsFile {
#[serde(default)]
gaps: Vec<Gap>,
}
pub fn load_gaps(path: &Path) -> Result<Vec<Gap>> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading gaps file at {}", path.display()))?;
let parsed: GapsFile = serde_yaml::from_str(&text)
.with_context(|| format!("parsing YAML at {}", path.display()))?;
Ok(parsed.gaps)
}
pub fn done_ids(all: &[Gap]) -> HashSet<String> {
all.iter()
.filter(|g| g.status == "done")
.map(|g| g.id.clone())
.collect()
}
pub fn pickable_gaps<'a>(all: &'a [Gap], n: usize, done_ids: &HashSet<String>) -> Vec<&'a Gap> {
all.iter()
.filter(|g| g.status == "open")
.filter(|g| g.priority == "P1" || g.priority == "P2")
.filter(|g| g.effort != "xl")
.filter(|g| g.depends_on.iter().flatten().all(|d| done_ids.contains(d)))
.take(n)
.collect()
}
fn priority_rank(p: &str) -> u8 {
match p {
"P1" => 1,
"P2" => 2,
"P3" => 3,
"P4" => 4,
_ => u8::MAX,
}
}
fn effort_rank(e: &str) -> u8 {
match e {
"xs" => 0,
"s" => 1,
"m" => 2,
"l" => 3,
"xl" => 4,
_ => u8::MAX,
}
}
pub fn dispatch_capacity() -> usize {
std::env::var("CHUMP_DISPATCH_CAPACITY")
.ok()
.and_then(|s| s.trim().parse::<usize>().ok())
.unwrap_or(3)
}
pub fn pick_gap<'a>(
all: &'a [Gap],
done_ids: &HashSet<String>,
live_claimed: &HashSet<String>,
active_count: usize,
capacity: usize,
) -> Option<&'a Gap> {
if active_count >= capacity {
return None;
}
let mut eligible: Vec<&Gap> = all
.iter()
.filter(|g| g.status == "open")
.filter(|g| !live_claimed.contains(&g.id))
.filter(|g| {
g.depends_on
.iter()
.flatten()
.all(|dep| done_ids.contains(dep))
})
.collect();
eligible.sort_by_key(|g| (priority_rank(&g.priority), effort_rank(&g.effort)));
eligible.into_iter().next()
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
fn g(id: &str, prio: &str, effort: &str, status: &str, deps: Option<Vec<&str>>) -> Gap {
Gap {
id: id.into(),
title: format!("title for {id}"),
priority: prio.into(),
effort: effort.into(),
status: status.into(),
depends_on: deps.map(|v| v.into_iter().map(String::from).collect()),
}
}
#[test]
fn picks_open_p1_first_n() {
let gaps = vec![
g("A", "P1", "m", "open", None),
g("B", "P1", "m", "open", None),
g("C", "P1", "m", "open", None),
];
let done = HashSet::new();
let picked = pickable_gaps(&gaps, 2, &done);
assert_eq!(picked.len(), 2);
assert_eq!(picked[0].id, "A");
assert_eq!(picked[1].id, "B");
}
#[test]
fn skips_done_and_p3_and_xl() {
let gaps = vec![
g("DONE", "P1", "m", "done", None),
g("P3-LO", "P3", "m", "open", None),
g("XL", "P1", "xl", "open", None),
g("OK", "P2", "l", "open", None),
];
let done = done_ids(&gaps);
let picked = pickable_gaps(&gaps, 5, &done);
assert_eq!(picked.len(), 1);
assert_eq!(picked[0].id, "OK");
}
#[test]
fn respects_unmet_dependency() {
let gaps = vec![
g("BLOCKER", "P1", "m", "open", None),
g("DEPENDENT", "P1", "m", "open", Some(vec!["BLOCKER"])),
];
let done = done_ids(&gaps);
let picked = pickable_gaps(&gaps, 5, &done);
assert_eq!(picked.len(), 1);
assert_eq!(picked[0].id, "BLOCKER");
}
#[test]
fn met_dependency_unblocks() {
let gaps = vec![
g("BLOCKER", "P1", "m", "done", None),
g("DEPENDENT", "P1", "m", "open", Some(vec!["BLOCKER"])),
];
let done = done_ids(&gaps);
let picked = pickable_gaps(&gaps, 5, &done);
assert_eq!(picked.len(), 1);
assert_eq!(picked[0].id, "DEPENDENT");
}
#[test]
fn n_zero_returns_empty() {
let gaps = vec![g("A", "P1", "m", "open", None)];
let picked = pickable_gaps(&gaps, 0, &HashSet::new());
assert!(picked.is_empty());
}
#[test]
fn empty_input_returns_empty() {
let picked = pickable_gaps(&[], 5, &HashSet::new());
assert!(picked.is_empty());
}
#[test]
fn multiple_unmet_deps_all_required() {
let gaps = vec![
g("A", "P1", "m", "done", None),
g("B", "P1", "m", "open", None), g("C", "P1", "m", "open", Some(vec!["A", "B"])),
];
let done = done_ids(&gaps);
let picked = pickable_gaps(&gaps, 5, &done);
let ids: Vec<&str> = picked.iter().map(|g| g.id.as_str()).collect();
assert_eq!(ids, vec!["B"]);
}
fn no_live() -> HashSet<String> {
HashSet::new()
}
#[test]
fn pick_gap_returns_none_when_capacity_full() {
let gaps = vec![g("A", "P1", "s", "open", None)];
let done = HashSet::new();
let result = pick_gap(&gaps, &done, &no_live(), 3, 3);
assert!(
result.is_none(),
"capacity=3 active=3 should block dispatch"
);
}
#[test]
fn pick_gap_returns_none_when_all_live_claimed() {
let gaps = vec![
g("A", "P1", "s", "open", None),
g("B", "P2", "m", "open", None),
];
let done = HashSet::new();
let live: HashSet<String> = ["A".to_string(), "B".to_string()].into();
let result = pick_gap(&gaps, &done, &live, 0, 3);
assert!(result.is_none(), "all gaps live-claimed → none available");
}
#[test]
fn pick_gap_skips_live_claimed_gap() {
let gaps = vec![
g("A", "P1", "s", "open", None),
g("B", "P2", "m", "open", None),
];
let done = HashSet::new();
let live: HashSet<String> = ["A".to_string()].into();
let result = pick_gap(&gaps, &done, &live, 0, 3).expect("B should be picked");
assert_eq!(result.id, "B", "A is live-claimed; B should be selected");
}
#[test]
fn pick_gap_dependency_blocking() {
let gaps = vec![
g("B", "P1", "m", "open", None),
g("C", "P1", "s", "open", Some(vec!["B"])),
];
let done: HashSet<String> = HashSet::new(); let result = pick_gap(&gaps, &done, &no_live(), 0, 3).expect("B should be picked");
assert_eq!(result.id, "B", "C has unmet dep; B should be selected");
}
#[test]
fn pick_gap_dependency_unblocked_when_dep_done() {
let gaps = vec![
g("B", "P1", "m", "done", None),
g("C", "P1", "s", "open", Some(vec!["B"])),
];
let done = done_ids(&gaps); let result = pick_gap(&gaps, &done, &no_live(), 0, 3).expect("C should be picked");
assert_eq!(result.id, "C", "B is done; C's dep is met");
}
#[test]
fn pick_gap_priority_ordering() {
let gaps = vec![
g("LOW", "P2", "s", "open", None),
g("HIGH", "P1", "l", "open", None),
];
let done = HashSet::new();
let result = pick_gap(&gaps, &done, &no_live(), 0, 3).expect("should pick");
assert_eq!(result.id, "HIGH", "P1 should beat P2");
}
#[test]
fn pick_gap_effort_ordering_within_same_priority() {
let gaps = vec![
g("BIG", "P1", "l", "open", None),
g("SMALL", "P1", "s", "open", None),
];
let done = HashSet::new();
let result = pick_gap(&gaps, &done, &no_live(), 0, 3).expect("should pick");
assert_eq!(result.id, "SMALL", "s effort beats l within same priority");
}
#[test]
fn pick_gap_none_when_all_done() {
let gaps = vec![g("A", "P1", "s", "done", None)];
let done = done_ids(&gaps);
let result = pick_gap(&gaps, &done, &no_live(), 0, 3);
assert!(result.is_none(), "all done → nothing to pick");
}
#[test]
fn pick_gap_capacity_allows_when_below_cap() {
let gaps = vec![g("A", "P1", "s", "open", None)];
let done = HashSet::new();
let result = pick_gap(&gaps, &done, &no_live(), 2, 3);
assert!(
result.is_some(),
"active=2 < capacity=3 should allow dispatch"
);
assert_eq!(result.unwrap().id, "A");
}
#[test]
#[serial(dispatch_capacity)]
fn dispatch_capacity_default_is_3() {
std::env::remove_var("CHUMP_DISPATCH_CAPACITY");
assert_eq!(dispatch_capacity(), 3);
}
#[test]
#[serial(dispatch_capacity)]
fn dispatch_capacity_respects_env() {
std::env::set_var("CHUMP_DISPATCH_CAPACITY", "5");
let cap = dispatch_capacity();
std::env::remove_var("CHUMP_DISPATCH_CAPACITY");
assert_eq!(cap, 5);
}
}