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#[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
30pub struct DispatchPlan {
32 pub waves: Vec<Wave>,
33 pub skipped: Vec<SizedBean>,
34 pub all_beans: Vec<SizedBean>,
36 pub index: Index,
38}
39
40pub(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 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 if let Some(filter_id) = filter_id {
65 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 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 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 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
133pub(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
162pub(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 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 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 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 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 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 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 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 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}