Skip to main content

bn/
blocking.rs

1use std::fmt;
2
3use crate::bean::Status;
4use crate::index::{Index, IndexEntry};
5
6// ---------------------------------------------------------------------------
7// Scope thresholds
8// ---------------------------------------------------------------------------
9
10/// Maximum number of `produces` artifacts before a bean is considered oversized.
11pub const MAX_PRODUCES: usize = 3;
12
13/// Maximum number of `paths` before a bean is considered oversized.
14pub const MAX_PATHS: usize = 5;
15
16// ---------------------------------------------------------------------------
17// BlockReason
18// ---------------------------------------------------------------------------
19
20/// Why a bean cannot be dispatched right now.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum BlockReason {
23    /// One or more dependency beans are not yet closed.
24    WaitingOn(Vec<String>),
25}
26
27/// Soft scope warnings — displayed but don't block dispatch.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ScopeWarning {
30    /// Scope is large: `produces > MAX_PRODUCES` or `paths > MAX_PATHS`.
31    Oversized,
32}
33
34impl fmt::Display for BlockReason {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            BlockReason::WaitingOn(ids) => {
38                write!(f, "waiting on {}", ids.join(", "))
39            }
40        }
41    }
42}
43
44impl fmt::Display for ScopeWarning {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            ScopeWarning::Oversized => write!(f, "oversized"),
48        }
49    }
50}
51
52// ---------------------------------------------------------------------------
53// Unified blocking check
54// ---------------------------------------------------------------------------
55
56/// Check whether `entry` is blocked, returning the reason if so.
57///
58/// Checks in priority order:
59/// 1. **Explicit dependencies** — any dep that isn't closed (or doesn't exist).
60/// 2. **Requires/produces** — sibling beans that produce a required artifact
61///    but aren't closed yet.
62pub fn check_blocked(entry: &IndexEntry, index: &Index) -> Option<BlockReason> {
63    let mut waiting_on = Vec::new();
64
65    // Explicit dependencies
66    for dep_id in &entry.dependencies {
67        match index.beans.iter().find(|e| e.id == *dep_id) {
68            Some(dep) if dep.status == Status::Closed => {}
69            _ => waiting_on.push(dep_id.clone()),
70        }
71    }
72
73    // Smart dependencies: requires → sibling produces
74    for required in &entry.requires {
75        if let Some(producer) = index
76            .beans
77            .iter()
78            .find(|e| e.id != entry.id && e.parent == entry.parent && e.produces.contains(required))
79        {
80            if producer.status != Status::Closed && !waiting_on.contains(&producer.id) {
81                waiting_on.push(producer.id.clone());
82            }
83        }
84    }
85
86    if !waiting_on.is_empty() {
87        return Some(BlockReason::WaitingOn(waiting_on));
88    }
89
90    None
91}
92
93/// Check for scope warnings (non-blocking).
94///
95/// Returns a warning if scope is large (`produces > MAX_PRODUCES` or `paths > MAX_PATHS`).
96/// Beans with no scope (no produces, no paths) are fine — not every bean needs explicit paths.
97pub fn check_scope_warning(entry: &IndexEntry) -> Option<ScopeWarning> {
98    if entry.produces.len() > MAX_PRODUCES || entry.paths.len() > MAX_PATHS {
99        return Some(ScopeWarning::Oversized);
100    }
101    None
102}
103
104// ---------------------------------------------------------------------------
105// Tests
106// ---------------------------------------------------------------------------
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use chrono::Utc;
112
113    fn make_entry(id: &str) -> IndexEntry {
114        IndexEntry {
115            id: id.to_string(),
116            title: format!("Bean {}", id),
117            status: Status::Open,
118            priority: 2,
119            parent: None,
120            dependencies: vec![],
121            labels: vec![],
122            assignee: None,
123            updated_at: Utc::now(),
124            produces: vec![],
125            requires: vec![],
126            has_verify: true,
127            claimed_by: None,
128            attempts: 0,
129            paths: vec![],
130        }
131    }
132
133    fn make_index(entries: Vec<IndexEntry>) -> Index {
134        Index { beans: entries }
135    }
136
137    // -- WaitingOn: explicit deps --
138
139    #[test]
140    fn blocking_not_blocked_when_deps_closed() {
141        let mut dep = make_entry("1");
142        dep.status = Status::Closed;
143
144        let mut entry = make_entry("2");
145        entry.dependencies = vec!["1".into()];
146        entry.produces = vec!["Foo".into()];
147        entry.paths = vec!["src/foo.rs".into()];
148
149        let index = make_index(vec![dep, entry.clone()]);
150        assert_eq!(check_blocked(&entry, &index), None);
151    }
152
153    #[test]
154    fn blocking_waiting_on_open_dep() {
155        let dep = make_entry("1"); // open
156
157        let mut entry = make_entry("2");
158        entry.dependencies = vec!["1".into()];
159        entry.produces = vec!["Foo".into()];
160        entry.paths = vec!["src/foo.rs".into()];
161
162        let index = make_index(vec![dep, entry.clone()]);
163        assert_eq!(
164            check_blocked(&entry, &index),
165            Some(BlockReason::WaitingOn(vec!["1".into()]))
166        );
167    }
168
169    #[test]
170    fn blocking_waiting_on_missing_dep() {
171        let mut entry = make_entry("2");
172        entry.dependencies = vec!["999".into()]; // doesn't exist
173        entry.produces = vec!["Foo".into()];
174        entry.paths = vec!["src/foo.rs".into()];
175
176        let index = make_index(vec![entry.clone()]);
177        assert_eq!(
178            check_blocked(&entry, &index),
179            Some(BlockReason::WaitingOn(vec!["999".into()]))
180        );
181    }
182
183    #[test]
184    fn blocking_waiting_on_multiple_deps() {
185        let dep_a = make_entry("1"); // open
186        let dep_b = make_entry("3"); // open
187
188        let mut entry = make_entry("2");
189        entry.dependencies = vec!["1".into(), "3".into()];
190        entry.produces = vec!["Foo".into()];
191        entry.paths = vec!["src/foo.rs".into()];
192
193        let index = make_index(vec![dep_a, entry.clone(), dep_b]);
194        assert_eq!(
195            check_blocked(&entry, &index),
196            Some(BlockReason::WaitingOn(vec!["1".into(), "3".into()]))
197        );
198    }
199
200    // -- WaitingOn: requires/produces --
201
202    #[test]
203    fn blocking_waiting_on_sibling_producer() {
204        let mut producer = make_entry("5.1");
205        producer.parent = Some("5".into());
206        producer.produces = vec!["UserType".into()];
207
208        let mut consumer = make_entry("5.2");
209        consumer.parent = Some("5".into());
210        consumer.requires = vec!["UserType".into()];
211        consumer.produces = vec!["UserAPI".into()];
212        consumer.paths = vec!["src/api.rs".into()];
213
214        let index = make_index(vec![producer, consumer.clone()]);
215        assert_eq!(
216            check_blocked(&consumer, &index),
217            Some(BlockReason::WaitingOn(vec!["5.1".into()]))
218        );
219    }
220
221    #[test]
222    fn blocking_not_blocked_when_producer_closed() {
223        let mut producer = make_entry("5.1");
224        producer.parent = Some("5".into());
225        producer.produces = vec!["UserType".into()];
226        producer.status = Status::Closed;
227
228        let mut consumer = make_entry("5.2");
229        consumer.parent = Some("5".into());
230        consumer.requires = vec!["UserType".into()];
231        consumer.produces = vec!["UserAPI".into()];
232        consumer.paths = vec!["src/api.rs".into()];
233
234        let index = make_index(vec![producer, consumer.clone()]);
235        assert_eq!(check_blocked(&consumer, &index), None);
236    }
237
238    #[test]
239    fn blocking_no_duplicate_when_dep_and_requires_overlap() {
240        let mut producer = make_entry("5.1");
241        producer.parent = Some("5".into());
242        producer.produces = vec!["UserType".into()];
243
244        let mut consumer = make_entry("5.2");
245        consumer.parent = Some("5".into());
246        consumer.dependencies = vec!["5.1".into()]; // explicit dep
247        consumer.requires = vec!["UserType".into()]; // also requires from same bean
248        consumer.produces = vec!["UserAPI".into()];
249        consumer.paths = vec!["src/api.rs".into()];
250
251        let index = make_index(vec![producer, consumer.clone()]);
252        if let Some(BlockReason::WaitingOn(ids)) = check_blocked(&consumer, &index) {
253            // 5.1 should appear only once even though it's both an explicit dep and a producer
254            assert_eq!(ids, vec!["5.1".to_string()]);
255        } else {
256            panic!("Expected WaitingOn");
257        }
258    }
259
260    // -- Scope warnings (non-blocking) --
261
262    #[test]
263    fn warning_oversized_too_many_produces() {
264        let mut entry = make_entry("1");
265        entry.produces = vec!["A".into(), "B".into(), "C".into(), "D".into()]; // 4 > MAX_PRODUCES
266        entry.paths = vec!["src/a.rs".into()];
267
268        // Not blocked — just a warning
269        let index = make_index(vec![entry.clone()]);
270        assert_eq!(check_blocked(&entry, &index), None);
271        assert_eq!(check_scope_warning(&entry), Some(ScopeWarning::Oversized));
272    }
273
274    #[test]
275    fn warning_oversized_too_many_paths() {
276        let mut entry = make_entry("1");
277        entry.produces = vec!["A".into()];
278        entry.paths = vec![
279            "a.rs".into(),
280            "b.rs".into(),
281            "c.rs".into(),
282            "d.rs".into(),
283            "e.rs".into(),
284            "f.rs".into(),
285        ]; // 6 > MAX_PATHS
286
287        let index = make_index(vec![entry.clone()]);
288        assert_eq!(check_blocked(&entry, &index), None);
289        assert_eq!(check_scope_warning(&entry), Some(ScopeWarning::Oversized));
290    }
291
292    #[test]
293    fn warning_not_oversized_at_threshold() {
294        let mut entry = make_entry("1");
295        entry.produces = vec!["A".into(), "B".into(), "C".into()]; // exactly MAX_PRODUCES
296        entry.paths = vec![
297            "a.rs".into(),
298            "b.rs".into(),
299            "c.rs".into(),
300            "d.rs".into(),
301            "e.rs".into(),
302        ]; // exactly MAX_PATHS
303
304        assert_eq!(check_scope_warning(&entry), None);
305    }
306
307    // -- Unscoped is NOT blocking --
308
309    #[test]
310    fn unscoped_bean_is_not_blocked() {
311        let entry = make_entry("1"); // produces=[], paths=[]
312
313        let index = make_index(vec![entry.clone()]);
314        assert_eq!(check_blocked(&entry, &index), None);
315    }
316
317    #[test]
318    fn not_blocked_with_produces_only() {
319        let mut entry = make_entry("1");
320        entry.produces = vec!["SomeType".into()];
321
322        let index = make_index(vec![entry.clone()]);
323        assert_eq!(check_blocked(&entry, &index), None);
324    }
325
326    #[test]
327    fn not_blocked_with_paths_only() {
328        let mut entry = make_entry("1");
329        entry.paths = vec!["src/main.rs".into()];
330
331        let index = make_index(vec![entry.clone()]);
332        assert_eq!(check_blocked(&entry, &index), None);
333    }
334
335    // -- Display --
336
337    #[test]
338    fn blocking_display_waiting_on() {
339        let reason = BlockReason::WaitingOn(vec!["3.1".into(), "3.2".into()]);
340        assert_eq!(format!("{}", reason), "waiting on 3.1, 3.2");
341    }
342
343    #[test]
344    fn warning_display_oversized() {
345        assert_eq!(format!("{}", ScopeWarning::Oversized), "oversized");
346    }
347
348    // -- Priority: deps still checked --
349
350    #[test]
351    fn blocking_deps_still_block_oversized_beans() {
352        let dep = make_entry("1"); // open
353
354        let mut entry = make_entry("2");
355        entry.dependencies = vec!["1".into()];
356        entry.produces = vec!["A".into(), "B".into(), "C".into(), "D".into()]; // oversized
357        entry.paths = vec!["a.rs".into()];
358
359        let index = make_index(vec![dep, entry.clone()]);
360        assert!(matches!(
361            check_blocked(&entry, &index),
362            Some(BlockReason::WaitingOn(_))
363        ));
364    }
365
366    #[test]
367    fn blocking_deps_still_block_unscoped_beans() {
368        let dep = make_entry("1"); // open
369
370        let mut entry = make_entry("2");
371        entry.dependencies = vec!["1".into()];
372        // produces=[], paths=[] → unscoped but deps block first
373
374        let index = make_index(vec![dep, entry.clone()]);
375        assert!(matches!(
376            check_blocked(&entry, &index),
377            Some(BlockReason::WaitingOn(_))
378        ));
379    }
380}