1use std::path::Path;
8
9use anyhow::Result;
10
11use crate::bean::{Bean, Status};
12use crate::config::Config;
13use crate::discovery::find_bean_file;
14use crate::index::Index;
15use crate::util::natural_cmp;
16
17pub struct PlanArgs {
19 pub id: Option<String>,
20 pub strategy: Option<String>,
21 pub auto: bool,
22 pub force: bool,
23 pub dry_run: bool,
24}
25
26pub fn cmd_plan(beans_dir: &Path, args: PlanArgs) -> Result<()> {
28 let config = Config::load_with_extends(beans_dir)?;
29 let workspace = beans_dir.parent().unwrap_or_else(|| Path::new("."));
30
31 let index = Index::load_or_rebuild(beans_dir)?;
32
33 match args.id {
34 Some(ref id) => plan_specific(beans_dir, &config, &index, workspace, id, &args),
35 None => plan_auto_pick(beans_dir, &config, &index, workspace, &args),
36 }
37}
38
39fn plan_specific(
41 beans_dir: &Path,
42 config: &Config,
43 _index: &Index,
44 _workspace: &Path,
45 id: &str,
46 args: &PlanArgs,
47) -> Result<()> {
48 let bean_path = find_bean_file(beans_dir, id)?;
49 let bean = Bean::from_file(&bean_path)?;
50
51 let is_oversized = bean.produces.len() > crate::blocking::MAX_PRODUCES
52 || bean.paths.len() > crate::blocking::MAX_PATHS;
53
54 if !is_oversized && !args.force {
55 eprintln!("Bean {} is small enough to run directly.", id,);
56 eprintln!(" Use bn run {} to dispatch it.", id);
57 eprintln!(" Use bn plan {} --force to plan anyway.", id);
58 return Ok(());
59 }
60
61 spawn_plan(beans_dir, config, id, &bean, args)
62}
63
64fn plan_auto_pick(
66 beans_dir: &Path,
67 config: &Config,
68 index: &Index,
69 _workspace: &Path,
70 args: &PlanArgs,
71) -> Result<()> {
72 let mut candidates: Vec<(String, String, u8)> = Vec::new();
74
75 for entry in &index.beans {
76 if entry.status != Status::Open {
77 continue;
78 }
79 if entry.claimed_by.is_some() {
81 continue;
82 }
83
84 let bean_path = match find_bean_file(beans_dir, &entry.id) {
85 Ok(p) => p,
86 Err(_) => continue,
87 };
88 let bean = match Bean::from_file(&bean_path) {
89 Ok(b) => b,
90 Err(_) => continue,
91 };
92
93 let is_oversized = bean.produces.len() > crate::blocking::MAX_PRODUCES
94 || bean.paths.len() > crate::blocking::MAX_PATHS;
95
96 if is_oversized {
97 candidates.push((entry.id.clone(), entry.title.clone(), entry.priority));
98 }
99 }
100
101 if candidates.is_empty() {
102 eprintln!("✓ All ready beans are small enough to run directly.");
103 eprintln!(" Use bn run to dispatch them.");
104 return Ok(());
105 }
106
107 candidates.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| natural_cmp(&a.0, &b.0)));
109
110 eprintln!("{} beans need planning:", candidates.len());
112 for (id, title, priority) in &candidates {
113 eprintln!(" P{} {:6} {}", priority, id, title);
114 }
115 eprintln!();
116
117 let (id, title, _) = &candidates[0];
119 eprintln!("Planning: {} — {}", id, title);
120
121 let bean_path = find_bean_file(beans_dir, id)?;
123 let bean = Bean::from_file(&bean_path)?;
124
125 spawn_plan(beans_dir, config, id, &bean, args)
126}
127
128fn spawn_plan(
133 beans_dir: &Path,
134 config: &Config,
135 id: &str,
136 bean: &Bean,
137 args: &PlanArgs,
138) -> Result<()> {
139 if let Some(ref template) = config.plan {
141 return spawn_template(template, id, args);
142 }
143
144 spawn_builtin(beans_dir, id, bean, args)
146}
147
148fn spawn_template(template: &str, id: &str, args: &PlanArgs) -> Result<()> {
150 let mut cmd = template.replace("{id}", id);
151
152 if let Some(ref strategy) = args.strategy {
153 cmd = format!("{} --strategy {}", cmd, strategy);
154 }
155
156 if args.dry_run {
157 eprintln!("Would spawn: {}", cmd);
158 return Ok(());
159 }
160
161 eprintln!("Spawning: {}", cmd);
162 run_shell_command(&cmd, id, args.auto)
163}
164
165fn spawn_builtin(beans_dir: &Path, id: &str, bean: &Bean, args: &PlanArgs) -> Result<()> {
167 let prompt = build_decomposition_prompt(id, bean, args.strategy.as_deref());
168
169 let bean_path = find_bean_file(beans_dir, id)?;
171 let bean_path_str = bean_path.display().to_string();
172
173 let escaped_prompt = shell_escape(&prompt);
175 let cmd = format!("pi @{} {}", bean_path_str, escaped_prompt);
176
177 if args.dry_run {
178 eprintln!("Would spawn: {}", cmd);
179 eprintln!("\n--- Built-in decomposition prompt ---");
180 eprintln!("{}", prompt);
181 return Ok(());
182 }
183
184 eprintln!("Spawning built-in decomposition for bean {}...", id);
185 run_shell_command(&cmd, id, args.auto)
186}
187
188fn run_shell_command(cmd: &str, id: &str, auto: bool) -> Result<()> {
190 if auto {
191 let status = std::process::Command::new("sh").args(["-c", cmd]).status();
192 match status {
193 Ok(s) if s.success() => {
194 eprintln!("Planning complete. Use bn tree {} to see children.", id);
195 }
196 Ok(s) => {
197 anyhow::bail!("Plan command exited with code {}", s.code().unwrap_or(-1));
198 }
199 Err(e) => {
200 anyhow::bail!("Failed to run plan command: {}", e);
201 }
202 }
203 } else {
204 let status = std::process::Command::new("sh")
205 .args(["-c", cmd])
206 .stdin(std::process::Stdio::inherit())
207 .stdout(std::process::Stdio::inherit())
208 .stderr(std::process::Stdio::inherit())
209 .status();
210 match status {
211 Ok(s) if s.success() => {
212 eprintln!("Planning complete. Use bn tree {} to see children.", id);
213 }
214 Ok(s) => {
215 let code = s.code().unwrap_or(-1);
216 if code != 0 {
217 anyhow::bail!("Plan command exited with code {}", code);
218 }
219 }
220 Err(e) => {
221 anyhow::bail!("Failed to run plan command: {}", e);
222 }
223 }
224 }
225 Ok(())
226}
227
228fn build_decomposition_prompt(id: &str, bean: &Bean, strategy: Option<&str>) -> String {
230 let strategy_guidance = match strategy {
231 Some("feature") | Some("by-feature") => {
232 "Split by feature — each child is a vertical slice (types + impl + tests for one feature)."
233 }
234 Some("layer") | Some("by-layer") => {
235 "Split by layer — types/interfaces first, then implementation, then tests."
236 }
237 Some("file") | Some("by-file") => {
238 "Split by file — each child handles one file or closely related file group."
239 }
240 Some("phase") => {
241 "Split by phase — scaffold first, then core logic, then edge cases, then polish."
242 }
243 Some(other) => {
244 return build_prompt_text(id, bean, other);
246 }
247 None => "Choose the best strategy: by-feature (vertical slices), by-layer, or by-file.",
248 };
249
250 build_prompt_text(id, bean, strategy_guidance)
251}
252
253fn build_prompt_text(id: &str, bean: &Bean, strategy_guidance: &str) -> String {
255 let title = &bean.title;
256 let priority = bean.priority;
257 let description = bean.description.as_deref().unwrap_or("(no description)");
258
259 let mut dep_context = String::new();
261 if !bean.produces.is_empty() {
262 dep_context.push_str(&format!("\nProduces: {}\n", bean.produces.join(", ")));
263 }
264 if !bean.requires.is_empty() {
265 dep_context.push_str(&format!("Requires: {}\n", bean.requires.join(", ")));
266 }
267
268 format!(
269 r#"Decompose bean {id} into smaller child beans.
270
271## Parent Bean
272- **ID:** {id}
273- **Title:** {title}
274- **Priority:** P{priority}
275{dep_context}
276## Strategy
277{strategy_guidance}
278
279## Sizing Rules
280- A bean is **atomic** if it requires ≤5 functions to write and ≤10 to read
281- Each child should have at most 3 `produces` artifacts and 5 `paths`
282- Count functions concretely by examining the code — don't estimate
283
284## Splitting Rules
285- Create **2-4 children** for medium beans, **3-5** for large ones
286- **Maximize parallelism** — prefer independent beans over sequential chains
287- Each child must have a **verify command** that exits 0 on success
288- Children should be independently testable where possible
289- Use `--produces` and `--requires` to express dependencies between siblings
290
291## Context Embedding Rules
292- **Embed context into descriptions** — don't reference files, include the relevant types/signatures
293- Include: concrete file paths, function signatures, type definitions
294- Include: specific steps, edge cases, error handling requirements
295- Be specific: "Add `fn validate_email(s: &str) -> bool` to `src/util.rs`" not "add validation"
296
297## How to Create Children
298Use `bn create` for each child bean:
299
300```
301bn create "child title" \
302 --parent {id} \
303 --priority {priority} \
304 --verify "test command that exits 0" \
305 --produces "artifact_name" \
306 --requires "artifact_from_sibling" \
307 --description "Full description with:
308- What to implement
309- Which files to modify (with paths)
310- Key types/signatures to use or create
311- Acceptance criteria
312- Edge cases to handle"
313```
314
315## Description Template
316A good child bean description includes:
3171. **What**: One clear sentence of what this child does
3182. **Files**: Specific file paths with what changes in each
3193. **Context**: Embedded type definitions, function signatures, patterns to follow
3204. **Acceptance**: Concrete criteria the verify command checks
3215. **Edge cases**: What could go wrong, what to handle
322
323## Your Task
3241. Read the parent bean's description below
3252. Examine referenced source files to count functions accurately
3263. Decide on a split strategy
3274. Create 2-5 child beans using `bn create` commands
3285. Ensure every child has a verify command
3296. After creating children, run `bn tree {id}` to show the result
330
331## Parent Bean Description
332{description}"#,
333 )
334}
335
336fn shell_escape(s: &str) -> String {
338 let escaped = s.replace('\'', "'\\''");
340 format!("'{}'", escaped)
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use std::fs;
347 use tempfile::TempDir;
348
349 fn setup_beans_dir() -> (TempDir, std::path::PathBuf) {
350 let dir = TempDir::new().unwrap();
351 let beans_dir = dir.path().join(".beans");
352 fs::create_dir(&beans_dir).unwrap();
353 fs::write(
354 beans_dir.join("config.yaml"),
355 "project: test\nnext_id: 10\n",
356 )
357 .unwrap();
358 (dir, beans_dir)
359 }
360
361 #[test]
362 fn plan_help_contains_plan() {
363 }
366
367 #[test]
368 fn plan_no_template_without_auto_errors() {
369 let (dir, beans_dir) = setup_beans_dir();
370
371 let mut bean = Bean::new("1", "Big bean");
373 bean.produces = vec!["a".into(), "b".into(), "c".into(), "d".into()];
374 bean.to_file(beans_dir.join("1-big-bean.md")).unwrap();
375
376 let _ = Index::build(&beans_dir);
377
378 let result = cmd_plan(
381 &beans_dir,
382 PlanArgs {
383 id: Some("1".to_string()),
384 strategy: None,
385 auto: false,
386 force: true,
387 dry_run: true, },
389 );
390
391 assert!(result.is_ok());
393
394 drop(dir);
395 }
396
397 #[test]
398 fn plan_small_bean_suggests_run() {
399 let (dir, beans_dir) = setup_beans_dir();
400
401 let bean = Bean::new("1", "Small bean");
402 bean.to_file(beans_dir.join("1-small-bean.md")).unwrap();
403
404 let _ = Index::build(&beans_dir);
405
406 let result = cmd_plan(
408 &beans_dir,
409 PlanArgs {
410 id: Some("1".to_string()),
411 strategy: None,
412 auto: false,
413 force: false,
414 dry_run: false,
415 },
416 );
417
418 assert!(result.is_ok());
419
420 drop(dir);
421 }
422
423 #[test]
424 fn plan_force_overrides_size_check() {
425 let (dir, beans_dir) = setup_beans_dir();
426
427 fs::write(
429 beans_dir.join("config.yaml"),
430 "project: test\nnext_id: 10\nplan: \"true\"\n",
431 )
432 .unwrap();
433
434 let bean = Bean::new("1", "Small bean");
435 bean.to_file(beans_dir.join("1-small-bean.md")).unwrap();
436
437 let _ = Index::build(&beans_dir);
438
439 let result = cmd_plan(
441 &beans_dir,
442 PlanArgs {
443 id: Some("1".to_string()),
444 strategy: None,
445 auto: false,
446 force: true,
447 dry_run: false,
448 },
449 );
450
451 assert!(result.is_ok());
452
453 drop(dir);
454 }
455
456 #[test]
457 fn plan_dry_run_does_not_spawn() {
458 let (dir, beans_dir) = setup_beans_dir();
459
460 fs::write(
461 beans_dir.join("config.yaml"),
462 "project: test\nnext_id: 10\nplan: \"echo planning {id}\"\n",
463 )
464 .unwrap();
465
466 let mut bean = Bean::new("1", "Big bean");
467 bean.produces = vec!["a".into(), "b".into(), "c".into(), "d".into()];
468 bean.to_file(beans_dir.join("1-big-bean.md")).unwrap();
469
470 let _ = Index::build(&beans_dir);
471
472 let result = cmd_plan(
473 &beans_dir,
474 PlanArgs {
475 id: Some("1".to_string()),
476 strategy: None,
477 auto: false,
478 force: false,
479 dry_run: true,
480 },
481 );
482
483 assert!(result.is_ok());
484
485 drop(dir);
486 }
487
488 #[test]
489 fn plan_auto_pick_finds_oversized() {
490 let (dir, beans_dir) = setup_beans_dir();
491
492 fs::write(
493 beans_dir.join("config.yaml"),
494 "project: test\nnext_id: 10\nplan: \"true\"\n",
495 )
496 .unwrap();
497
498 let mut big = Bean::new("1", "Big bean");
500 big.produces = vec!["a".into(), "b".into(), "c".into(), "d".into()];
501 big.to_file(beans_dir.join("1-big-bean.md")).unwrap();
502
503 let small = Bean::new("2", "Small bean");
505 small.to_file(beans_dir.join("2-small-bean.md")).unwrap();
506
507 let _ = Index::build(&beans_dir);
508
509 let result = cmd_plan(
510 &beans_dir,
511 PlanArgs {
512 id: None,
513 strategy: None,
514 auto: true,
515 force: false,
516 dry_run: false,
517 },
518 );
519
520 assert!(result.is_ok());
521
522 drop(dir);
523 }
524
525 #[test]
526 fn plan_auto_pick_none_needed() {
527 let (dir, beans_dir) = setup_beans_dir();
528
529 let bean = Bean::new("1", "Small");
531 bean.to_file(beans_dir.join("1-small.md")).unwrap();
532
533 let _ = Index::build(&beans_dir);
534
535 let result = cmd_plan(
536 &beans_dir,
537 PlanArgs {
538 id: None,
539 strategy: None,
540 auto: false,
541 force: false,
542 dry_run: false,
543 },
544 );
545
546 assert!(result.is_ok());
547
548 drop(dir);
549 }
550
551 #[test]
552 fn build_prompt_includes_decomposition_rules() {
553 let bean = Bean::new("42", "Implement auth system");
554 let prompt = build_decomposition_prompt("42", &bean, None);
555
556 assert!(prompt.contains("Decompose bean 42"), "missing header");
558 assert!(prompt.contains("Implement auth system"), "missing title");
559 assert!(prompt.contains("≤5 functions"), "missing sizing rules");
560 assert!(
561 prompt.contains("Maximize parallelism"),
562 "missing parallelism rule"
563 );
564 assert!(
565 prompt.contains("Embed context"),
566 "missing context embedding rule"
567 );
568 assert!(
569 prompt.contains("verify command"),
570 "missing verify requirement"
571 );
572 assert!(prompt.contains("bn create"), "missing create syntax");
573 assert!(prompt.contains("--parent 42"), "missing parent flag");
574 assert!(prompt.contains("--produces"), "missing produces flag");
575 assert!(prompt.contains("--requires"), "missing requires flag");
576 }
577
578 #[test]
579 fn build_prompt_with_strategy() {
580 let bean = Bean::new("1", "Big task");
581 let prompt = build_decomposition_prompt("1", &bean, Some("by-feature"));
582
583 assert!(
584 prompt.contains("vertical slice"),
585 "missing feature strategy guidance"
586 );
587 }
588
589 #[test]
590 fn build_prompt_includes_produces_requires() {
591 let mut bean = Bean::new("5", "Task with deps");
592 bean.produces = vec!["auth_types".to_string(), "auth_middleware".to_string()];
593 bean.requires = vec!["db_connection".to_string()];
594
595 let prompt = build_decomposition_prompt("5", &bean, None);
596
597 assert!(prompt.contains("auth_types"), "missing produces");
598 assert!(prompt.contains("db_connection"), "missing requires");
599 }
600
601 #[test]
602 fn shell_escape_simple() {
603 assert_eq!(shell_escape("hello world"), "'hello world'");
604 }
605
606 #[test]
607 fn shell_escape_with_quotes() {
608 assert_eq!(shell_escape("it's here"), "'it'\\''s here'");
609 }
610
611 #[test]
612 fn plan_builtin_dry_run_shows_prompt() {
613 let (dir, beans_dir) = setup_beans_dir();
614
615 let mut bean = Bean::new("1", "Big bean");
617 bean.description = Some("x".repeat(2000));
618 bean.to_file(beans_dir.join("1-big-bean.md")).unwrap();
619
620 let _ = Index::build(&beans_dir);
621
622 let result = cmd_plan(
623 &beans_dir,
624 PlanArgs {
625 id: Some("1".to_string()),
626 strategy: None,
627 auto: false,
628 force: true,
629 dry_run: true,
630 },
631 );
632
633 assert!(result.is_ok());
634
635 drop(dir);
636 }
637}