Skip to main content

bn/commands/
create.rs

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
15/// Create arguments structure for organizing all the parameters passed to create.
16pub 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    /// Comma-separated file paths relevant to this bean.
31    pub paths: Option<String>,
32    /// Action on verify failure
33    pub on_fail: Option<OnFailAction>,
34    /// Skip fail-first check (allow verify to already pass)
35    pub pass_ok: bool,
36    /// Claim the bean immediately after creation
37    pub claim: bool,
38    /// Who is claiming (used with claim)
39    pub by: Option<String>,
40    /// Timeout in seconds for the verify command (kills process on expiry).
41    pub verify_timeout: Option<u64>,
42    /// Mark as a product feature (human-only close, no verify gate required).
43    pub feature: bool,
44}
45
46/// Assign a child ID for a parent bean.
47/// Scans .beans/ for {parent_id}.{N}-*.md, finds highest N, returns "{parent_id}.{N+1}".
48pub 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        // Look for files matching "{parent_id}.{N}-*.md" (new format)
64        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                    // Extract the number part before the hyphen
68                    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        // Also support legacy format for backward compatibility: {parent_id}.{N}.yaml
79        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
95/// Parse an `--on-fail` CLI string into an `OnFailAction`.
96///
97/// Accepted formats:
98/// - `retry` → Retry { max: None, delay_secs: None }
99/// - `retry:5` → Retry { max: Some(5), delay_secs: None }
100/// - `escalate` → Escalate { priority: None, message: None }
101/// - `escalate:P0` or `escalate:0` → Escalate { priority: Some(0), message: None }
102pub 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
148/// Create a new bean.
149///
150/// If `args.parent` is given, assign a child ID ({parent_id}.{next_child}).
151/// Otherwise, use the next sequential ID from config and increment it.
152/// Returns the created bean ID on success.
153pub fn cmd_create(beans_dir: &Path, args: CreateArgs) -> Result<String> {
154    // Validate priority if provided
155    if let Some(priority) = args.priority {
156        validate_priority(priority)?;
157    }
158
159    // When --claim is used without --parent, require validation criteria
160    // (same as bn quick). Parent/goal beans (no --claim) remain exempt.
161    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    // Fail-first check (default): verify command must FAIL before bean can be created
169    // This prevents "cheating tests" like `assert True` that always pass
170    // Use --pass-ok / -p to skip this check
171    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    // Load config
202    let mut config = Config::load(beans_dir)?;
203
204    // Determine the bean ID
205    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    // Generate slug from title
214    let slug = title_to_slug(&args.title);
215
216    // Track if verify was provided for suggestion later
217    let has_verify = args.verify.is_some();
218
219    // Create the bean
220    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    // Parse labels
253    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    // Parse dependencies
261    if let Some(deps_str) = args.deps {
262        bean.dependencies = deps_str.split(',').map(|s| s.trim().to_string()).collect();
263    }
264
265    // Parse produces
266    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    // Parse requires
274    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    // Parse paths
282    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    // Set on_fail action
291    if let Some(on_fail) = args.on_fail {
292        bean.on_fail = Some(on_fail);
293    }
294
295    // Set verify_timeout if provided
296    if let Some(timeout) = args.verify_timeout {
297        bean.verify_timeout = Some(timeout);
298    }
299
300    // Get the project directory (parent of beans_dir which is .beans)
301    let project_dir = beans_dir
302        .parent()
303        .ok_or_else(|| anyhow!("Failed to determine project directory"))?;
304
305    // Call pre-create hook (blocking - abort if it fails)
306    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    // Write the bean file with new naming convention: {id}-{slug}.md
314    let bean_path = beans_dir.join(format!("{}-{}.md", bean_id, slug));
315    bean.to_file(&bean_path)?;
316
317    // Update the index by rebuilding from disk (includes the bean we just wrote)
318    let index = Index::build(beans_dir)?;
319    index.save(beans_dir)?;
320
321    eprintln!("Created bean {}: {}", bean_id, args.title);
322
323    // Suggest verify command if none was provided
324    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    // Call post-create hook (non-blocking - log warning if it fails)
334    if let Err(e) = execute_hook(HookEvent::PostCreate, &bean, project_dir, None) {
335        eprintln!("Warning: post-create hook failed: {}", e);
336    }
337
338    // If --claim was passed, claim the bean immediately (skip verify-on-claim check)
339    if args.claim {
340        cmd_claim(beans_dir, &bean_id, args.by, true)?;
341    }
342
343    Ok(bean_id)
344}
345
346/// Create a new bean that automatically depends on @latest (the most recently updated bean).
347///
348/// This enables sequential chaining:
349/// ```bash
350/// bn create "Step 1" -p
351/// bn create next "Step 2" --verify "cargo test step2"
352/// bn create next "Step 3" --verify "cargo test step3"
353/// ```
354///
355/// If `args.deps` already contains dependencies, @latest is prepended.
356/// Returns the created bean ID on success.
357pub fn cmd_create_next(beans_dir: &Path, args: CreateArgs) -> Result<String> {
358    // Resolve @latest — find the most recently updated bean
359    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    // Merge @latest dep with any explicit deps
373    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        // Check the bean file exists with new naming convention
455        let bean_path = beans_dir.join("1-first-task.md");
456        assert!(bean_path.exists());
457
458        // Verify content
459        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        // Create first bean
511        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        // Create second bean
536        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        // Verify both exist with correct IDs and new filenames
561        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        // Create parent bean
572        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        // Create child bean
597        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        // Verify child ID is 1.1 with new filename
622        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        // Create parent
632        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        // Create multiple children
657        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        // Verify all children exist with new naming
684        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        // Load and check index
766        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        // Create some child files with new naming convention
789        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    // =========================================================================
877    // Hook Integration Tests
878    // =========================================================================
879
880    #[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        // Enable trust and create a pre-create hook that succeeds
889        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        // Bean should be created
921        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        // Verify bean was created
928        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        // Enable trust and create a pre-create hook that fails
941        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        // Bean creation should fail
973        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        // Verify bean was NOT created
986        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        // Enable trust and create a post-create hook that writes to a file
1003        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        // Create hook that writes to marker file
1010        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        // Create bean
1043        let result = cmd_create(&beans_dir, args);
1044        assert!(result.is_ok(), "Creation should succeed");
1045
1046        // Verify bean was created
1047        let bean_path = beans_dir.join("1-bean-with-post-create-hook.md");
1048        assert!(bean_path.exists(), "Bean file should exist");
1049
1050        // Verify post-create hook ran (marker file exists)
1051        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        // Enable trust and create a post-create hook that fails
1066        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        // Bean creation should STILL succeed (post-create failures are non-blocking)
1098        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        // Verify bean WAS created
1105        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        // DO NOT enable trust - hooks should be skipped
1120
1121        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        // Bean creation should succeed (untrusted hooks are skipped)
1151        let result = cmd_create(&beans_dir, args);
1152        assert!(
1153            result.is_ok(),
1154            "Creation should succeed when hooks are untrusted"
1155        );
1156
1157        // Verify bean WAS created
1158        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()), // always passes
1176            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, // default: fail-first enforced
1186            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()), // always fails
1209            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, // default: fail-first enforced
1219            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        // Bean should be created
1229        let bean_path = beans_dir.join("1-real-test.md");
1230        assert!(bean_path.exists());
1231
1232        // Should have fail_first set in the bean
1233        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()), // always passes — allowed with --pass-ok
1248            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        // Bean should be created
1268        let bean_path = beans_dir.join("1-passing-verify-ok.md");
1269        assert!(bean_path.exists());
1270
1271        // Should NOT have fail_first set
1272        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, // no verify command — fail-first not applicable
1287            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        // Should NOT have fail_first set (no verify)
1307        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    // =========================================================================
1313    // --claim Flag Tests
1314    // =========================================================================
1315
1316    #[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        // When no --by is given, identity is auto-resolved from config/git.
1389        // claimed_by may be Some(...) or None depending on environment.
1390        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        // Create parent first
1434        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        // Create child with --claim
1459        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    // =========================================================================
1492    // --claim Validation: require --acceptance or --verify
1493    // =========================================================================
1494
1495    #[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        // Create parent first
1602        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        // Create child with --claim but no acceptance/verify
1627        // Should succeed because child beans with --parent are exempt
1628        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        // Parent/goal beans without --claim don't need acceptance or verify
1663        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    // =========================================================================
1694    // parse_on_fail Tests
1695    // =========================================================================
1696
1697    #[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    // =========================================================================
1790    // cmd_create_next Tests
1791    // =========================================================================
1792
1793    #[test]
1794    fn create_next_depends_on_latest() {
1795        let (_dir, beans_dir) = setup_beans_dir_with_config();
1796
1797        // Create the first bean
1798        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        // Create second bean via create_next
1823        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        // Verify the second bean depends on the first
1848        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        // Create first bean normally
1863        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        // Chain second bean
1888        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        // Chain third bean
1913        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        // Verify chain: 1 <- 2 <- 3
1938        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        // Create two beans normally
1959        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        // Create next with explicit deps — should merge @latest (2) + explicit (1)
2008        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        // Try create next with no existing beans — should fail
2049        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}