Skip to main content

mana_core/
blocking.rs

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