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            handle: None,
140            id: id.to_string(),
141            title: format!("Unit {}", id),
142            status: Status::Open,
143            priority: 2,
144            parent: None,
145            dependencies: vec![],
146            labels: vec![],
147            assignee: None,
148            updated_at: Utc::now(),
149            produces: vec![],
150            requires: vec![],
151            has_verify: true,
152            verify: None,
153            created_at: Utc::now(),
154            claimed_by: None,
155            attempts: 0,
156            paths: vec![],
157            kind: crate::unit::UnitType::Task,
158            feature: false,
159            has_decisions: false,
160        }
161    }
162
163    fn make_index(entries: Vec<IndexEntry>) -> Index {
164        Index { units: entries }
165    }
166
167    // -- WaitingOn: explicit deps --
168
169    #[test]
170    fn blocking_not_blocked_when_deps_closed() {
171        let mut dep = make_entry("1");
172        dep.status = Status::Closed;
173
174        let mut entry = make_entry("2");
175        entry.dependencies = vec!["1".into()];
176        entry.produces = vec!["Foo".into()];
177        entry.paths = vec!["src/foo.rs".into()];
178
179        let index = make_index(vec![dep, entry.clone()]);
180        assert_eq!(check_blocked(&entry, &index), None);
181    }
182
183    #[test]
184    fn blocking_waiting_on_open_dep() {
185        let dep = make_entry("1"); // open
186
187        let mut entry = make_entry("2");
188        entry.dependencies = vec!["1".into()];
189        entry.produces = vec!["Foo".into()];
190        entry.paths = vec!["src/foo.rs".into()];
191
192        let index = make_index(vec![dep, entry.clone()]);
193        assert_eq!(
194            check_blocked(&entry, &index),
195            Some(BlockReason::WaitingOn(vec!["1".into()]))
196        );
197    }
198
199    #[test]
200    fn blocking_waiting_on_missing_dep() {
201        let mut entry = make_entry("2");
202        entry.dependencies = vec!["999".into()]; // doesn't exist
203        entry.produces = vec!["Foo".into()];
204        entry.paths = vec!["src/foo.rs".into()];
205
206        let index = make_index(vec![entry.clone()]);
207        assert_eq!(
208            check_blocked(&entry, &index),
209            Some(BlockReason::WaitingOn(vec!["999".into()]))
210        );
211    }
212
213    #[test]
214    fn blocking_waiting_on_multiple_deps() {
215        let dep_a = make_entry("1"); // open
216        let dep_b = make_entry("3"); // open
217
218        let mut entry = make_entry("2");
219        entry.dependencies = vec!["1".into(), "3".into()];
220        entry.produces = vec!["Foo".into()];
221        entry.paths = vec!["src/foo.rs".into()];
222
223        let index = make_index(vec![dep_a, entry.clone(), dep_b]);
224        assert_eq!(
225            check_blocked(&entry, &index),
226            Some(BlockReason::WaitingOn(vec!["1".into(), "3".into()]))
227        );
228    }
229
230    // -- WaitingOn: requires/produces --
231
232    #[test]
233    fn blocking_waiting_on_sibling_producer() {
234        let mut producer = make_entry("5.1");
235        producer.parent = Some("5".into());
236        producer.produces = vec!["UserType".into()];
237
238        let mut consumer = make_entry("5.2");
239        consumer.parent = Some("5".into());
240        consumer.requires = vec!["UserType".into()];
241        consumer.produces = vec!["UserAPI".into()];
242        consumer.paths = vec!["src/api.rs".into()];
243
244        let index = make_index(vec![producer, consumer.clone()]);
245        assert_eq!(
246            check_blocked(&consumer, &index),
247            Some(BlockReason::WaitingOn(vec!["5.1".into()]))
248        );
249    }
250
251    #[test]
252    fn blocking_not_blocked_when_producer_closed() {
253        let mut producer = make_entry("5.1");
254        producer.parent = Some("5".into());
255        producer.produces = vec!["UserType".into()];
256        producer.status = Status::Closed;
257
258        let mut consumer = make_entry("5.2");
259        consumer.parent = Some("5".into());
260        consumer.requires = vec!["UserType".into()];
261        consumer.produces = vec!["UserAPI".into()];
262        consumer.paths = vec!["src/api.rs".into()];
263
264        let index = make_index(vec![producer, consumer.clone()]);
265        assert_eq!(check_blocked(&consumer, &index), None);
266    }
267
268    #[test]
269    fn blocking_no_duplicate_when_dep_and_requires_overlap() {
270        let mut producer = make_entry("5.1");
271        producer.parent = Some("5".into());
272        producer.produces = vec!["UserType".into()];
273
274        let mut consumer = make_entry("5.2");
275        consumer.parent = Some("5".into());
276        consumer.dependencies = vec!["5.1".into()]; // explicit dep
277        consumer.requires = vec!["UserType".into()]; // also requires from same unit
278        consumer.produces = vec!["UserAPI".into()];
279        consumer.paths = vec!["src/api.rs".into()];
280
281        let index = make_index(vec![producer, consumer.clone()]);
282        if let Some(BlockReason::WaitingOn(ids)) = check_blocked(&consumer, &index) {
283            // 5.1 should appear only once even though it's both an explicit dep and a producer
284            assert_eq!(ids, vec!["5.1".to_string()]);
285        } else {
286            panic!("Expected WaitingOn");
287        }
288    }
289
290    // -- Scope warnings (non-blocking) --
291
292    #[test]
293    fn warning_oversized_too_many_produces() {
294        let mut entry = make_entry("1");
295        entry.produces = vec!["A".into(), "B".into(), "C".into(), "D".into()]; // 4 > MAX_PRODUCES
296        entry.paths = vec!["src/a.rs".into()];
297
298        // Not blocked — just a warning
299        let index = make_index(vec![entry.clone()]);
300        assert_eq!(check_blocked(&entry, &index), None);
301        assert_eq!(check_scope_warning(&entry), Some(ScopeWarning::Oversized));
302    }
303
304    #[test]
305    fn warning_oversized_too_many_paths() {
306        let mut entry = make_entry("1");
307        entry.produces = vec!["A".into()];
308        entry.paths = vec![
309            "a.rs".into(),
310            "b.rs".into(),
311            "c.rs".into(),
312            "d.rs".into(),
313            "e.rs".into(),
314            "f.rs".into(),
315        ]; // 6 > MAX_PATHS
316
317        let index = make_index(vec![entry.clone()]);
318        assert_eq!(check_blocked(&entry, &index), None);
319        assert_eq!(check_scope_warning(&entry), Some(ScopeWarning::Oversized));
320    }
321
322    #[test]
323    fn warning_not_oversized_at_threshold() {
324        let mut entry = make_entry("1");
325        entry.produces = vec!["A".into(), "B".into(), "C".into()]; // exactly MAX_PRODUCES
326        entry.paths = vec![
327            "a.rs".into(),
328            "b.rs".into(),
329            "c.rs".into(),
330            "d.rs".into(),
331            "e.rs".into(),
332        ]; // exactly MAX_PATHS
333
334        assert_eq!(check_scope_warning(&entry), None);
335    }
336
337    // -- Unscoped is NOT blocking --
338
339    #[test]
340    fn unscoped_unit_is_not_blocked() {
341        let entry = make_entry("1"); // produces=[], paths=[]
342
343        let index = make_index(vec![entry.clone()]);
344        assert_eq!(check_blocked(&entry, &index), None);
345    }
346
347    #[test]
348    fn not_blocked_with_produces_only() {
349        let mut entry = make_entry("1");
350        entry.produces = vec!["SomeType".into()];
351
352        let index = make_index(vec![entry.clone()]);
353        assert_eq!(check_blocked(&entry, &index), None);
354    }
355
356    #[test]
357    fn not_blocked_with_paths_only() {
358        let mut entry = make_entry("1");
359        entry.paths = vec!["src/main.rs".into()];
360
361        let index = make_index(vec![entry.clone()]);
362        assert_eq!(check_blocked(&entry, &index), None);
363    }
364
365    // -- Display --
366
367    #[test]
368    fn blocking_display_waiting_on() {
369        let reason = BlockReason::WaitingOn(vec!["3.1".into(), "3.2".into()]);
370        assert_eq!(format!("{}", reason), "waiting on 3.1, 3.2");
371    }
372
373    #[test]
374    fn warning_display_oversized() {
375        assert_eq!(format!("{}", ScopeWarning::Oversized), "oversized");
376    }
377
378    // -- Priority: deps still checked --
379
380    #[test]
381    fn blocking_deps_still_block_oversized_units() {
382        let dep = make_entry("1"); // open
383
384        let mut entry = make_entry("2");
385        entry.dependencies = vec!["1".into()];
386        entry.produces = vec!["A".into(), "B".into(), "C".into(), "D".into()]; // oversized
387        entry.paths = vec!["a.rs".into()];
388
389        let index = make_index(vec![dep, entry.clone()]);
390        assert!(matches!(
391            check_blocked(&entry, &index),
392            Some(BlockReason::WaitingOn(_))
393        ));
394    }
395
396    #[test]
397    fn blocking_deps_still_block_unscoped_units() {
398        let dep = make_entry("1"); // open
399
400        let mut entry = make_entry("2");
401        entry.dependencies = vec!["1".into()];
402        // produces=[], paths=[] → unscoped but deps block first
403
404        let index = make_index(vec![dep, entry.clone()]);
405        assert!(matches!(
406            check_blocked(&entry, &index),
407            Some(BlockReason::WaitingOn(_))
408        ));
409    }
410}