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#[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#[derive(Debug, Clone)]
31pub struct BlockedBean {
32 pub id: String,
33 pub title: String,
34 pub reason: BlockReason,
35}
36
37pub struct DispatchPlan {
39 pub waves: Vec<Wave>,
40 pub skipped: Vec<BlockedBean>,
41 pub warnings: Vec<(String, ScopeWarning)>,
43 pub all_beans: Vec<SizedBean>,
45 pub index: Index,
47}
48
49pub(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 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 if let Some(filter_id) = filter_id {
77 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 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 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
137pub(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
161pub(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 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 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 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 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 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 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 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}