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 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
18/// Create arguments structure for organizing all the parameters passed to create.
19pub 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    /// Comma-separated file paths relevant to this bean.
34    pub paths: Option<String>,
35    /// Action on verify failure
36    pub on_fail: Option<OnFailAction>,
37    /// Skip fail-first check (allow verify to already pass)
38    pub pass_ok: bool,
39    /// Claim the bean immediately after creation
40    pub claim: bool,
41    /// Who is claiming (used with claim)
42    pub by: Option<String>,
43    /// Timeout in seconds for the verify command (kills process on expiry).
44    pub verify_timeout: Option<u64>,
45}
46
47/// Assign a child ID for a parent bean.
48/// Scans .beans/ for {parent_id}.{N}-*.md, finds highest N, returns "{parent_id}.{N+1}".
49pub 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        // Look for files matching "{parent_id}.{N}-*.md" (new format)
65        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                    // Extract the number part before the hyphen
69                    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        // Also support legacy format for backward compatibility: {parent_id}.{N}.yaml
80        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
96/// Parse an `--on-fail` CLI string into an `OnFailAction`.
97///
98/// Accepted formats:
99/// - `retry` → Retry { max: None, delay_secs: None }
100/// - `retry:5` → Retry { max: Some(5), delay_secs: None }
101/// - `escalate` → Escalate { priority: None, message: None }
102/// - `escalate:P0` or `escalate:0` → Escalate { priority: Some(0), message: None }
103pub 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
149/// Create a new bean.
150///
151/// If `args.parent` is given, assign a child ID ({parent_id}.{next_child}).
152/// Otherwise, use the next sequential ID from config and increment it.
153/// Returns the created bean ID on success.
154pub fn cmd_create(beans_dir: &Path, args: CreateArgs) -> Result<String> {
155    // Validate priority if provided
156    if let Some(priority) = args.priority {
157        validate_priority(priority)?;
158    }
159
160    // When --claim is used without --parent, require validation criteria
161    // (same as bn quick). Parent/goal beans (no --claim) remain exempt.
162    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    // Fail-first check (default): verify command must FAIL before bean can be created
170    // This prevents "cheating tests" like `assert True` that always pass
171    // Use --pass-ok / -p to skip this check
172    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    // Load config
203    let mut config = Config::load(beans_dir)?;
204
205    // Determine the bean ID
206    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    // Generate slug from title
215    let slug = title_to_slug(&args.title);
216
217    // Track if verify was provided for suggestion later
218    let has_verify = args.verify.is_some();
219
220    // Create the bean
221    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    // Parse labels
254    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    // Parse dependencies
262    if let Some(deps_str) = args.deps {
263        bean.dependencies = deps_str.split(',').map(|s| s.trim().to_string()).collect();
264    }
265
266    // Parse produces
267    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    // Parse requires
275    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    // Parse paths
283    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    // Set on_fail action
292    if let Some(on_fail) = args.on_fail {
293        bean.on_fail = Some(on_fail);
294    }
295
296    // Set verify_timeout if provided
297    if let Some(timeout) = args.verify_timeout {
298        bean.verify_timeout = Some(timeout);
299    }
300
301    // Get the project directory (parent of beans_dir which is .beans)
302    let project_dir = beans_dir
303        .parent()
304        .ok_or_else(|| anyhow!("Failed to determine project directory"))?;
305
306    // Call pre-create hook (blocking - abort if it fails)
307    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    // Calculate and store token count
315    let tokens = calculate_tokens(&bean, project_dir);
316    bean.tokens = Some(tokens);
317    bean.tokens_updated = Some(Utc::now());
318
319    // Write the bean file with new naming convention: {id}-{slug}.md
320    let bean_path = beans_dir.join(format!("{}-{}.md", bean_id, slug));
321    bean.to_file(&bean_path)?;
322
323    // Update the index by rebuilding from disk (includes the bean we just wrote)
324    let index = Index::build(beans_dir)?;
325    index.save(beans_dir)?;
326
327    // Show token size feedback with assessment (stderr — keeps stdout clean for --json)
328    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    // Suggest verify command if none was provided
351    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    // Call post-create hook (non-blocking - log warning if it fails)
361    if let Err(e) = execute_hook(HookEvent::PostCreate, &bean, project_dir, None) {
362        eprintln!("Warning: post-create hook failed: {}", e);
363    }
364
365    // If --claim was passed, claim the bean immediately (skip verify-on-claim check)
366    if args.claim {
367        cmd_claim(beans_dir, &bean_id, args.by, true)?;
368    }
369
370    Ok(bean_id)
371}
372
373/// Create a new bean that automatically depends on @latest (the most recently updated bean).
374///
375/// This enables sequential chaining:
376/// ```bash
377/// bn create "Step 1" -p
378/// bn create next "Step 2" --verify "cargo test step2"
379/// bn create next "Step 3" --verify "cargo test step3"
380/// ```
381///
382/// If `args.deps` already contains dependencies, @latest is prepended.
383/// Returns the created bean ID on success.
384pub fn cmd_create_next(beans_dir: &Path, args: CreateArgs) -> Result<String> {
385    // Resolve @latest — find the most recently updated bean
386    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    // Merge @latest dep with any explicit deps
400    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        // Check the bean file exists with new naming convention
479        let bean_path = beans_dir.join("1-first-task.md");
480        assert!(bean_path.exists());
481
482        // Verify content
483        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        // Create first bean
534        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        // Create second bean
558        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        // Verify both exist with correct IDs and new filenames
582        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        // Create parent bean
593        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        // Create child bean
617        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        // Verify child ID is 1.1 with new filename
641        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        // Create parent
651        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        // Create multiple children
675        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        // Verify all children exist with new naming
701        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        // Load and check index
781        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        // Create some child files with new naming convention
804        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    // =========================================================================
890    // Hook Integration Tests
891    // =========================================================================
892
893    #[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        // Enable trust and create a pre-create hook that succeeds
902        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        // Bean should be created
933        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        // Verify bean was created
940        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        // Enable trust and create a pre-create hook that fails
953        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        // Bean creation should fail
984        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        // Verify bean was NOT created
997        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        // Enable trust and create a post-create hook that writes to a file
1014        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        // Create hook that writes to marker file
1021        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        // Create bean
1053        let result = cmd_create(&beans_dir, args);
1054        assert!(result.is_ok(), "Creation should succeed");
1055
1056        // Verify bean was created
1057        let bean_path = beans_dir.join("1-bean-with-post-create-hook.md");
1058        assert!(bean_path.exists(), "Bean file should exist");
1059
1060        // Verify post-create hook ran (marker file exists)
1061        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        // Enable trust and create a post-create hook that fails
1076        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        // Bean creation should STILL succeed (post-create failures are non-blocking)
1107        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        // Verify bean WAS created
1114        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        // DO NOT enable trust - hooks should be skipped
1129
1130        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        // Bean creation should succeed (untrusted hooks are skipped)
1159        let result = cmd_create(&beans_dir, args);
1160        assert!(
1161            result.is_ok(),
1162            "Creation should succeed when hooks are untrusted"
1163        );
1164
1165        // Verify bean WAS created
1166        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()), // always passes
1184            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, // default: fail-first enforced
1194            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()), // always fails
1216            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, // default: fail-first enforced
1226            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        // Bean should be created
1235        let bean_path = beans_dir.join("1-real-test.md");
1236        assert!(bean_path.exists());
1237
1238        // Should have fail_first set in the bean
1239        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()), // always passes — allowed with --pass-ok
1254            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        // Bean should be created
1273        let bean_path = beans_dir.join("1-passing-verify-ok.md");
1274        assert!(bean_path.exists());
1275
1276        // Should NOT have fail_first set
1277        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, // no verify command — fail-first not applicable
1292            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        // Should NOT have fail_first set (no verify)
1311        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    // =========================================================================
1317    // --claim Flag Tests
1318    // =========================================================================
1319
1320    #[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        // 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            claim: false,
1452            by: None,
1453            verify_timeout: None,
1454        };
1455        cmd_create(&beans_dir, parent_args).unwrap();
1456
1457        // Create child with --claim
1458        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    // =========================================================================
1490    // --claim Validation: require --acceptance or --verify
1491    // =========================================================================
1492
1493    #[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        // Create parent first
1597        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        // Create child with --claim but no acceptance/verify
1621        // Should succeed because child beans with --parent are exempt
1622        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        // Parent/goal beans without --claim don't need acceptance or verify
1656        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    // =========================================================================
1686    // parse_on_fail Tests
1687    // =========================================================================
1688
1689    #[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    // =========================================================================
1782    // cmd_create_next Tests
1783    // =========================================================================
1784
1785    #[test]
1786    fn create_next_depends_on_latest() {
1787        let (_dir, beans_dir) = setup_beans_dir_with_config();
1788
1789        // Create the first bean
1790        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        // Create second bean via create_next
1814        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        // Verify the second bean depends on the first
1838        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        // Create first bean normally
1853        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        // Chain second bean
1877        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        // Chain third bean
1901        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        // Verify chain: 1 <- 2 <- 3
1925        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        // Create two beans normally
1946        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        // Create next with explicit deps — should merge @latest (2) + explicit (1)
1993        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        // Try create next with no existing beans — should fail
2033        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}