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