1pub mod dispatch;
14pub mod monitor;
15pub mod reflect;
16pub mod self_test;
17
18use anyhow::{Context, Result};
19use serde::Deserialize;
20use std::collections::HashSet;
21use std::path::Path;
22
23#[derive(Debug, Clone, Deserialize)]
29pub struct Gap {
30 pub id: String,
31 #[serde(default)]
32 pub title: String,
33 #[serde(default)]
34 pub priority: String,
35 #[serde(default)]
36 pub effort: String,
37 #[serde(default)]
38 pub status: String,
39 #[serde(default)]
40 pub depends_on: Option<Vec<String>>,
41}
42
43#[derive(Debug, Deserialize)]
44struct GapsFile {
45 #[serde(default)]
46 gaps: Vec<Gap>,
47}
48
49pub fn load_gaps(path: &Path) -> Result<Vec<Gap>> {
51 let text = std::fs::read_to_string(path)
52 .with_context(|| format!("reading gaps file at {}", path.display()))?;
53 let parsed: GapsFile = serde_yaml::from_str(&text)
54 .with_context(|| format!("parsing YAML at {}", path.display()))?;
55 Ok(parsed.gaps)
56}
57
58pub fn done_ids(all: &[Gap]) -> HashSet<String> {
60 all.iter()
61 .filter(|g| g.status == "done")
62 .map(|g| g.id.clone())
63 .collect()
64}
65
66pub fn pickable_gaps<'a>(all: &'a [Gap], n: usize, done_ids: &HashSet<String>) -> Vec<&'a Gap> {
80 all.iter()
81 .filter(|g| g.status == "open")
82 .filter(|g| g.priority == "P1" || g.priority == "P2")
83 .filter(|g| g.effort != "xl")
84 .filter(|g| g.depends_on.iter().flatten().all(|d| done_ids.contains(d)))
85 .take(n)
86 .collect()
87}
88
89fn priority_rank(p: &str) -> u8 {
94 match p {
95 "P1" => 1,
96 "P2" => 2,
97 "P3" => 3,
98 "P4" => 4,
99 _ => u8::MAX,
100 }
101}
102
103fn effort_rank(e: &str) -> u8 {
106 match e {
107 "xs" => 0,
108 "s" => 1,
109 "m" => 2,
110 "l" => 3,
111 "xl" => 4,
112 _ => u8::MAX,
113 }
114}
115
116pub fn dispatch_capacity() -> usize {
118 std::env::var("CHUMP_DISPATCH_CAPACITY")
119 .ok()
120 .and_then(|s| s.trim().parse::<usize>().ok())
121 .unwrap_or(3)
122}
123
124pub fn pick_gap<'a>(
146 all: &'a [Gap],
147 done_ids: &HashSet<String>,
148 live_claimed: &HashSet<String>,
149 active_count: usize,
150 capacity: usize,
151) -> Option<&'a Gap> {
152 if active_count >= capacity {
154 return None;
155 }
156
157 let mut eligible: Vec<&Gap> = all
158 .iter()
159 .filter(|g| g.status == "open")
161 .filter(|g| !live_claimed.contains(&g.id))
163 .filter(|g| {
165 g.depends_on
166 .iter()
167 .flatten()
168 .all(|dep| done_ids.contains(dep))
169 })
170 .collect();
171
172 eligible.sort_by_key(|g| (priority_rank(&g.priority), effort_rank(&g.effort)));
174
175 eligible.into_iter().next()
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use serial_test::serial;
183
184 fn g(id: &str, prio: &str, effort: &str, status: &str, deps: Option<Vec<&str>>) -> Gap {
185 Gap {
186 id: id.into(),
187 title: format!("title for {id}"),
188 priority: prio.into(),
189 effort: effort.into(),
190 status: status.into(),
191 depends_on: deps.map(|v| v.into_iter().map(String::from).collect()),
192 }
193 }
194
195 #[test]
196 fn picks_open_p1_first_n() {
197 let gaps = vec![
198 g("A", "P1", "m", "open", None),
199 g("B", "P1", "m", "open", None),
200 g("C", "P1", "m", "open", None),
201 ];
202 let done = HashSet::new();
203 let picked = pickable_gaps(&gaps, 2, &done);
204 assert_eq!(picked.len(), 2);
205 assert_eq!(picked[0].id, "A");
206 assert_eq!(picked[1].id, "B");
207 }
208
209 #[test]
210 fn skips_done_and_p3_and_xl() {
211 let gaps = vec![
212 g("DONE", "P1", "m", "done", None),
213 g("P3-LO", "P3", "m", "open", None),
214 g("XL", "P1", "xl", "open", None),
215 g("OK", "P2", "l", "open", None),
216 ];
217 let done = done_ids(&gaps);
218 let picked = pickable_gaps(&gaps, 5, &done);
219 assert_eq!(picked.len(), 1);
220 assert_eq!(picked[0].id, "OK");
221 }
222
223 #[test]
224 fn respects_unmet_dependency() {
225 let gaps = vec![
226 g("BLOCKER", "P1", "m", "open", None),
227 g("DEPENDENT", "P1", "m", "open", Some(vec!["BLOCKER"])),
228 ];
229 let done = done_ids(&gaps);
230 let picked = pickable_gaps(&gaps, 5, &done);
231 assert_eq!(picked.len(), 1);
233 assert_eq!(picked[0].id, "BLOCKER");
234 }
235
236 #[test]
237 fn met_dependency_unblocks() {
238 let gaps = vec![
239 g("BLOCKER", "P1", "m", "done", None),
240 g("DEPENDENT", "P1", "m", "open", Some(vec!["BLOCKER"])),
241 ];
242 let done = done_ids(&gaps);
243 let picked = pickable_gaps(&gaps, 5, &done);
244 assert_eq!(picked.len(), 1);
245 assert_eq!(picked[0].id, "DEPENDENT");
246 }
247
248 #[test]
249 fn n_zero_returns_empty() {
250 let gaps = vec![g("A", "P1", "m", "open", None)];
251 let picked = pickable_gaps(&gaps, 0, &HashSet::new());
252 assert!(picked.is_empty());
253 }
254
255 #[test]
256 fn empty_input_returns_empty() {
257 let picked = pickable_gaps(&[], 5, &HashSet::new());
258 assert!(picked.is_empty());
259 }
260
261 #[test]
262 fn multiple_unmet_deps_all_required() {
263 let gaps = vec![
264 g("A", "P1", "m", "done", None),
265 g("B", "P1", "m", "open", None), g("C", "P1", "m", "open", Some(vec!["A", "B"])),
267 ];
268 let done = done_ids(&gaps);
269 let picked = pickable_gaps(&gaps, 5, &done);
270 let ids: Vec<&str> = picked.iter().map(|g| g.id.as_str()).collect();
272 assert_eq!(ids, vec!["B"]);
273 }
274
275 fn no_live() -> HashSet<String> {
278 HashSet::new()
279 }
280
281 #[test]
282 fn pick_gap_returns_none_when_capacity_full() {
283 let gaps = vec![g("A", "P1", "s", "open", None)];
284 let done = HashSet::new();
285 let result = pick_gap(&gaps, &done, &no_live(), 3, 3);
287 assert!(
288 result.is_none(),
289 "capacity=3 active=3 should block dispatch"
290 );
291 }
292
293 #[test]
294 fn pick_gap_returns_none_when_all_live_claimed() {
295 let gaps = vec![
296 g("A", "P1", "s", "open", None),
297 g("B", "P2", "m", "open", None),
298 ];
299 let done = HashSet::new();
300 let live: HashSet<String> = ["A".to_string(), "B".to_string()].into();
301 let result = pick_gap(&gaps, &done, &live, 0, 3);
302 assert!(result.is_none(), "all gaps live-claimed → none available");
303 }
304
305 #[test]
306 fn pick_gap_skips_live_claimed_gap() {
307 let gaps = vec![
308 g("A", "P1", "s", "open", None),
309 g("B", "P2", "m", "open", None),
310 ];
311 let done = HashSet::new();
312 let live: HashSet<String> = ["A".to_string()].into();
313 let result = pick_gap(&gaps, &done, &live, 0, 3).expect("B should be picked");
314 assert_eq!(result.id, "B", "A is live-claimed; B should be selected");
315 }
316
317 #[test]
318 fn pick_gap_dependency_blocking() {
319 let gaps = vec![
321 g("B", "P1", "m", "open", None),
322 g("C", "P1", "s", "open", Some(vec!["B"])),
323 ];
324 let done: HashSet<String> = HashSet::new(); let result = pick_gap(&gaps, &done, &no_live(), 0, 3).expect("B should be picked");
326 assert_eq!(result.id, "B", "C has unmet dep; B should be selected");
327 }
328
329 #[test]
330 fn pick_gap_dependency_unblocked_when_dep_done() {
331 let gaps = vec![
332 g("B", "P1", "m", "done", None),
333 g("C", "P1", "s", "open", Some(vec!["B"])),
334 ];
335 let done = done_ids(&gaps); let result = pick_gap(&gaps, &done, &no_live(), 0, 3).expect("C should be picked");
337 assert_eq!(result.id, "C", "B is done; C's dep is met");
338 }
339
340 #[test]
341 fn pick_gap_priority_ordering() {
342 let gaps = vec![
344 g("LOW", "P2", "s", "open", None),
345 g("HIGH", "P1", "l", "open", None),
346 ];
347 let done = HashSet::new();
348 let result = pick_gap(&gaps, &done, &no_live(), 0, 3).expect("should pick");
349 assert_eq!(result.id, "HIGH", "P1 should beat P2");
350 }
351
352 #[test]
353 fn pick_gap_effort_ordering_within_same_priority() {
354 let gaps = vec![
356 g("BIG", "P1", "l", "open", None),
357 g("SMALL", "P1", "s", "open", None),
358 ];
359 let done = HashSet::new();
360 let result = pick_gap(&gaps, &done, &no_live(), 0, 3).expect("should pick");
361 assert_eq!(result.id, "SMALL", "s effort beats l within same priority");
362 }
363
364 #[test]
365 fn pick_gap_none_when_all_done() {
366 let gaps = vec![g("A", "P1", "s", "done", None)];
367 let done = done_ids(&gaps);
368 let result = pick_gap(&gaps, &done, &no_live(), 0, 3);
369 assert!(result.is_none(), "all done → nothing to pick");
370 }
371
372 #[test]
373 fn pick_gap_capacity_allows_when_below_cap() {
374 let gaps = vec![g("A", "P1", "s", "open", None)];
375 let done = HashSet::new();
376 let result = pick_gap(&gaps, &done, &no_live(), 2, 3);
378 assert!(
379 result.is_some(),
380 "active=2 < capacity=3 should allow dispatch"
381 );
382 assert_eq!(result.unwrap().id, "A");
383 }
384
385 #[test]
386 #[serial(dispatch_capacity)]
387 fn dispatch_capacity_default_is_3() {
388 std::env::remove_var("CHUMP_DISPATCH_CAPACITY");
389 assert_eq!(dispatch_capacity(), 3);
390 }
391
392 #[test]
393 #[serial(dispatch_capacity)]
394 fn dispatch_capacity_respects_env() {
395 std::env::set_var("CHUMP_DISPATCH_CAPACITY", "5");
396 let cap = dispatch_capacity();
397 std::env::remove_var("CHUMP_DISPATCH_CAPACITY");
398 assert_eq!(cap, 5);
399 }
400}