Skip to main content

bn/commands/run/
plan.rs

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