Skip to main content

bn/commands/
quick.rs

1use std::path::Path;
2use std::process::Command as ShellCommand;
3
4use anyhow::{anyhow, Context, Result};
5use chrono::Utc;
6
7use crate::bean::{validate_priority, Bean, OnFailAction, Status};
8use crate::commands::create::assign_child_id;
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/// Arguments for quick-create command.
16pub struct QuickArgs {
17    pub title: String,
18    pub description: Option<String>,
19    pub acceptance: Option<String>,
20    pub notes: Option<String>,
21    pub verify: Option<String>,
22    pub priority: Option<u8>,
23    pub by: Option<String>,
24    pub produces: Option<String>,
25    pub requires: Option<String>,
26    /// Parent bean ID (creates child bean under parent)
27    pub parent: Option<String>,
28    /// Action on verify failure
29    pub on_fail: Option<OnFailAction>,
30    /// Skip fail-first check (allow verify to already pass)
31    pub pass_ok: bool,
32    /// Timeout in seconds for the verify command (kills process on expiry).
33    pub verify_timeout: Option<u64>,
34}
35
36/// Quick-create: create a bean and immediately claim it.
37///
38/// This is a convenience command that combines `bn create` + `bn claim`
39/// in a single operation. Useful for agents starting immediate work.
40pub fn cmd_quick(beans_dir: &Path, args: QuickArgs) -> Result<()> {
41    // Validate priority if provided
42    if let Some(priority) = args.priority {
43        validate_priority(priority)?;
44    }
45
46    // Require at least acceptance or verify criteria
47    if args.acceptance.is_none() && args.verify.is_none() {
48        anyhow::bail!(
49            "Bean must have validation criteria: provide --acceptance or --verify (or both)"
50        );
51    }
52
53    // Fail-first check (default): verify command must FAIL before bean can be created
54    // This prevents "cheating tests" like `assert True` that always pass
55    // Use --pass-ok / -p to skip this check
56    if !args.pass_ok {
57        if let Some(verify_cmd) = args.verify.as_ref() {
58            let project_root = beans_dir
59                .parent()
60                .ok_or_else(|| anyhow!("Cannot determine project root"))?;
61
62            println!("Running verify (must fail): {}", verify_cmd);
63
64            let status = ShellCommand::new("sh")
65                .args(["-c", verify_cmd])
66                .current_dir(project_root)
67                .status()
68                .with_context(|| format!("Failed to execute verify command: {}", verify_cmd))?;
69
70            if status.success() {
71                anyhow::bail!(
72                    "Cannot create bean: verify command already passes!\n\n\
73                     The test must FAIL on current code to prove it tests something real.\n\
74                     Either:\n\
75                     - The test doesn't actually test the new behavior\n\
76                     - The feature is already implemented\n\
77                     - The test is a no-op (assert True)\n\n\
78                     Use --pass-ok / -p to skip this check."
79                );
80            }
81
82            println!("✓ Verify failed as expected - test is real");
83        }
84    }
85
86    // Load config and assign ID (child ID from parent, or next global ID)
87    let bean_id = if let Some(ref parent_id) = args.parent {
88        assign_child_id(beans_dir, parent_id)?
89    } else {
90        let mut config = Config::load(beans_dir)?;
91        let id = config.increment_id().to_string();
92        config.save(beans_dir)?;
93        id
94    };
95
96    // Generate slug from title
97    let slug = title_to_slug(&args.title);
98
99    // Track if verify was provided for suggestion later
100    let has_verify = args.verify.is_some();
101
102    // Create the bean with InProgress status (already claimed)
103    let now = Utc::now();
104    let mut bean = Bean::new(&bean_id, &args.title);
105    bean.slug = Some(slug.clone());
106    bean.status = Status::InProgress;
107    bean.claimed_by = args.by.clone();
108    bean.claimed_at = Some(now);
109
110    if let Some(desc) = args.description {
111        bean.description = Some(desc);
112    }
113    if let Some(acceptance) = args.acceptance {
114        bean.acceptance = Some(acceptance);
115    }
116    if let Some(notes) = args.notes {
117        bean.notes = Some(notes);
118    }
119    let has_fail_first = !args.pass_ok && args.verify.is_some();
120    if let Some(verify) = args.verify {
121        bean.verify = Some(verify);
122    }
123    if has_fail_first {
124        bean.fail_first = true;
125    }
126    if let Some(priority) = args.priority {
127        bean.priority = priority;
128    }
129    if let Some(parent) = args.parent {
130        bean.parent = Some(parent);
131    }
132
133    // Parse produces
134    if let Some(produces_str) = args.produces {
135        bean.produces = produces_str
136            .split(',')
137            .map(|s| s.trim().to_string())
138            .collect();
139    }
140
141    // Parse requires
142    if let Some(requires_str) = args.requires {
143        bean.requires = requires_str
144            .split(',')
145            .map(|s| s.trim().to_string())
146            .collect();
147    }
148
149    // Set on_fail action
150    if let Some(on_fail) = args.on_fail {
151        bean.on_fail = Some(on_fail);
152    }
153
154    // Set verify_timeout if provided
155    if let Some(timeout) = args.verify_timeout {
156        bean.verify_timeout = Some(timeout);
157    }
158
159    // Get the project directory (parent of beans_dir which is .beans)
160    let project_dir = beans_dir
161        .parent()
162        .ok_or_else(|| anyhow!("Failed to determine project directory"))?;
163
164    // Call pre-create hook (blocking - abort if it fails)
165    let pre_passed = execute_hook(HookEvent::PreCreate, &bean, project_dir, None)
166        .context("Pre-create hook execution failed")?;
167
168    if !pre_passed {
169        return Err(anyhow!("Pre-create hook rejected bean creation"));
170    }
171
172    // Write the bean file with naming convention: {id}-{slug}.md
173    let bean_path = beans_dir.join(format!("{}-{}.md", bean_id, slug));
174    bean.to_file(&bean_path)?;
175
176    // Update the index by rebuilding from disk
177    let index = Index::build(beans_dir)?;
178    index.save(beans_dir)?;
179
180    let claimer = args.by.as_deref().unwrap_or("anonymous");
181    println!(
182        "Created and claimed bean {}: {} (by {})",
183        bean_id, args.title, claimer
184    );
185
186    // Suggest verify command if none was provided
187    if !has_verify {
188        if let Some(suggested) = suggest_verify_command(project_dir) {
189            println!(
190                "Tip: Consider adding a verify command: --verify \"{}\"",
191                suggested
192            );
193        }
194    }
195
196    // Call post-create hook (non-blocking - log warning if it fails)
197    if let Err(e) = execute_hook(HookEvent::PostCreate, &bean, project_dir, None) {
198        eprintln!("Warning: post-create hook failed: {}", e);
199    }
200
201    Ok(())
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use std::fs;
208    use tempfile::TempDir;
209
210    fn setup_beans_dir_with_config() -> (TempDir, std::path::PathBuf) {
211        let dir = TempDir::new().unwrap();
212        let beans_dir = dir.path().join(".beans");
213        fs::create_dir(&beans_dir).unwrap();
214
215        let config = Config {
216            project: "test".to_string(),
217            next_id: 1,
218            auto_close_parent: true,
219            run: None,
220            plan: None,
221            max_loops: 10,
222            max_concurrent: 4,
223            poll_interval: 30,
224            extends: vec![],
225            rules_file: None,
226            file_locking: false,
227            worktree: false,
228            on_close: None,
229            on_fail: None,
230            post_plan: None,
231            verify_timeout: None,
232            review: None,
233            user: None,
234            user_email: None,
235        };
236        config.save(&beans_dir).unwrap();
237
238        (dir, beans_dir)
239    }
240
241    #[test]
242    fn quick_creates_and_claims_bean() {
243        let (_dir, beans_dir) = setup_beans_dir_with_config();
244
245        let args = QuickArgs {
246            title: "Quick task".to_string(),
247            description: None,
248            acceptance: Some("Done".to_string()),
249            notes: None,
250            verify: None,
251            priority: None,
252            by: Some("agent-1".to_string()),
253            produces: None,
254            requires: None,
255            parent: None,
256            on_fail: None,
257            pass_ok: true,
258            verify_timeout: None,
259        };
260
261        cmd_quick(&beans_dir, args).unwrap();
262
263        // Check the bean file exists
264        let bean_path = beans_dir.join("1-quick-task.md");
265        assert!(bean_path.exists());
266
267        // Verify content
268        let bean = Bean::from_file(&bean_path).unwrap();
269        assert_eq!(bean.id, "1");
270        assert_eq!(bean.title, "Quick task");
271        assert_eq!(bean.status, Status::InProgress);
272        assert_eq!(bean.claimed_by, Some("agent-1".to_string()));
273        assert!(bean.claimed_at.is_some());
274    }
275
276    #[test]
277    fn quick_works_without_by() {
278        let (_dir, beans_dir) = setup_beans_dir_with_config();
279
280        let args = QuickArgs {
281            title: "Anonymous task".to_string(),
282            description: None,
283            acceptance: None,
284            notes: None,
285            verify: Some("cargo test".to_string()),
286            priority: None,
287            by: None,
288            produces: None,
289            requires: None,
290            parent: None,
291            on_fail: None,
292            pass_ok: true,
293            verify_timeout: None,
294        };
295
296        cmd_quick(&beans_dir, args).unwrap();
297
298        let bean_path = beans_dir.join("1-anonymous-task.md");
299        let bean = Bean::from_file(&bean_path).unwrap();
300        assert_eq!(bean.status, Status::InProgress);
301        assert_eq!(bean.claimed_by, None);
302        assert!(bean.claimed_at.is_some());
303    }
304
305    #[test]
306    fn quick_rejects_missing_validation_criteria() {
307        let (_dir, beans_dir) = setup_beans_dir_with_config();
308
309        let args = QuickArgs {
310            title: "No criteria".to_string(),
311            description: None,
312            acceptance: None,
313            notes: None,
314            verify: None,
315            priority: None,
316            by: None,
317            produces: None,
318            requires: None,
319            parent: None,
320            on_fail: None,
321            pass_ok: true,
322            verify_timeout: None,
323        };
324
325        let result = cmd_quick(&beans_dir, args);
326        assert!(result.is_err());
327        let err_msg = result.unwrap_err().to_string();
328        assert!(err_msg.contains("validation criteria"));
329    }
330
331    #[test]
332    fn quick_increments_id() {
333        let (_dir, beans_dir) = setup_beans_dir_with_config();
334
335        // Create first bean
336        let args1 = QuickArgs {
337            title: "First".to_string(),
338            description: None,
339            acceptance: Some("Done".to_string()),
340            notes: None,
341            verify: None,
342            priority: None,
343            by: None,
344            produces: None,
345            requires: None,
346            parent: None,
347            on_fail: None,
348            pass_ok: true,
349            verify_timeout: None,
350        };
351        cmd_quick(&beans_dir, args1).unwrap();
352
353        // Create second bean
354        let args2 = QuickArgs {
355            title: "Second".to_string(),
356            description: None,
357            acceptance: None,
358            notes: None,
359            verify: Some("true".to_string()),
360            priority: None,
361            by: None,
362            produces: None,
363            requires: None,
364            parent: None,
365            on_fail: None,
366            pass_ok: true,
367            verify_timeout: None,
368        };
369        cmd_quick(&beans_dir, args2).unwrap();
370
371        // Verify both exist with correct IDs
372        let bean1 = Bean::from_file(beans_dir.join("1-first.md")).unwrap();
373        let bean2 = Bean::from_file(beans_dir.join("2-second.md")).unwrap();
374        assert_eq!(bean1.id, "1");
375        assert_eq!(bean2.id, "2");
376    }
377
378    #[test]
379    fn quick_updates_index() {
380        let (_dir, beans_dir) = setup_beans_dir_with_config();
381
382        let args = QuickArgs {
383            title: "Indexed bean".to_string(),
384            description: None,
385            acceptance: Some("Indexed correctly".to_string()),
386            notes: None,
387            verify: None,
388            priority: None,
389            by: Some("tester".to_string()),
390            produces: None,
391            requires: None,
392            parent: None,
393            on_fail: None,
394            pass_ok: true,
395            verify_timeout: None,
396        };
397
398        cmd_quick(&beans_dir, args).unwrap();
399
400        // Load and check index
401        let index = Index::load(&beans_dir).unwrap();
402        assert_eq!(index.beans.len(), 1);
403        assert_eq!(index.beans[0].id, "1");
404        assert_eq!(index.beans[0].title, "Indexed bean");
405        assert_eq!(index.beans[0].status, Status::InProgress);
406    }
407
408    #[test]
409    fn quick_with_all_fields() {
410        let (_dir, beans_dir) = setup_beans_dir_with_config();
411
412        let args = QuickArgs {
413            title: "Full bean".to_string(),
414            description: Some("A description".to_string()),
415            acceptance: Some("All tests pass".to_string()),
416            notes: Some("Some notes".to_string()),
417            verify: Some("cargo test".to_string()),
418            priority: Some(1),
419            by: Some("agent-x".to_string()),
420            produces: Some("FooStruct,bar_function".to_string()),
421            requires: Some("BazTrait".to_string()),
422            parent: None,
423            on_fail: None,
424            pass_ok: true,
425            verify_timeout: None,
426        };
427
428        cmd_quick(&beans_dir, args).unwrap();
429
430        let bean = Bean::from_file(beans_dir.join("1-full-bean.md")).unwrap();
431        assert_eq!(bean.title, "Full bean");
432        assert_eq!(bean.description, Some("A description".to_string()));
433        assert_eq!(bean.acceptance, Some("All tests pass".to_string()));
434        assert_eq!(bean.notes, Some("Some notes".to_string()));
435        assert_eq!(bean.verify, Some("cargo test".to_string()));
436        assert_eq!(bean.priority, 1);
437        assert_eq!(bean.status, Status::InProgress);
438        assert_eq!(bean.claimed_by, Some("agent-x".to_string()));
439    }
440
441    #[test]
442    fn default_rejects_passing_verify() {
443        let (_dir, beans_dir) = setup_beans_dir_with_config();
444
445        let args = QuickArgs {
446            title: "Cheating test".to_string(),
447            description: None,
448            acceptance: None,
449            notes: None,
450            verify: Some("true".to_string()), // always passes
451            priority: None,
452            by: None,
453            produces: None,
454            requires: None,
455            parent: None,
456            on_fail: None,
457            pass_ok: false, // default: fail-first enforced
458            verify_timeout: None,
459        };
460
461        let result = cmd_quick(&beans_dir, args);
462        assert!(result.is_err());
463        let err_msg = result.unwrap_err().to_string();
464        assert!(err_msg.contains("verify command already passes"));
465    }
466
467    #[test]
468    fn default_accepts_failing_verify() {
469        let (_dir, beans_dir) = setup_beans_dir_with_config();
470
471        let args = QuickArgs {
472            title: "Real test".to_string(),
473            description: None,
474            acceptance: None,
475            notes: None,
476            verify: Some("false".to_string()), // always fails
477            priority: None,
478            by: None,
479            produces: None,
480            requires: None,
481            parent: None,
482            on_fail: None,
483            pass_ok: false, // default: fail-first enforced
484            verify_timeout: None,
485        };
486
487        let result = cmd_quick(&beans_dir, args);
488        assert!(result.is_ok());
489
490        // Bean should be created
491        let bean_path = beans_dir.join("1-real-test.md");
492        assert!(bean_path.exists());
493
494        // Should have fail_first set in the bean
495        let bean = Bean::from_file(&bean_path).unwrap();
496        assert!(bean.fail_first);
497    }
498
499    #[test]
500    fn pass_ok_skips_fail_first_check() {
501        let (_dir, beans_dir) = setup_beans_dir_with_config();
502
503        let args = QuickArgs {
504            title: "Passing verify ok".to_string(),
505            description: None,
506            acceptance: None,
507            notes: None,
508            verify: Some("true".to_string()), // always passes — allowed with --pass-ok
509            priority: None,
510            by: None,
511            produces: None,
512            requires: None,
513            parent: None,
514            on_fail: None,
515            pass_ok: true,
516            verify_timeout: None,
517        };
518
519        let result = cmd_quick(&beans_dir, args);
520        assert!(result.is_ok());
521
522        // Bean should be created
523        let bean_path = beans_dir.join("1-passing-verify-ok.md");
524        assert!(bean_path.exists());
525
526        // Should NOT have fail_first set
527        let bean = Bean::from_file(&bean_path).unwrap();
528        assert!(!bean.fail_first);
529    }
530
531    #[test]
532    fn no_verify_skips_fail_first_check() {
533        let (_dir, beans_dir) = setup_beans_dir_with_config();
534
535        let args = QuickArgs {
536            title: "No verify".to_string(),
537            description: None,
538            acceptance: Some("Done".to_string()),
539            notes: None,
540            verify: None, // no verify command — fail-first not applicable
541            priority: None,
542            by: None,
543            produces: None,
544            requires: None,
545            parent: None,
546            on_fail: None,
547            pass_ok: false,
548            verify_timeout: None,
549        };
550
551        let result = cmd_quick(&beans_dir, args);
552        assert!(result.is_ok());
553
554        // Should NOT have fail_first set (no verify)
555        let bean_path = beans_dir.join("1-no-verify.md");
556        let bean = Bean::from_file(&bean_path).unwrap();
557        assert!(!bean.fail_first);
558    }
559}