Skip to main content

bn/commands/run/
plan.rs

1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::bean::Status;
6use crate::blocking::{check_blocked, check_scope_warning, BlockReason, ScopeWarning};
7use crate::config::Config;
8use crate::index::{ArchiveIndex, Index, IndexEntry};
9use crate::stream::{self, StreamEvent};
10
11use super::ready_queue::all_deps_closed;
12use super::wave::{compute_waves, Wave};
13use super::BeanAction;
14
15/// A bean ready for dispatch.
16#[derive(Debug, Clone)]
17pub struct SizedBean {
18    pub id: String,
19    pub title: String,
20    pub action: BeanAction,
21    pub priority: u8,
22    pub dependencies: Vec<String>,
23    pub parent: Option<String>,
24    pub produces: Vec<String>,
25    pub requires: Vec<String>,
26    pub paths: Vec<String>,
27}
28
29/// A bean that was excluded from dispatch due to scope issues.
30#[derive(Debug, Clone)]
31pub struct BlockedBean {
32    pub id: String,
33    pub title: String,
34    pub reason: BlockReason,
35}
36
37/// Result from planning dispatch.
38pub struct DispatchPlan {
39    pub waves: Vec<Wave>,
40    pub skipped: Vec<BlockedBean>,
41    /// Scope warnings for beans that will dispatch but have large scope.
42    pub warnings: Vec<(String, ScopeWarning)>,
43    /// Flat list of all beans to dispatch (for ready-queue mode).
44    pub all_beans: Vec<SizedBean>,
45    /// The index snapshot used for planning.
46    pub index: Index,
47}
48
49/// Plan dispatch: get ready beans, filter by scope, compute waves.
50pub(super) fn plan_dispatch(
51    beans_dir: &Path,
52    _config: &Config,
53    filter_id: Option<&str>,
54    _auto_plan: bool,
55    simulate: bool,
56) -> Result<DispatchPlan> {
57    let index = Index::load_or_rebuild(beans_dir)?;
58    let archive = ArchiveIndex::load_or_rebuild(beans_dir)
59        .unwrap_or_else(|_| ArchiveIndex { beans: Vec::new() });
60
61    // Get candidate beans: open with verify.
62    // In simulate mode (dry-run), include all open beans with verify — even those
63    // whose deps aren't met yet — so compute_waves can show the full execution plan.
64    // In normal mode, only include beans whose deps are already closed.
65    let mut candidate_entries: Vec<&IndexEntry> = index
66        .beans
67        .iter()
68        .filter(|e| {
69            e.has_verify
70                && e.status == Status::Open
71                && (simulate || all_deps_closed(e, &index, &archive))
72        })
73        .collect();
74
75    // Filter by ID if provided
76    if let Some(filter_id) = filter_id {
77        // Check if it's a parent — if so, get its ready children
78        let is_parent = index
79            .beans
80            .iter()
81            .any(|e| e.parent.as_deref() == Some(filter_id));
82        if is_parent {
83            candidate_entries.retain(|e| e.parent.as_deref() == Some(filter_id));
84        } else {
85            candidate_entries.retain(|e| e.id == filter_id);
86        }
87    }
88
89    // Partition into dispatchable vs blocked.
90    // In simulate mode, skip blocking checks — we want to show the full plan.
91    // In normal mode, dependency blocking is already handled by all_deps_closed above,
92    // but check_blocked catches edge cases (e.g., missing deps not in index).
93    // Scope warnings (oversized) are non-blocking — beans dispatch with a warning.
94    let mut dispatch_beans: Vec<SizedBean> = Vec::new();
95    let mut skipped: Vec<BlockedBean> = Vec::new();
96    let mut warnings: Vec<(String, ScopeWarning)> = Vec::new();
97
98    for entry in &candidate_entries {
99        if !simulate {
100            if let Some(reason) = check_blocked(entry, &index) {
101                skipped.push(BlockedBean {
102                    id: entry.id.clone(),
103                    title: entry.title.clone(),
104                    reason,
105                });
106                continue;
107            }
108        }
109        // Check for scope warnings (non-blocking)
110        if let Some(warning) = check_scope_warning(entry) {
111            warnings.push((entry.id.clone(), warning));
112        }
113        dispatch_beans.push(SizedBean {
114            id: entry.id.clone(),
115            title: entry.title.clone(),
116            action: BeanAction::Implement,
117            priority: entry.priority,
118            dependencies: entry.dependencies.clone(),
119            parent: entry.parent.clone(),
120            produces: entry.produces.clone(),
121            requires: entry.requires.clone(),
122            paths: entry.paths.clone(),
123        });
124    }
125
126    let waves = compute_waves(&dispatch_beans, &index);
127
128    Ok(DispatchPlan {
129        waves,
130        skipped,
131        warnings,
132        all_beans: dispatch_beans,
133        index,
134    })
135}
136
137/// Print the dispatch plan without executing.
138pub(super) fn print_plan(plan: &DispatchPlan) {
139    for (wave_idx, wave) in plan.waves.iter().enumerate() {
140        println!("Wave {}: {} bean(s)", wave_idx + 1, wave.beans.len());
141        for sb in &wave.beans {
142            let warning = plan
143                .warnings
144                .iter()
145                .find(|(id, _)| id == &sb.id)
146                .map(|(_, w)| format!("  ⚠ {}", w))
147                .unwrap_or_default();
148            println!("  {}  {}  {}{}", sb.id, sb.title, sb.action, warning);
149        }
150    }
151
152    if !plan.skipped.is_empty() {
153        println!();
154        println!("Blocked ({}):", plan.skipped.len());
155        for bb in &plan.skipped {
156            println!("  ⚠ {}  {}  ({})", bb.id, bb.title, bb.reason);
157        }
158    }
159}
160
161/// Print the dispatch plan as JSON stream events.
162pub(super) fn print_plan_json(plan: &DispatchPlan, parent_id: Option<&str>) {
163    let parent_id = parent_id.unwrap_or("all").to_string();
164    let rounds: Vec<stream::RoundPlan> = plan
165        .waves
166        .iter()
167        .enumerate()
168        .map(|(i, wave)| stream::RoundPlan {
169            round: i + 1,
170            beans: wave
171                .beans
172                .iter()
173                .map(|b| stream::BeanInfo {
174                    id: b.id.clone(),
175                    title: b.title.clone(),
176                    round: i + 1,
177                })
178                .collect(),
179        })
180        .collect();
181
182    stream::emit(&StreamEvent::DryRun { parent_id, rounds });
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use crate::config::Config;
189    use std::fs;
190    use std::path::Path;
191    use tempfile::TempDir;
192
193    fn make_beans_dir() -> (TempDir, std::path::PathBuf) {
194        let dir = TempDir::new().unwrap();
195        let beans_dir = dir.path().join(".beans");
196        fs::create_dir(&beans_dir).unwrap();
197        (dir, beans_dir)
198    }
199
200    fn write_config(beans_dir: &Path, run: Option<&str>) {
201        let run_line = match run {
202            Some(r) => format!("run: \"{}\"\n", r),
203            None => String::new(),
204        };
205        fs::write(
206            beans_dir.join("config.yaml"),
207            format!("project: test\nnext_id: 1\n{}", run_line),
208        )
209        .unwrap();
210    }
211
212    #[test]
213    fn plan_dispatch_no_ready_beans() {
214        let (_dir, beans_dir) = make_beans_dir();
215        write_config(&beans_dir, Some("echo {id}"));
216
217        let config = Config::load_with_extends(&beans_dir).unwrap();
218        let plan = plan_dispatch(&beans_dir, &config, None, false, false).unwrap();
219
220        assert!(plan.waves.is_empty());
221        assert!(plan.skipped.is_empty());
222    }
223
224    #[test]
225    fn plan_dispatch_returns_ready_beans() {
226        let (_dir, beans_dir) = make_beans_dir();
227        write_config(&beans_dir, Some("echo {id}"));
228
229        let mut bean = crate::bean::Bean::new("1", "Task one");
230        bean.verify = Some("echo ok".to_string());
231        bean.produces = vec!["X".to_string()];
232        bean.paths = vec!["src/x.rs".to_string()];
233        bean.to_file(beans_dir.join("1-task-one.md")).unwrap();
234
235        let mut bean2 = crate::bean::Bean::new("2", "Task two");
236        bean2.verify = Some("echo ok".to_string());
237        bean2.produces = vec!["Y".to_string()];
238        bean2.paths = vec!["src/y.rs".to_string()];
239        bean2.to_file(beans_dir.join("2-task-two.md")).unwrap();
240
241        let config = Config::load_with_extends(&beans_dir).unwrap();
242        let plan = plan_dispatch(&beans_dir, &config, None, false, false).unwrap();
243
244        assert_eq!(plan.waves.len(), 1);
245        assert_eq!(plan.waves[0].beans.len(), 2);
246    }
247
248    #[test]
249    fn plan_dispatch_filters_by_id() {
250        let (_dir, beans_dir) = make_beans_dir();
251        write_config(&beans_dir, Some("echo {id}"));
252
253        let mut bean = crate::bean::Bean::new("1", "Task one");
254        bean.verify = Some("echo ok".to_string());
255        bean.produces = vec!["X".to_string()];
256        bean.paths = vec!["src/x.rs".to_string()];
257        bean.to_file(beans_dir.join("1-task-one.md")).unwrap();
258
259        let mut bean2 = crate::bean::Bean::new("2", "Task two");
260        bean2.verify = Some("echo ok".to_string());
261        bean2.produces = vec!["Y".to_string()];
262        bean2.paths = vec!["src/y.rs".to_string()];
263        bean2.to_file(beans_dir.join("2-task-two.md")).unwrap();
264
265        let config = Config::load_with_extends(&beans_dir).unwrap();
266        let plan = plan_dispatch(&beans_dir, &config, Some("1"), false, false).unwrap();
267
268        assert_eq!(plan.waves.len(), 1);
269        assert_eq!(plan.waves[0].beans.len(), 1);
270        assert_eq!(plan.waves[0].beans[0].id, "1");
271    }
272
273    #[test]
274    fn plan_dispatch_parent_id_gets_children() {
275        let (_dir, beans_dir) = make_beans_dir();
276        write_config(&beans_dir, Some("echo {id}"));
277
278        let parent = crate::bean::Bean::new("1", "Parent");
279        parent.to_file(beans_dir.join("1-parent.md")).unwrap();
280
281        let mut child1 = crate::bean::Bean::new("1.1", "Child one");
282        child1.parent = Some("1".to_string());
283        child1.verify = Some("echo ok".to_string());
284        child1.produces = vec!["A".to_string()];
285        child1.paths = vec!["src/a.rs".to_string()];
286        child1.to_file(beans_dir.join("1.1-child-one.md")).unwrap();
287
288        let mut child2 = crate::bean::Bean::new("1.2", "Child two");
289        child2.parent = Some("1".to_string());
290        child2.verify = Some("echo ok".to_string());
291        child2.produces = vec!["B".to_string()];
292        child2.paths = vec!["src/b.rs".to_string()];
293        child2.to_file(beans_dir.join("1.2-child-two.md")).unwrap();
294
295        let config = Config::load_with_extends(&beans_dir).unwrap();
296        let plan = plan_dispatch(&beans_dir, &config, Some("1"), false, false).unwrap();
297
298        assert_eq!(plan.waves.len(), 1);
299        assert_eq!(plan.waves[0].beans.len(), 2);
300    }
301
302    #[test]
303    fn oversized_bean_dispatched_with_warning() {
304        let (_dir, beans_dir) = make_beans_dir();
305        write_config(&beans_dir, Some("echo {id}"));
306
307        let mut bean = crate::bean::Bean::new("1", "Oversized bean");
308        bean.verify = Some("echo ok".to_string());
309        // 4 produces exceeds MAX_PRODUCES (3) — warning but not blocked
310        bean.produces = vec![
311            "A".to_string(),
312            "B".to_string(),
313            "C".to_string(),
314            "D".to_string(),
315        ];
316        bean.paths = vec!["src/a.rs".to_string()];
317        bean.to_file(beans_dir.join("1-oversized.md")).unwrap();
318
319        let config = Config::load_with_extends(&beans_dir).unwrap();
320        let plan = plan_dispatch(&beans_dir, &config, None, false, false).unwrap();
321
322        assert_eq!(plan.waves.len(), 1);
323        assert_eq!(plan.waves[0].beans.len(), 1);
324        assert!(plan.skipped.is_empty());
325        assert_eq!(plan.warnings.len(), 1);
326        assert_eq!(plan.warnings[0].0, "1");
327    }
328
329    #[test]
330    fn unscoped_bean_dispatched_normally() {
331        let (_dir, beans_dir) = make_beans_dir();
332        write_config(&beans_dir, Some("echo {id}"));
333
334        let mut bean = crate::bean::Bean::new("1", "Unscoped bean");
335        bean.verify = Some("echo ok".to_string());
336        // No produces, no paths — dispatched normally
337        bean.to_file(beans_dir.join("1-unscoped.md")).unwrap();
338
339        let config = Config::load_with_extends(&beans_dir).unwrap();
340        let plan = plan_dispatch(&beans_dir, &config, None, false, false).unwrap();
341
342        assert_eq!(plan.waves.len(), 1);
343        assert_eq!(plan.waves[0].beans.len(), 1);
344        assert!(plan.skipped.is_empty());
345        assert!(plan.warnings.is_empty());
346    }
347
348    #[test]
349    fn well_scoped_bean_dispatched() {
350        let (_dir, beans_dir) = make_beans_dir();
351        write_config(&beans_dir, Some("echo {id}"));
352
353        let mut bean = crate::bean::Bean::new("1", "Well scoped");
354        bean.verify = Some("echo ok".to_string());
355        bean.produces = vec!["Widget".to_string()];
356        bean.paths = vec!["src/widget.rs".to_string()];
357        bean.to_file(beans_dir.join("1-well-scoped.md")).unwrap();
358
359        let config = Config::load_with_extends(&beans_dir).unwrap();
360        let plan = plan_dispatch(&beans_dir, &config, None, false, false).unwrap();
361
362        assert_eq!(plan.waves.len(), 1);
363        assert_eq!(plan.waves[0].beans.len(), 1);
364        assert!(plan.skipped.is_empty());
365    }
366
367    #[test]
368    fn dry_run_simulate_shows_all_waves() {
369        let (_dir, beans_dir) = make_beans_dir();
370        write_config(&beans_dir, Some("echo {id}"));
371
372        // Create a chain: 1.1 → 1.2 → 1.3 (parent=1)
373        let parent = crate::bean::Bean::new("1", "Parent");
374        parent.to_file(beans_dir.join("1-parent.md")).unwrap();
375
376        let mut a = crate::bean::Bean::new("1.1", "Step A");
377        a.parent = Some("1".to_string());
378        a.verify = Some("echo ok".to_string());
379        a.produces = vec!["A".to_string()];
380        a.paths = vec!["src/a.rs".to_string()];
381        a.to_file(beans_dir.join("1.1-step-a.md")).unwrap();
382
383        let mut b = crate::bean::Bean::new("1.2", "Step B");
384        b.parent = Some("1".to_string());
385        b.verify = Some("echo ok".to_string());
386        b.dependencies = vec!["1.1".to_string()];
387        b.produces = vec!["B".to_string()];
388        b.paths = vec!["src/b.rs".to_string()];
389        b.to_file(beans_dir.join("1.2-step-b.md")).unwrap();
390
391        let mut c = crate::bean::Bean::new("1.3", "Step C");
392        c.parent = Some("1".to_string());
393        c.verify = Some("echo ok".to_string());
394        c.dependencies = vec!["1.2".to_string()];
395        c.produces = vec!["C".to_string()];
396        c.paths = vec!["src/c.rs".to_string()];
397        c.to_file(beans_dir.join("1.3-step-c.md")).unwrap();
398
399        // Without simulate: only wave 1 (1.1) is ready
400        let config = Config::load_with_extends(&beans_dir).unwrap();
401        let plan = plan_dispatch(&beans_dir, &config, Some("1"), false, false).unwrap();
402        assert_eq!(plan.waves.len(), 1);
403        assert_eq!(plan.waves[0].beans.len(), 1);
404        assert_eq!(plan.waves[0].beans[0].id, "1.1");
405
406        // With simulate: all 3 waves shown
407        let plan = plan_dispatch(&beans_dir, &config, Some("1"), false, true).unwrap();
408        assert_eq!(plan.waves.len(), 3);
409        assert_eq!(plan.waves[0].beans[0].id, "1.1");
410        assert_eq!(plan.waves[1].beans[0].id, "1.2");
411        assert_eq!(plan.waves[2].beans[0].id, "1.3");
412    }
413
414    #[test]
415    fn dry_run_simulate_respects_produces_requires() {
416        let (_dir, beans_dir) = make_beans_dir();
417        write_config(&beans_dir, Some("echo {id}"));
418
419        let parent = crate::bean::Bean::new("1", "Parent");
420        parent.to_file(beans_dir.join("1-parent.md")).unwrap();
421
422        let mut a = crate::bean::Bean::new("1.1", "Types");
423        a.parent = Some("1".to_string());
424        a.verify = Some("echo ok".to_string());
425        a.produces = vec!["types".to_string()];
426        a.paths = vec!["src/types.rs".to_string()];
427        a.to_file(beans_dir.join("1.1-types.md")).unwrap();
428
429        let mut b = crate::bean::Bean::new("1.2", "Impl");
430        b.parent = Some("1".to_string());
431        b.verify = Some("echo ok".to_string());
432        b.requires = vec!["types".to_string()];
433        b.produces = vec!["impl".to_string()];
434        b.paths = vec!["src/impl.rs".to_string()];
435        b.to_file(beans_dir.join("1.2-impl.md")).unwrap();
436
437        // Without simulate: only 1.1 is ready (1.2 blocked on requires)
438        let config = Config::load_with_extends(&beans_dir).unwrap();
439        let plan = plan_dispatch(&beans_dir, &config, Some("1"), false, false).unwrap();
440        assert_eq!(plan.waves.len(), 1);
441        assert_eq!(plan.waves[0].beans[0].id, "1.1");
442
443        // With simulate: both shown in correct wave order
444        let plan = plan_dispatch(&beans_dir, &config, Some("1"), false, true).unwrap();
445        assert_eq!(plan.waves.len(), 2);
446        assert_eq!(plan.waves[0].beans[0].id, "1.1");
447        assert_eq!(plan.waves[1].beans[0].id, "1.2");
448    }
449}