1use std::fs;
2use std::path::Path;
3use std::process::Command as ShellCommand;
4
5use anyhow::{anyhow, Context, Result};
6
7use crate::bean::{validate_priority, Bean, OnFailAction};
8use crate::commands::claim::cmd_claim;
9use crate::config::Config;
10use crate::hooks::{execute_hook, HookEvent};
11use crate::index::Index;
12use crate::project::suggest_verify_command;
13use crate::util::title_to_slug;
14
15pub struct CreateArgs {
17 pub title: String,
18 pub description: Option<String>,
19 pub acceptance: Option<String>,
20 pub notes: Option<String>,
21 pub design: Option<String>,
22 pub verify: Option<String>,
23 pub priority: Option<u8>,
24 pub labels: Option<String>,
25 pub assignee: Option<String>,
26 pub deps: Option<String>,
27 pub parent: Option<String>,
28 pub produces: Option<String>,
29 pub requires: Option<String>,
30 pub paths: Option<String>,
32 pub on_fail: Option<OnFailAction>,
34 pub pass_ok: bool,
36 pub claim: bool,
38 pub by: Option<String>,
40 pub verify_timeout: Option<u64>,
42 pub feature: bool,
44}
45
46pub fn assign_child_id(beans_dir: &Path, parent_id: &str) -> Result<String> {
49 let mut max_child: u32 = 0;
50
51 let dir_entries = fs::read_dir(beans_dir)
52 .with_context(|| format!("Failed to read directory: {}", beans_dir.display()))?;
53
54 for entry in dir_entries {
55 let entry = entry?;
56 let path = entry.path();
57
58 let filename = path
59 .file_name()
60 .and_then(|n| n.to_str())
61 .unwrap_or_default();
62
63 if let Some(name_without_ext) = filename.strip_suffix(".md") {
65 if let Some(name_without_parent) = name_without_ext.strip_prefix(parent_id) {
66 if let Some(after_dot) = name_without_parent.strip_prefix('.') {
67 let num_part = after_dot.split('-').next().unwrap_or_default();
69 if let Ok(child_num) = num_part.parse::<u32>() {
70 if child_num > max_child {
71 max_child = child_num;
72 }
73 }
74 }
75 }
76 }
77
78 if let Some(name_without_ext) = filename.strip_suffix(".yaml") {
80 if let Some(name_without_parent) = name_without_ext.strip_prefix(parent_id) {
81 if let Some(after_dot) = name_without_parent.strip_prefix('.') {
82 if let Ok(child_num) = after_dot.parse::<u32>() {
83 if child_num > max_child {
84 max_child = child_num;
85 }
86 }
87 }
88 }
89 }
90 }
91
92 Ok(format!("{}.{}", parent_id, max_child + 1))
93}
94
95pub fn parse_on_fail(s: &str) -> Result<OnFailAction> {
103 let (action, arg) = match s.split_once(':') {
104 Some((a, b)) => (a, Some(b)),
105 None => (s, None),
106 };
107
108 match action {
109 "retry" => {
110 let max = arg.map(|a| a.parse::<u32>()).transpose().map_err(|_| {
111 anyhow!(
112 "Invalid retry max: '{}'. Expected a number (e.g. retry:5)",
113 arg.unwrap_or("")
114 )
115 })?;
116 Ok(OnFailAction::Retry {
117 max,
118 delay_secs: None,
119 })
120 }
121 "escalate" => {
122 let priority = match arg {
123 Some(a) => {
124 let stripped = a
125 .strip_prefix('P')
126 .or_else(|| a.strip_prefix('p'))
127 .unwrap_or(a);
128 let p = stripped.parse::<u8>().map_err(|_| {
129 anyhow!("Invalid escalate priority: '{}'. Expected P0-P4 or 0-4", a)
130 })?;
131 validate_priority(p)?;
132 Some(p)
133 }
134 None => None,
135 };
136 Ok(OnFailAction::Escalate {
137 priority,
138 message: None,
139 })
140 }
141 _ => Err(anyhow!(
142 "Unknown on-fail action: '{}'. Expected 'retry' or 'escalate'",
143 action
144 )),
145 }
146}
147
148pub fn cmd_create(beans_dir: &Path, args: CreateArgs) -> Result<String> {
154 if let Some(priority) = args.priority {
156 validate_priority(priority)?;
157 }
158
159 if args.claim && args.parent.is_none() && args.acceptance.is_none() && args.verify.is_none() {
162 anyhow::bail!(
163 "Bean must have validation criteria: provide --acceptance or --verify (or both)\n\
164 Hint: parent/goal beans (without --claim) don't require this."
165 );
166 }
167
168 if !args.pass_ok {
172 if let Some(verify_cmd) = args.verify.as_ref() {
173 let project_root = beans_dir
174 .parent()
175 .ok_or_else(|| anyhow!("Cannot determine project root"))?;
176
177 eprintln!("Running verify (must fail): {}", verify_cmd);
178
179 let status = ShellCommand::new("sh")
180 .args(["-c", verify_cmd])
181 .current_dir(project_root)
182 .status()
183 .with_context(|| format!("Failed to execute verify command: {}", verify_cmd))?;
184
185 if status.success() {
186 anyhow::bail!(
187 "Cannot create bean: verify command already passes!\n\n\
188 The test must FAIL on current code to prove it tests something real.\n\
189 Either:\n\
190 - The test doesn't actually test the new behavior\n\
191 - The feature is already implemented\n\
192 - The test is a no-op (assert True)\n\n\
193 Use --pass-ok / -p to skip this check."
194 );
195 }
196
197 eprintln!("✓ Verify failed as expected - test is real");
198 }
199 }
200
201 let mut config = Config::load(beans_dir)?;
203
204 let bean_id = if let Some(parent_id) = &args.parent {
206 assign_child_id(beans_dir, parent_id)?
207 } else {
208 let id = config.increment_id();
209 config.save(beans_dir)?;
210 id.to_string()
211 };
212
213 let slug = title_to_slug(&args.title);
215
216 let has_verify = args.verify.is_some();
218
219 let mut bean = Bean::new(&bean_id, &args.title);
221 bean.slug = Some(slug.clone());
222
223 if let Some(desc) = args.description {
224 bean.description = Some(desc);
225 }
226 if let Some(acceptance) = args.acceptance {
227 bean.acceptance = Some(acceptance);
228 }
229 if let Some(notes) = args.notes {
230 bean.notes = Some(notes);
231 }
232 if let Some(design) = args.design {
233 bean.design = Some(design);
234 }
235 let has_fail_first = !args.pass_ok && args.verify.is_some();
236 if let Some(verify) = args.verify {
237 bean.verify = Some(verify);
238 }
239 if has_fail_first {
240 bean.fail_first = true;
241 }
242 if let Some(priority) = args.priority {
243 bean.priority = priority;
244 }
245 if let Some(assignee) = args.assignee {
246 bean.assignee = Some(assignee);
247 }
248 if let Some(parent) = args.parent {
249 bean.parent = Some(parent);
250 }
251
252 if let Some(labels_str) = args.labels {
254 bean.labels = labels_str
255 .split(',')
256 .map(|s| s.trim().to_string())
257 .collect();
258 }
259
260 if let Some(deps_str) = args.deps {
262 bean.dependencies = deps_str.split(',').map(|s| s.trim().to_string()).collect();
263 }
264
265 if let Some(produces_str) = args.produces {
267 bean.produces = produces_str
268 .split(',')
269 .map(|s| s.trim().to_string())
270 .collect();
271 }
272
273 if let Some(requires_str) = args.requires {
275 bean.requires = requires_str
276 .split(',')
277 .map(|s| s.trim().to_string())
278 .collect();
279 }
280
281 if let Some(paths_str) = args.paths {
283 bean.paths = paths_str
284 .split(',')
285 .map(|s| s.trim().to_string())
286 .filter(|s| !s.is_empty())
287 .collect();
288 }
289
290 if let Some(on_fail) = args.on_fail {
292 bean.on_fail = Some(on_fail);
293 }
294
295 if let Some(timeout) = args.verify_timeout {
297 bean.verify_timeout = Some(timeout);
298 }
299
300 let project_dir = beans_dir
302 .parent()
303 .ok_or_else(|| anyhow!("Failed to determine project directory"))?;
304
305 let pre_passed = execute_hook(HookEvent::PreCreate, &bean, project_dir, None)
307 .context("Pre-create hook execution failed")?;
308
309 if !pre_passed {
310 return Err(anyhow!("Pre-create hook rejected bean creation"));
311 }
312
313 let bean_path = beans_dir.join(format!("{}-{}.md", bean_id, slug));
315 bean.to_file(&bean_path)?;
316
317 let index = Index::build(beans_dir)?;
319 index.save(beans_dir)?;
320
321 eprintln!("Created bean {}: {}", bean_id, args.title);
322
323 if !has_verify {
325 if let Some(suggested) = suggest_verify_command(project_dir) {
326 eprintln!(
327 "Tip: Consider adding a verify command: --verify \"{}\"",
328 suggested
329 );
330 }
331 }
332
333 if let Err(e) = execute_hook(HookEvent::PostCreate, &bean, project_dir, None) {
335 eprintln!("Warning: post-create hook failed: {}", e);
336 }
337
338 if args.claim {
340 cmd_claim(beans_dir, &bean_id, args.by, true)?;
341 }
342
343 Ok(bean_id)
344}
345
346pub fn cmd_create_next(beans_dir: &Path, args: CreateArgs) -> Result<String> {
358 let index = Index::load(beans_dir).or_else(|_| Index::build(beans_dir))?;
360 let latest_id = index
361 .beans
362 .iter()
363 .max_by_key(|e| e.updated_at)
364 .map(|e| e.id.clone())
365 .ok_or_else(|| {
366 anyhow!(
367 "No previous bean found. 'bn create next' requires at least one existing bean.\n\
368 Use 'bn create' for the first bean in a chain."
369 )
370 })?;
371
372 let merged_deps = match args.deps {
374 Some(ref d) => Some(format!("{},{}", latest_id, d)),
375 None => Some(latest_id.clone()),
376 };
377
378 eprintln!("⛓ Chained after bean {} (@latest)", latest_id);
379
380 let new_args = CreateArgs {
381 deps: merged_deps,
382 ..args
383 };
384
385 cmd_create(beans_dir, new_args)
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use crate::bean::Status;
392 use tempfile::TempDir;
393
394 fn setup_beans_dir_with_config() -> (TempDir, std::path::PathBuf) {
395 let dir = TempDir::new().unwrap();
396 let beans_dir = dir.path().join(".beans");
397 fs::create_dir(&beans_dir).unwrap();
398
399 let config = Config {
400 project: "test".to_string(),
401 next_id: 1,
402 auto_close_parent: true,
403 run: None,
404 plan: None,
405 max_loops: 10,
406 max_concurrent: 4,
407 poll_interval: 30,
408 extends: vec![],
409 rules_file: None,
410 file_locking: false,
411 worktree: false,
412 on_close: None,
413 on_fail: None,
414 post_plan: None,
415 verify_timeout: None,
416 review: None,
417 user: None,
418 user_email: None,
419 };
420 config.save(&beans_dir).unwrap();
421
422 (dir, beans_dir)
423 }
424
425 #[test]
426 fn create_minimal_bean() {
427 let (_dir, beans_dir) = setup_beans_dir_with_config();
428
429 let args = CreateArgs {
430 title: "First task".to_string(),
431 description: None,
432 acceptance: None,
433 notes: None,
434 design: None,
435 verify: Some("cargo test".to_string()),
436 priority: None,
437 labels: None,
438 assignee: None,
439 deps: None,
440 parent: None,
441 produces: None,
442 requires: None,
443 paths: None,
444 on_fail: None,
445 pass_ok: true,
446 feature: false,
447 claim: false,
448 by: None,
449 verify_timeout: None,
450 };
451
452 cmd_create(&beans_dir, args).unwrap();
453
454 let bean_path = beans_dir.join("1-first-task.md");
456 assert!(bean_path.exists());
457
458 let bean = Bean::from_file(&bean_path).unwrap();
460 assert_eq!(bean.id, "1");
461 assert_eq!(bean.title, "First task");
462 assert_eq!(bean.slug, Some("first-task".to_string()));
463 }
464
465 #[test]
466 fn create_allows_bean_without_verify_or_acceptance() {
467 let (_dir, beans_dir) = setup_beans_dir_with_config();
468
469 let args = CreateArgs {
470 title: "Goal bean".to_string(),
471 description: Some("A parent/goal bean with no verify".to_string()),
472 acceptance: None,
473 notes: None,
474 design: None,
475 verify: None,
476 priority: None,
477 labels: None,
478 assignee: None,
479 deps: None,
480 parent: None,
481 produces: None,
482 requires: None,
483 paths: None,
484 on_fail: None,
485 pass_ok: true,
486 feature: false,
487 claim: false,
488 by: None,
489 verify_timeout: None,
490 };
491
492 let result = cmd_create(&beans_dir, args);
493 assert!(
494 result.is_ok(),
495 "Should allow bean without verify or acceptance"
496 );
497
498 let bean_path = beans_dir.join("1-goal-bean.md");
499 assert!(bean_path.exists());
500 let bean = Bean::from_file(&bean_path).unwrap();
501 assert_eq!(bean.title, "Goal bean");
502 assert!(bean.verify.is_none());
503 assert!(bean.acceptance.is_none());
504 }
505
506 #[test]
507 fn create_increments_id() {
508 let (_dir, beans_dir) = setup_beans_dir_with_config();
509
510 let args1 = CreateArgs {
512 title: "First".to_string(),
513 description: None,
514 acceptance: Some("Done".to_string()),
515 notes: None,
516 design: None,
517 verify: None,
518 priority: None,
519 labels: None,
520 assignee: None,
521 deps: None,
522 parent: None,
523 produces: None,
524 requires: None,
525 paths: None,
526 on_fail: None,
527 pass_ok: true,
528 feature: false,
529 claim: false,
530 by: None,
531 verify_timeout: None,
532 };
533 cmd_create(&beans_dir, args1).unwrap();
534
535 let args2 = CreateArgs {
537 title: "Second".to_string(),
538 description: None,
539 acceptance: None,
540 notes: None,
541 design: None,
542 verify: Some("true".to_string()),
543 priority: None,
544 labels: None,
545 assignee: None,
546 deps: None,
547 parent: None,
548 produces: None,
549 requires: None,
550 paths: None,
551 on_fail: None,
552 pass_ok: true,
553 feature: false,
554 claim: false,
555 by: None,
556 verify_timeout: None,
557 };
558 cmd_create(&beans_dir, args2).unwrap();
559
560 let bean1 = Bean::from_file(beans_dir.join("1-first.md")).unwrap();
562 let bean2 = Bean::from_file(beans_dir.join("2-second.md")).unwrap();
563 assert_eq!(bean1.id, "1");
564 assert_eq!(bean2.id, "2");
565 }
566
567 #[test]
568 fn create_with_parent_assigns_child_id() {
569 let (_dir, beans_dir) = setup_beans_dir_with_config();
570
571 let parent_args = CreateArgs {
573 title: "Parent".to_string(),
574 description: None,
575 acceptance: Some("Children complete".to_string()),
576 notes: None,
577 design: None,
578 verify: None,
579 priority: None,
580 labels: None,
581 assignee: None,
582 deps: None,
583 parent: None,
584 produces: None,
585 requires: None,
586 paths: None,
587 on_fail: None,
588 pass_ok: true,
589 feature: false,
590 claim: false,
591 by: None,
592 verify_timeout: None,
593 };
594 cmd_create(&beans_dir, parent_args).unwrap();
595
596 let child_args = CreateArgs {
598 title: "Child 1".to_string(),
599 description: None,
600 acceptance: None,
601 notes: None,
602 design: None,
603 verify: Some("cargo test".to_string()),
604 priority: None,
605 labels: None,
606 assignee: None,
607 deps: None,
608 parent: Some("1".to_string()),
609 produces: None,
610 requires: None,
611 paths: None,
612 on_fail: None,
613 pass_ok: true,
614 feature: false,
615 claim: false,
616 by: None,
617 verify_timeout: None,
618 };
619 cmd_create(&beans_dir, child_args).unwrap();
620
621 let bean = Bean::from_file(beans_dir.join("1.1-child-1.md")).unwrap();
623 assert_eq!(bean.id, "1.1");
624 assert_eq!(bean.parent, Some("1".to_string()));
625 }
626
627 #[test]
628 fn create_multiple_children() {
629 let (_dir, beans_dir) = setup_beans_dir_with_config();
630
631 let parent_args = CreateArgs {
633 title: "Parent".to_string(),
634 description: None,
635 acceptance: Some("All children complete".to_string()),
636 notes: None,
637 design: None,
638 verify: None,
639 priority: None,
640 labels: None,
641 assignee: None,
642 deps: None,
643 parent: None,
644 produces: None,
645 requires: None,
646 paths: None,
647 on_fail: None,
648 pass_ok: true,
649 feature: false,
650 claim: false,
651 by: None,
652 verify_timeout: None,
653 };
654 cmd_create(&beans_dir, parent_args).unwrap();
655
656 for i in 1..=3 {
658 let child_args = CreateArgs {
659 title: format!("Child {}", i),
660 description: None,
661 acceptance: None,
662 notes: None,
663 design: None,
664 verify: Some("cargo test".to_string()),
665 priority: None,
666 labels: None,
667 assignee: None,
668 deps: None,
669 parent: Some("1".to_string()),
670 produces: None,
671 requires: None,
672 paths: None,
673 on_fail: None,
674 pass_ok: true,
675 feature: false,
676 claim: false,
677 by: None,
678 verify_timeout: None,
679 };
680 cmd_create(&beans_dir, child_args).unwrap();
681 }
682
683 for i in 1..=3 {
685 let expected_id = format!("1.{}", i);
686 let expected_slug = format!("child-{}", i);
687 let path = beans_dir.join(format!("{}-{}.md", expected_id, expected_slug));
688 assert!(path.exists(), "Child {} should exist at {:?}", i, path);
689
690 let bean = Bean::from_file(&path).unwrap();
691 assert_eq!(bean.id, expected_id);
692 }
693 }
694
695 #[test]
696 fn create_with_all_fields() {
697 let (_dir, beans_dir) = setup_beans_dir_with_config();
698
699 let args = CreateArgs {
700 title: "Complex bean".to_string(),
701 description: Some("A description".to_string()),
702 acceptance: Some("All tests pass".to_string()),
703 notes: Some("Some notes".to_string()),
704 design: Some("Design decision".to_string()),
705 verify: None,
706 priority: Some(1),
707 labels: Some("bug,critical".to_string()),
708 assignee: Some("alice".to_string()),
709 deps: Some("2,3".to_string()),
710 parent: None,
711 produces: None,
712 requires: None,
713 paths: None,
714 on_fail: None,
715 pass_ok: true,
716 feature: false,
717 claim: false,
718 by: None,
719 verify_timeout: None,
720 };
721
722 cmd_create(&beans_dir, args).unwrap();
723
724 let bean = Bean::from_file(beans_dir.join("1-complex-bean.md")).unwrap();
725 assert_eq!(bean.title, "Complex bean");
726 assert_eq!(bean.description, Some("A description".to_string()));
727 assert_eq!(bean.acceptance, Some("All tests pass".to_string()));
728 assert_eq!(bean.notes, Some("Some notes".to_string()));
729 assert_eq!(bean.design, Some("Design decision".to_string()));
730 assert_eq!(bean.priority, 1);
731 assert_eq!(bean.labels, vec!["bug", "critical"]);
732 assert_eq!(bean.assignee, Some("alice".to_string()));
733 assert_eq!(bean.dependencies, vec!["2", "3"]);
734 }
735
736 #[test]
737 fn create_updates_index() {
738 let (_dir, beans_dir) = setup_beans_dir_with_config();
739
740 let args = CreateArgs {
741 title: "Indexed bean".to_string(),
742 description: None,
743 acceptance: Some("Indexed correctly".to_string()),
744 notes: None,
745 design: None,
746 verify: None,
747 priority: None,
748 labels: None,
749 assignee: None,
750 deps: None,
751 parent: None,
752 produces: None,
753 requires: None,
754 paths: None,
755 on_fail: None,
756 pass_ok: true,
757 feature: false,
758 claim: false,
759 by: None,
760 verify_timeout: None,
761 };
762
763 cmd_create(&beans_dir, args).unwrap();
764
765 let index = Index::load(&beans_dir).unwrap();
767 assert_eq!(index.beans.len(), 1);
768 assert_eq!(index.beans[0].id, "1");
769 assert_eq!(index.beans[0].title, "Indexed bean");
770 }
771
772 #[test]
773 fn assign_child_id_starts_at_1() {
774 let dir = TempDir::new().unwrap();
775 let beans_dir = dir.path().join(".beans");
776 fs::create_dir(&beans_dir).unwrap();
777
778 let id = assign_child_id(&beans_dir, "parent").unwrap();
779 assert_eq!(id, "parent.1");
780 }
781
782 #[test]
783 fn assign_child_id_finds_existing_children() {
784 let dir = TempDir::new().unwrap();
785 let beans_dir = dir.path().join(".beans");
786 fs::create_dir(&beans_dir).unwrap();
787
788 let bean1 = Bean::new("parent.1", "Child 1");
790 let bean2 = Bean::new("parent.2", "Child 2");
791 let bean5 = Bean::new("parent.5", "Child 5");
792
793 bean1
794 .to_file(beans_dir.join("parent.1-child-1.md"))
795 .unwrap();
796 bean2
797 .to_file(beans_dir.join("parent.2-child-2.md"))
798 .unwrap();
799 bean5
800 .to_file(beans_dir.join("parent.5-child-5.md"))
801 .unwrap();
802
803 let id = assign_child_id(&beans_dir, "parent").unwrap();
804 assert_eq!(id, "parent.6");
805 }
806
807 #[test]
808 fn create_rejects_priority_too_high() {
809 let (_dir, beans_dir) = setup_beans_dir_with_config();
810
811 let args = CreateArgs {
812 title: "Invalid priority bean".to_string(),
813 description: None,
814 acceptance: Some("Done".to_string()),
815 notes: None,
816 design: None,
817 verify: None,
818 priority: Some(5),
819 labels: None,
820 assignee: None,
821 deps: None,
822 parent: None,
823 produces: None,
824 requires: None,
825 paths: None,
826 on_fail: None,
827 pass_ok: true,
828 feature: false,
829 claim: false,
830 by: None,
831 verify_timeout: None,
832 };
833
834 let result = cmd_create(&beans_dir, args);
835 assert!(result.is_err(), "Should reject priority > 4");
836 let err_msg = result.unwrap_err().to_string();
837 assert!(
838 err_msg.contains("priority"),
839 "Error should mention priority"
840 );
841 }
842
843 #[test]
844 fn create_accepts_valid_priorities() {
845 for priority in 0..=4 {
846 let (_dir, beans_dir) = setup_beans_dir_with_config();
847
848 let args = CreateArgs {
849 title: format!("Bean with priority {}", priority),
850 description: None,
851 acceptance: Some("Done".to_string()),
852 notes: None,
853 design: None,
854 verify: None,
855 priority: Some(priority),
856 labels: None,
857 assignee: None,
858 deps: None,
859 parent: None,
860 produces: None,
861 requires: None,
862 paths: None,
863 on_fail: None,
864 pass_ok: true,
865 feature: false,
866 claim: false,
867 by: None,
868 verify_timeout: None,
869 };
870
871 let result = cmd_create(&beans_dir, args);
872 assert!(result.is_ok(), "Priority {} should be valid", priority);
873 }
874 }
875
876 #[test]
881 fn pre_create_hook_accepts_bean_creation() {
882 use std::os::unix::fs::PermissionsExt;
883 let (dir, beans_dir) = setup_beans_dir_with_config();
884 let project_dir = dir.path();
885 let hooks_dir = beans_dir.join("hooks");
886 fs::create_dir_all(&hooks_dir).unwrap();
887
888 crate::hooks::create_trust(project_dir).unwrap();
890
891 let hook_path = hooks_dir.join("pre-create");
892 fs::write(&hook_path, "#!/bin/bash\nexit 0").unwrap();
893
894 #[cfg(unix)]
895 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
896
897 let args = CreateArgs {
898 title: "Bean with accepting hook".to_string(),
899 description: None,
900 acceptance: Some("Done".to_string()),
901 notes: None,
902 design: None,
903 verify: None,
904 priority: None,
905 labels: None,
906 assignee: None,
907 deps: None,
908 parent: None,
909 produces: None,
910 requires: None,
911 paths: None,
912 on_fail: None,
913 pass_ok: true,
914 feature: false,
915 claim: false,
916 by: None,
917 verify_timeout: None,
918 };
919
920 let result = cmd_create(&beans_dir, args);
922 assert!(
923 result.is_ok(),
924 "Creation should succeed with accepting pre-create hook"
925 );
926
927 let bean_path = beans_dir.join("1-bean-with-accepting-hook.md");
929 assert!(bean_path.exists(), "Bean file should exist");
930 }
931
932 #[test]
933 fn pre_create_hook_rejects_bean_creation() {
934 use std::os::unix::fs::PermissionsExt;
935 let (dir, beans_dir) = setup_beans_dir_with_config();
936 let project_dir = dir.path();
937 let hooks_dir = beans_dir.join("hooks");
938 fs::create_dir_all(&hooks_dir).unwrap();
939
940 crate::hooks::create_trust(project_dir).unwrap();
942
943 let hook_path = hooks_dir.join("pre-create");
944 fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
945
946 #[cfg(unix)]
947 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
948
949 let args = CreateArgs {
950 title: "Bean with rejecting hook".to_string(),
951 description: None,
952 acceptance: Some("Done".to_string()),
953 notes: None,
954 design: None,
955 verify: None,
956 priority: None,
957 labels: None,
958 assignee: None,
959 deps: None,
960 parent: None,
961 produces: None,
962 requires: None,
963 paths: None,
964 on_fail: None,
965 pass_ok: true,
966 feature: false,
967 claim: false,
968 by: None,
969 verify_timeout: None,
970 };
971
972 let result = cmd_create(&beans_dir, args);
974 assert!(
975 result.is_err(),
976 "Creation should fail with rejecting pre-create hook"
977 );
978
979 let err_msg = result.unwrap_err().to_string();
980 assert!(
981 err_msg.contains("Pre-create hook rejected"),
982 "Error should indicate hook rejection"
983 );
984
985 let bean_path = beans_dir.join("1-bean-with-rejecting-hook.md");
987 assert!(
988 !bean_path.exists(),
989 "Bean file should NOT exist when pre-create hook rejects"
990 );
991 }
992
993 #[test]
994 fn post_create_hook_runs_after_creation() {
995 use std::os::unix::fs::PermissionsExt;
996
997 let (dir, beans_dir) = setup_beans_dir_with_config();
998 let project_dir = dir.path();
999 let hooks_dir = beans_dir.join("hooks");
1000 fs::create_dir_all(&hooks_dir).unwrap();
1001
1002 crate::hooks::create_trust(project_dir).unwrap();
1004
1005 let hook_path = hooks_dir.join("post-create");
1006 let marker_file = project_dir.join("hook-executed.txt");
1007 let marker_file_str = marker_file.to_string_lossy().to_string();
1008
1009 let hook_script = format!(
1011 "#!/bin/bash\necho 'post-create executed' >> '{}'\nexit 0",
1012 marker_file_str
1013 );
1014 fs::write(&hook_path, hook_script).unwrap();
1015
1016 #[cfg(unix)]
1017 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
1018
1019 let args = CreateArgs {
1020 title: "Bean with post-create hook".to_string(),
1021 description: None,
1022 acceptance: Some("Done".to_string()),
1023 notes: None,
1024 design: None,
1025 verify: None,
1026 priority: None,
1027 labels: None,
1028 assignee: None,
1029 deps: None,
1030 parent: None,
1031 produces: None,
1032 requires: None,
1033 paths: None,
1034 on_fail: None,
1035 pass_ok: true,
1036 feature: false,
1037 claim: false,
1038 by: None,
1039 verify_timeout: None,
1040 };
1041
1042 let result = cmd_create(&beans_dir, args);
1044 assert!(result.is_ok(), "Creation should succeed");
1045
1046 let bean_path = beans_dir.join("1-bean-with-post-create-hook.md");
1048 assert!(bean_path.exists(), "Bean file should exist");
1049
1050 assert!(
1052 marker_file.exists(),
1053 "Post-create hook should have run and created marker file"
1054 );
1055 }
1056
1057 #[test]
1058 fn post_create_hook_failure_does_not_break_creation() {
1059 use std::os::unix::fs::PermissionsExt;
1060 let (dir, beans_dir) = setup_beans_dir_with_config();
1061 let project_dir = dir.path();
1062 let hooks_dir = beans_dir.join("hooks");
1063 fs::create_dir_all(&hooks_dir).unwrap();
1064
1065 crate::hooks::create_trust(project_dir).unwrap();
1067
1068 let hook_path = hooks_dir.join("post-create");
1069 fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
1070
1071 #[cfg(unix)]
1072 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
1073
1074 let args = CreateArgs {
1075 title: "Bean with failing post-create hook".to_string(),
1076 description: None,
1077 acceptance: Some("Done".to_string()),
1078 notes: None,
1079 design: None,
1080 verify: None,
1081 priority: None,
1082 labels: None,
1083 assignee: None,
1084 deps: None,
1085 parent: None,
1086 produces: None,
1087 requires: None,
1088 paths: None,
1089 on_fail: None,
1090 pass_ok: true,
1091 feature: false,
1092 claim: false,
1093 by: None,
1094 verify_timeout: None,
1095 };
1096
1097 let result = cmd_create(&beans_dir, args);
1099 assert!(
1100 result.is_ok(),
1101 "Creation should succeed even if post-create hook fails"
1102 );
1103
1104 let bean_path = beans_dir.join("1-bean-with-failing-post-create-hook.md");
1106 assert!(
1107 bean_path.exists(),
1108 "Bean file should exist even when post-create hook fails"
1109 );
1110 }
1111
1112 #[test]
1113 fn untrusted_hooks_are_silently_skipped() {
1114 use std::os::unix::fs::PermissionsExt;
1115 let (_dir, beans_dir) = setup_beans_dir_with_config();
1116 let hooks_dir = beans_dir.join("hooks");
1117 fs::create_dir_all(&hooks_dir).unwrap();
1118
1119 let hook_path = hooks_dir.join("pre-create");
1122 fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
1123
1124 #[cfg(unix)]
1125 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
1126
1127 let args = CreateArgs {
1128 title: "Bean with untrusted hook".to_string(),
1129 description: None,
1130 acceptance: Some("Done".to_string()),
1131 notes: None,
1132 design: None,
1133 verify: None,
1134 priority: None,
1135 labels: None,
1136 assignee: None,
1137 deps: None,
1138 parent: None,
1139 produces: None,
1140 requires: None,
1141 paths: None,
1142 on_fail: None,
1143 pass_ok: true,
1144 feature: false,
1145 claim: false,
1146 by: None,
1147 verify_timeout: None,
1148 };
1149
1150 let result = cmd_create(&beans_dir, args);
1152 assert!(
1153 result.is_ok(),
1154 "Creation should succeed when hooks are untrusted"
1155 );
1156
1157 let bean_path = beans_dir.join("1-bean-with-untrusted-hook.md");
1159 assert!(
1160 bean_path.exists(),
1161 "Bean file should exist when hooks are untrusted"
1162 );
1163 }
1164
1165 #[test]
1166 fn default_rejects_passing_verify() {
1167 let (_dir, beans_dir) = setup_beans_dir_with_config();
1168
1169 let args = CreateArgs {
1170 title: "Cheating test".to_string(),
1171 description: None,
1172 acceptance: None,
1173 notes: None,
1174 design: None,
1175 verify: Some("true".to_string()), priority: None,
1177 labels: None,
1178 assignee: None,
1179 deps: None,
1180 parent: None,
1181 produces: None,
1182 requires: None,
1183 paths: None,
1184 on_fail: None,
1185 pass_ok: false, feature: false,
1187 claim: false,
1188 by: None,
1189 verify_timeout: None,
1190 };
1191
1192 let result = cmd_create(&beans_dir, args);
1193 assert!(result.is_err());
1194 let err_msg = result.unwrap_err().to_string();
1195 assert!(err_msg.contains("verify command already passes"));
1196 }
1197
1198 #[test]
1199 fn default_accepts_failing_verify() {
1200 let (_dir, beans_dir) = setup_beans_dir_with_config();
1201
1202 let args = CreateArgs {
1203 title: "Real test".to_string(),
1204 description: None,
1205 acceptance: None,
1206 notes: None,
1207 design: None,
1208 verify: Some("false".to_string()), priority: None,
1210 labels: None,
1211 assignee: None,
1212 deps: None,
1213 parent: None,
1214 produces: None,
1215 requires: None,
1216 paths: None,
1217 on_fail: None,
1218 pass_ok: false, feature: false,
1220 claim: false,
1221 by: None,
1222 verify_timeout: None,
1223 };
1224
1225 let result = cmd_create(&beans_dir, args);
1226 assert!(result.is_ok());
1227
1228 let bean_path = beans_dir.join("1-real-test.md");
1230 assert!(bean_path.exists());
1231
1232 let bean = Bean::from_file(&bean_path).unwrap();
1234 assert!(bean.fail_first);
1235 }
1236
1237 #[test]
1238 fn pass_ok_skips_fail_first_check() {
1239 let (_dir, beans_dir) = setup_beans_dir_with_config();
1240
1241 let args = CreateArgs {
1242 title: "Passing verify ok".to_string(),
1243 description: None,
1244 acceptance: None,
1245 notes: None,
1246 design: None,
1247 verify: Some("true".to_string()), priority: None,
1249 labels: None,
1250 assignee: None,
1251 deps: None,
1252 parent: None,
1253 produces: None,
1254 requires: None,
1255 paths: None,
1256 on_fail: None,
1257 pass_ok: true,
1258 feature: false,
1259 claim: false,
1260 by: None,
1261 verify_timeout: None,
1262 };
1263
1264 let result = cmd_create(&beans_dir, args);
1265 assert!(result.is_ok());
1266
1267 let bean_path = beans_dir.join("1-passing-verify-ok.md");
1269 assert!(bean_path.exists());
1270
1271 let bean = Bean::from_file(&bean_path).unwrap();
1273 assert!(!bean.fail_first);
1274 }
1275
1276 #[test]
1277 fn no_verify_skips_fail_first_check() {
1278 let (_dir, beans_dir) = setup_beans_dir_with_config();
1279
1280 let args = CreateArgs {
1281 title: "No verify".to_string(),
1282 description: None,
1283 acceptance: Some("Done".to_string()),
1284 notes: None,
1285 design: None,
1286 verify: None, priority: None,
1288 labels: None,
1289 assignee: None,
1290 deps: None,
1291 parent: None,
1292 produces: None,
1293 requires: None,
1294 paths: None,
1295 on_fail: None,
1296 pass_ok: false,
1297 feature: false,
1298 claim: false,
1299 by: None,
1300 verify_timeout: None,
1301 };
1302
1303 let result = cmd_create(&beans_dir, args);
1304 assert!(result.is_ok());
1305
1306 let bean_path = beans_dir.join("1-no-verify.md");
1308 let bean = Bean::from_file(&bean_path).unwrap();
1309 assert!(!bean.fail_first);
1310 }
1311
1312 #[test]
1317 fn create_with_claim_sets_in_progress() {
1318 let (_dir, beans_dir) = setup_beans_dir_with_config();
1319
1320 let args = CreateArgs {
1321 title: "Claimed task".to_string(),
1322 description: None,
1323 acceptance: None,
1324 notes: None,
1325 design: None,
1326 verify: Some("cargo test".to_string()),
1327 priority: None,
1328 labels: None,
1329 assignee: None,
1330 deps: None,
1331 parent: None,
1332 produces: None,
1333 requires: None,
1334 paths: None,
1335 on_fail: None,
1336 pass_ok: true,
1337 feature: false,
1338 claim: true,
1339 by: Some("agent-1".to_string()),
1340 verify_timeout: None,
1341 };
1342
1343 cmd_create(&beans_dir, args).unwrap();
1344
1345 let bean_path = beans_dir.join("1-claimed-task.md");
1346 assert!(bean_path.exists());
1347
1348 let bean = Bean::from_file(&bean_path).unwrap();
1349 assert_eq!(bean.id, "1");
1350 assert_eq!(bean.title, "Claimed task");
1351 assert_eq!(bean.status, Status::InProgress);
1352 assert_eq!(bean.claimed_by, Some("agent-1".to_string()));
1353 assert!(bean.claimed_at.is_some());
1354 }
1355
1356 #[test]
1357 fn create_with_claim_without_by() {
1358 let (_dir, beans_dir) = setup_beans_dir_with_config();
1359
1360 let args = CreateArgs {
1361 title: "Anon claimed".to_string(),
1362 description: None,
1363 acceptance: None,
1364 notes: None,
1365 design: None,
1366 verify: Some("true".to_string()),
1367 priority: None,
1368 labels: None,
1369 assignee: None,
1370 deps: None,
1371 parent: None,
1372 produces: None,
1373 requires: None,
1374 paths: None,
1375 on_fail: None,
1376 pass_ok: true,
1377 feature: false,
1378 claim: true,
1379 by: None,
1380 verify_timeout: None,
1381 };
1382
1383 cmd_create(&beans_dir, args).unwrap();
1384
1385 let bean_path = beans_dir.join("1-anon-claimed.md");
1386 let bean = Bean::from_file(&bean_path).unwrap();
1387 assert_eq!(bean.status, Status::InProgress);
1388 assert!(bean.claimed_at.is_some());
1391 }
1392
1393 #[test]
1394 fn create_without_claim_stays_open() {
1395 let (_dir, beans_dir) = setup_beans_dir_with_config();
1396
1397 let args = CreateArgs {
1398 title: "Unclaimed task".to_string(),
1399 description: None,
1400 acceptance: None,
1401 notes: None,
1402 design: None,
1403 verify: Some("cargo test".to_string()),
1404 priority: None,
1405 labels: None,
1406 assignee: None,
1407 deps: None,
1408 parent: None,
1409 produces: None,
1410 requires: None,
1411 paths: None,
1412 on_fail: None,
1413 pass_ok: true,
1414 feature: false,
1415 claim: false,
1416 by: None,
1417 verify_timeout: None,
1418 };
1419
1420 cmd_create(&beans_dir, args).unwrap();
1421
1422 let bean_path = beans_dir.join("1-unclaimed-task.md");
1423 let bean = Bean::from_file(&bean_path).unwrap();
1424 assert_eq!(bean.status, Status::Open);
1425 assert_eq!(bean.claimed_by, None);
1426 assert_eq!(bean.claimed_at, None);
1427 }
1428
1429 #[test]
1430 fn create_with_claim_and_parent() {
1431 let (_dir, beans_dir) = setup_beans_dir_with_config();
1432
1433 let parent_args = CreateArgs {
1435 title: "Parent".to_string(),
1436 description: None,
1437 acceptance: Some("Children done".to_string()),
1438 notes: None,
1439 design: None,
1440 verify: None,
1441 priority: None,
1442 labels: None,
1443 assignee: None,
1444 deps: None,
1445 parent: None,
1446 produces: None,
1447 requires: None,
1448 paths: None,
1449 on_fail: None,
1450 pass_ok: true,
1451 feature: false,
1452 claim: false,
1453 by: None,
1454 verify_timeout: None,
1455 };
1456 cmd_create(&beans_dir, parent_args).unwrap();
1457
1458 let child_args = CreateArgs {
1460 title: "Child claimed".to_string(),
1461 description: None,
1462 acceptance: None,
1463 notes: None,
1464 design: None,
1465 verify: Some("cargo test".to_string()),
1466 priority: None,
1467 labels: None,
1468 assignee: None,
1469 deps: None,
1470 parent: Some("1".to_string()),
1471 produces: None,
1472 requires: None,
1473 paths: None,
1474 on_fail: None,
1475 pass_ok: true,
1476 feature: false,
1477 claim: true,
1478 by: Some("agent-2".to_string()),
1479 verify_timeout: None,
1480 };
1481 cmd_create(&beans_dir, child_args).unwrap();
1482
1483 let bean_path = beans_dir.join("1.1-child-claimed.md");
1484 let bean = Bean::from_file(&bean_path).unwrap();
1485 assert_eq!(bean.id, "1.1");
1486 assert_eq!(bean.parent, Some("1".to_string()));
1487 assert_eq!(bean.status, Status::InProgress);
1488 assert_eq!(bean.claimed_by, Some("agent-2".to_string()));
1489 }
1490
1491 #[test]
1496 fn create_claim_rejects_missing_validation_criteria() {
1497 let (_dir, beans_dir) = setup_beans_dir_with_config();
1498
1499 let args = CreateArgs {
1500 title: "No criteria claimed".to_string(),
1501 description: None,
1502 acceptance: None,
1503 notes: None,
1504 design: None,
1505 verify: None,
1506 priority: None,
1507 labels: None,
1508 assignee: None,
1509 deps: None,
1510 parent: None,
1511 produces: None,
1512 requires: None,
1513 paths: None,
1514 on_fail: None,
1515 pass_ok: true,
1516 feature: false,
1517 claim: true,
1518 by: Some("agent-1".to_string()),
1519 verify_timeout: None,
1520 };
1521
1522 let result = cmd_create(&beans_dir, args);
1523 assert!(
1524 result.is_err(),
1525 "Should reject --claim without --acceptance or --verify"
1526 );
1527 let err_msg = result.unwrap_err().to_string();
1528 assert!(
1529 err_msg.contains("validation criteria"),
1530 "Error should mention validation criteria, got: {}",
1531 err_msg
1532 );
1533 }
1534
1535 #[test]
1536 fn create_claim_accepts_with_acceptance() {
1537 let (_dir, beans_dir) = setup_beans_dir_with_config();
1538
1539 let args = CreateArgs {
1540 title: "Claimed with acceptance".to_string(),
1541 description: None,
1542 acceptance: Some("Done when tests pass".to_string()),
1543 notes: None,
1544 design: None,
1545 verify: None,
1546 priority: None,
1547 labels: None,
1548 assignee: None,
1549 deps: None,
1550 parent: None,
1551 produces: None,
1552 requires: None,
1553 paths: None,
1554 on_fail: None,
1555 pass_ok: true,
1556 feature: false,
1557 claim: true,
1558 by: None,
1559 verify_timeout: None,
1560 };
1561
1562 let result = cmd_create(&beans_dir, args);
1563 assert!(result.is_ok(), "Should accept --claim with --acceptance");
1564 }
1565
1566 #[test]
1567 fn create_claim_accepts_with_verify() {
1568 let (_dir, beans_dir) = setup_beans_dir_with_config();
1569
1570 let args = CreateArgs {
1571 title: "Claimed with verify".to_string(),
1572 description: None,
1573 acceptance: None,
1574 notes: None,
1575 design: None,
1576 verify: Some("cargo test".to_string()),
1577 priority: None,
1578 labels: None,
1579 assignee: None,
1580 deps: None,
1581 parent: None,
1582 produces: None,
1583 requires: None,
1584 paths: None,
1585 on_fail: None,
1586 pass_ok: true,
1587 feature: false,
1588 claim: true,
1589 by: None,
1590 verify_timeout: None,
1591 };
1592
1593 let result = cmd_create(&beans_dir, args);
1594 assert!(result.is_ok(), "Should accept --claim with --verify");
1595 }
1596
1597 #[test]
1598 fn create_claim_with_parent_exempt_from_validation() {
1599 let (_dir, beans_dir) = setup_beans_dir_with_config();
1600
1601 let parent_args = CreateArgs {
1603 title: "Parent".to_string(),
1604 description: None,
1605 acceptance: Some("Children done".to_string()),
1606 notes: None,
1607 design: None,
1608 verify: None,
1609 priority: None,
1610 labels: None,
1611 assignee: None,
1612 deps: None,
1613 parent: None,
1614 produces: None,
1615 requires: None,
1616 paths: None,
1617 on_fail: None,
1618 pass_ok: true,
1619 feature: false,
1620 claim: false,
1621 by: None,
1622 verify_timeout: None,
1623 };
1624 cmd_create(&beans_dir, parent_args).unwrap();
1625
1626 let child_args = CreateArgs {
1629 title: "Child no criteria".to_string(),
1630 description: None,
1631 acceptance: None,
1632 notes: None,
1633 design: None,
1634 verify: None,
1635 priority: None,
1636 labels: None,
1637 assignee: None,
1638 deps: None,
1639 parent: Some("1".to_string()),
1640 produces: None,
1641 requires: None,
1642 paths: None,
1643 on_fail: None,
1644 pass_ok: true,
1645 feature: false,
1646 claim: true,
1647 by: Some("agent-1".to_string()),
1648 verify_timeout: None,
1649 };
1650
1651 let result = cmd_create(&beans_dir, child_args);
1652 assert!(
1653 result.is_ok(),
1654 "Should allow --claim --parent without --acceptance or --verify"
1655 );
1656 }
1657
1658 #[test]
1659 fn create_without_claim_exempt_from_validation() {
1660 let (_dir, beans_dir) = setup_beans_dir_with_config();
1661
1662 let args = CreateArgs {
1664 title: "Goal bean no criteria".to_string(),
1665 description: None,
1666 acceptance: None,
1667 notes: None,
1668 design: None,
1669 verify: None,
1670 priority: None,
1671 labels: None,
1672 assignee: None,
1673 deps: None,
1674 parent: None,
1675 produces: None,
1676 requires: None,
1677 paths: None,
1678 on_fail: None,
1679 pass_ok: true,
1680 feature: false,
1681 claim: false,
1682 by: None,
1683 verify_timeout: None,
1684 };
1685
1686 let result = cmd_create(&beans_dir, args);
1687 assert!(
1688 result.is_ok(),
1689 "Should allow create without --claim and without criteria"
1690 );
1691 }
1692
1693 #[test]
1698 fn parse_on_fail_retry_bare() {
1699 let action = parse_on_fail("retry").unwrap();
1700 assert_eq!(
1701 action,
1702 OnFailAction::Retry {
1703 max: None,
1704 delay_secs: None
1705 }
1706 );
1707 }
1708
1709 #[test]
1710 fn parse_on_fail_retry_with_max() {
1711 let action = parse_on_fail("retry:5").unwrap();
1712 assert_eq!(
1713 action,
1714 OnFailAction::Retry {
1715 max: Some(5),
1716 delay_secs: None
1717 }
1718 );
1719 }
1720
1721 #[test]
1722 fn parse_on_fail_escalate_bare() {
1723 let action = parse_on_fail("escalate").unwrap();
1724 assert_eq!(
1725 action,
1726 OnFailAction::Escalate {
1727 priority: None,
1728 message: None
1729 }
1730 );
1731 }
1732
1733 #[test]
1734 fn parse_on_fail_escalate_with_priority_uppercase() {
1735 let action = parse_on_fail("escalate:P0").unwrap();
1736 assert_eq!(
1737 action,
1738 OnFailAction::Escalate {
1739 priority: Some(0),
1740 message: None
1741 }
1742 );
1743 }
1744
1745 #[test]
1746 fn parse_on_fail_escalate_with_priority_lowercase() {
1747 let action = parse_on_fail("escalate:p1").unwrap();
1748 assert_eq!(
1749 action,
1750 OnFailAction::Escalate {
1751 priority: Some(1),
1752 message: None
1753 }
1754 );
1755 }
1756
1757 #[test]
1758 fn parse_on_fail_escalate_with_priority_number() {
1759 let action = parse_on_fail("escalate:3").unwrap();
1760 assert_eq!(
1761 action,
1762 OnFailAction::Escalate {
1763 priority: Some(3),
1764 message: None
1765 }
1766 );
1767 }
1768
1769 #[test]
1770 fn parse_on_fail_rejects_invalid_action() {
1771 let result = parse_on_fail("unknown");
1772 assert!(result.is_err());
1773 assert!(result.unwrap_err().to_string().contains("Unknown on-fail"));
1774 }
1775
1776 #[test]
1777 fn parse_on_fail_rejects_invalid_retry_max() {
1778 let result = parse_on_fail("retry:abc");
1779 assert!(result.is_err());
1780 }
1781
1782 #[test]
1783 fn parse_on_fail_rejects_priority_out_of_range() {
1784 let result = parse_on_fail("escalate:P5");
1785 assert!(result.is_err());
1786 assert!(result.unwrap_err().to_string().contains("priority"));
1787 }
1788
1789 #[test]
1794 fn create_next_depends_on_latest() {
1795 let (_dir, beans_dir) = setup_beans_dir_with_config();
1796
1797 let args1 = CreateArgs {
1799 title: "First step".to_string(),
1800 description: None,
1801 acceptance: None,
1802 notes: None,
1803 design: None,
1804 verify: Some("true".to_string()),
1805 priority: None,
1806 labels: None,
1807 assignee: None,
1808 deps: None,
1809 parent: None,
1810 produces: None,
1811 requires: None,
1812 paths: None,
1813 on_fail: None,
1814 pass_ok: true,
1815 feature: false,
1816 claim: false,
1817 by: None,
1818 verify_timeout: None,
1819 };
1820 let id1 = cmd_create(&beans_dir, args1).unwrap();
1821
1822 let args2 = CreateArgs {
1824 title: "Second step".to_string(),
1825 description: None,
1826 acceptance: None,
1827 notes: None,
1828 design: None,
1829 verify: Some("true".to_string()),
1830 priority: None,
1831 labels: None,
1832 assignee: None,
1833 deps: None,
1834 parent: None,
1835 produces: None,
1836 requires: None,
1837 paths: None,
1838 on_fail: None,
1839 pass_ok: true,
1840 feature: false,
1841 claim: false,
1842 by: None,
1843 verify_timeout: None,
1844 };
1845 let id2 = cmd_create_next(&beans_dir, args2).unwrap();
1846
1847 let bean2_path = beans_dir.join(format!("{}-second-step.md", id2));
1849 let bean2 = Bean::from_file(&bean2_path).unwrap();
1850 assert!(
1851 bean2.dependencies.contains(&id1),
1852 "Second bean should depend on first bean ({}), got deps: {:?}",
1853 id1,
1854 bean2.dependencies
1855 );
1856 }
1857
1858 #[test]
1859 fn create_next_chain_three_beans() {
1860 let (_dir, beans_dir) = setup_beans_dir_with_config();
1861
1862 let args1 = CreateArgs {
1864 title: "Step one".to_string(),
1865 description: None,
1866 acceptance: None,
1867 notes: None,
1868 design: None,
1869 verify: Some("true".to_string()),
1870 priority: None,
1871 labels: None,
1872 assignee: None,
1873 deps: None,
1874 parent: None,
1875 produces: None,
1876 requires: None,
1877 paths: None,
1878 on_fail: None,
1879 pass_ok: true,
1880 feature: false,
1881 claim: false,
1882 by: None,
1883 verify_timeout: None,
1884 };
1885 let id1 = cmd_create(&beans_dir, args1).unwrap();
1886
1887 let args2 = CreateArgs {
1889 title: "Step two".to_string(),
1890 description: None,
1891 acceptance: None,
1892 notes: None,
1893 design: None,
1894 verify: Some("true".to_string()),
1895 priority: None,
1896 labels: None,
1897 assignee: None,
1898 deps: None,
1899 parent: None,
1900 produces: None,
1901 requires: None,
1902 paths: None,
1903 on_fail: None,
1904 pass_ok: true,
1905 feature: false,
1906 claim: false,
1907 by: None,
1908 verify_timeout: None,
1909 };
1910 let id2 = cmd_create_next(&beans_dir, args2).unwrap();
1911
1912 let args3 = CreateArgs {
1914 title: "Step three".to_string(),
1915 description: None,
1916 acceptance: None,
1917 notes: None,
1918 design: None,
1919 verify: Some("true".to_string()),
1920 priority: None,
1921 labels: None,
1922 assignee: None,
1923 deps: None,
1924 parent: None,
1925 produces: None,
1926 requires: None,
1927 paths: None,
1928 on_fail: None,
1929 pass_ok: true,
1930 feature: false,
1931 claim: false,
1932 by: None,
1933 verify_timeout: None,
1934 };
1935 let id3 = cmd_create_next(&beans_dir, args3).unwrap();
1936
1937 let bean2_path = beans_dir.join(format!("{}-step-two.md", id2));
1939 let bean2 = Bean::from_file(&bean2_path).unwrap();
1940 assert!(
1941 bean2.dependencies.contains(&id1),
1942 "Bean 2 should depend on bean 1"
1943 );
1944
1945 let bean3_path = beans_dir.join(format!("{}-step-three.md", id3));
1946 let bean3 = Bean::from_file(&bean3_path).unwrap();
1947 assert!(
1948 bean3.dependencies.contains(&id2),
1949 "Bean 3 should depend on bean 2, got deps: {:?}",
1950 bean3.dependencies
1951 );
1952 }
1953
1954 #[test]
1955 fn create_next_merges_explicit_deps() {
1956 let (_dir, beans_dir) = setup_beans_dir_with_config();
1957
1958 let args1 = CreateArgs {
1960 title: "First".to_string(),
1961 description: None,
1962 acceptance: None,
1963 notes: None,
1964 design: None,
1965 verify: Some("true".to_string()),
1966 priority: None,
1967 labels: None,
1968 assignee: None,
1969 deps: None,
1970 parent: None,
1971 produces: None,
1972 requires: None,
1973 paths: None,
1974 on_fail: None,
1975 pass_ok: true,
1976 feature: false,
1977 claim: false,
1978 by: None,
1979 verify_timeout: None,
1980 };
1981 cmd_create(&beans_dir, args1).unwrap();
1982
1983 let args2 = CreateArgs {
1984 title: "Second".to_string(),
1985 description: None,
1986 acceptance: None,
1987 notes: None,
1988 design: None,
1989 verify: Some("true".to_string()),
1990 priority: None,
1991 labels: None,
1992 assignee: None,
1993 deps: None,
1994 parent: None,
1995 produces: None,
1996 requires: None,
1997 paths: None,
1998 on_fail: None,
1999 pass_ok: true,
2000 feature: false,
2001 claim: false,
2002 by: None,
2003 verify_timeout: None,
2004 };
2005 cmd_create(&beans_dir, args2).unwrap();
2006
2007 let args3 = CreateArgs {
2009 title: "Third".to_string(),
2010 description: None,
2011 acceptance: None,
2012 notes: None,
2013 design: None,
2014 verify: Some("true".to_string()),
2015 priority: None,
2016 labels: None,
2017 assignee: None,
2018 deps: Some("1".to_string()),
2019 parent: None,
2020 produces: None,
2021 requires: None,
2022 paths: None,
2023 on_fail: None,
2024 pass_ok: true,
2025 feature: false,
2026 claim: false,
2027 by: None,
2028 verify_timeout: None,
2029 };
2030 let id3 = cmd_create_next(&beans_dir, args3).unwrap();
2031
2032 let bean3_path = beans_dir.join(format!("{}-third.md", id3));
2033 let bean3 = Bean::from_file(&bean3_path).unwrap();
2034 assert!(
2035 bean3.dependencies.contains(&"1".to_string()),
2036 "Should have explicit dep on 1"
2037 );
2038 assert!(
2039 bean3.dependencies.contains(&"2".to_string()),
2040 "Should have auto dep on @latest (2)"
2041 );
2042 }
2043
2044 #[test]
2045 fn create_next_fails_with_no_beans() {
2046 let (_dir, beans_dir) = setup_beans_dir_with_config();
2047
2048 let args = CreateArgs {
2050 title: "Orphan".to_string(),
2051 description: None,
2052 acceptance: None,
2053 notes: None,
2054 design: None,
2055 verify: Some("true".to_string()),
2056 priority: None,
2057 labels: None,
2058 assignee: None,
2059 deps: None,
2060 parent: None,
2061 produces: None,
2062 requires: None,
2063 paths: None,
2064 on_fail: None,
2065 pass_ok: true,
2066 feature: false,
2067 claim: false,
2068 by: None,
2069 verify_timeout: None,
2070 };
2071 let result = cmd_create_next(&beans_dir, args);
2072 assert!(result.is_err(), "Should fail with no existing beans");
2073 let err_msg = result.unwrap_err().to_string();
2074 assert!(
2075 err_msg.contains("No previous bean"),
2076 "Error should mention no previous bean, got: {}",
2077 err_msg
2078 );
2079 }
2080}