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            max_tokens: 30000,
220            run: None,
221            plan: None,
222            max_loops: 10,
223            max_concurrent: 4,
224            poll_interval: 30,
225            extends: vec![],
226            rules_file: None,
227            file_locking: false,
228            on_close: None,
229            on_fail: None,
230            post_plan: None,
231            verify_timeout: None,
232            review: None,
233        };
234        config.save(&beans_dir).unwrap();
235
236        (dir, beans_dir)
237    }
238
239    #[test]
240    fn quick_creates_and_claims_bean() {
241        let (_dir, beans_dir) = setup_beans_dir_with_config();
242
243        let args = QuickArgs {
244            title: "Quick task".to_string(),
245            description: None,
246            acceptance: Some("Done".to_string()),
247            notes: None,
248            verify: None,
249            priority: None,
250            by: Some("agent-1".to_string()),
251            produces: None,
252            requires: None,
253            parent: None,
254            on_fail: None,
255            pass_ok: true,
256            verify_timeout: None,
257        };
258
259        cmd_quick(&beans_dir, args).unwrap();
260
261        // Check the bean file exists
262        let bean_path = beans_dir.join("1-quick-task.md");
263        assert!(bean_path.exists());
264
265        // Verify content
266        let bean = Bean::from_file(&bean_path).unwrap();
267        assert_eq!(bean.id, "1");
268        assert_eq!(bean.title, "Quick task");
269        assert_eq!(bean.status, Status::InProgress);
270        assert_eq!(bean.claimed_by, Some("agent-1".to_string()));
271        assert!(bean.claimed_at.is_some());
272    }
273
274    #[test]
275    fn quick_works_without_by() {
276        let (_dir, beans_dir) = setup_beans_dir_with_config();
277
278        let args = QuickArgs {
279            title: "Anonymous task".to_string(),
280            description: None,
281            acceptance: None,
282            notes: None,
283            verify: Some("cargo test".to_string()),
284            priority: None,
285            by: None,
286            produces: None,
287            requires: None,
288            parent: None,
289            on_fail: None,
290            pass_ok: true,
291            verify_timeout: None,
292        };
293
294        cmd_quick(&beans_dir, args).unwrap();
295
296        let bean_path = beans_dir.join("1-anonymous-task.md");
297        let bean = Bean::from_file(&bean_path).unwrap();
298        assert_eq!(bean.status, Status::InProgress);
299        assert_eq!(bean.claimed_by, None);
300        assert!(bean.claimed_at.is_some());
301    }
302
303    #[test]
304    fn quick_rejects_missing_validation_criteria() {
305        let (_dir, beans_dir) = setup_beans_dir_with_config();
306
307        let args = QuickArgs {
308            title: "No criteria".to_string(),
309            description: None,
310            acceptance: None,
311            notes: None,
312            verify: None,
313            priority: None,
314            by: None,
315            produces: None,
316            requires: None,
317            parent: None,
318            on_fail: None,
319            pass_ok: true,
320            verify_timeout: None,
321        };
322
323        let result = cmd_quick(&beans_dir, args);
324        assert!(result.is_err());
325        let err_msg = result.unwrap_err().to_string();
326        assert!(err_msg.contains("validation criteria"));
327    }
328
329    #[test]
330    fn quick_increments_id() {
331        let (_dir, beans_dir) = setup_beans_dir_with_config();
332
333        // Create first bean
334        let args1 = QuickArgs {
335            title: "First".to_string(),
336            description: None,
337            acceptance: Some("Done".to_string()),
338            notes: None,
339            verify: None,
340            priority: None,
341            by: None,
342            produces: None,
343            requires: None,
344            parent: None,
345            on_fail: None,
346            pass_ok: true,
347            verify_timeout: None,
348        };
349        cmd_quick(&beans_dir, args1).unwrap();
350
351        // Create second bean
352        let args2 = QuickArgs {
353            title: "Second".to_string(),
354            description: None,
355            acceptance: None,
356            notes: None,
357            verify: Some("true".to_string()),
358            priority: None,
359            by: None,
360            produces: None,
361            requires: None,
362            parent: None,
363            on_fail: None,
364            pass_ok: true,
365            verify_timeout: None,
366        };
367        cmd_quick(&beans_dir, args2).unwrap();
368
369        // Verify both exist with correct IDs
370        let bean1 = Bean::from_file(beans_dir.join("1-first.md")).unwrap();
371        let bean2 = Bean::from_file(beans_dir.join("2-second.md")).unwrap();
372        assert_eq!(bean1.id, "1");
373        assert_eq!(bean2.id, "2");
374    }
375
376    #[test]
377    fn quick_updates_index() {
378        let (_dir, beans_dir) = setup_beans_dir_with_config();
379
380        let args = QuickArgs {
381            title: "Indexed bean".to_string(),
382            description: None,
383            acceptance: Some("Indexed correctly".to_string()),
384            notes: None,
385            verify: None,
386            priority: None,
387            by: Some("tester".to_string()),
388            produces: None,
389            requires: None,
390            parent: None,
391            on_fail: None,
392            pass_ok: true,
393            verify_timeout: None,
394        };
395
396        cmd_quick(&beans_dir, args).unwrap();
397
398        // Load and check index
399        let index = Index::load(&beans_dir).unwrap();
400        assert_eq!(index.beans.len(), 1);
401        assert_eq!(index.beans[0].id, "1");
402        assert_eq!(index.beans[0].title, "Indexed bean");
403        assert_eq!(index.beans[0].status, Status::InProgress);
404    }
405
406    #[test]
407    fn quick_with_all_fields() {
408        let (_dir, beans_dir) = setup_beans_dir_with_config();
409
410        let args = QuickArgs {
411            title: "Full bean".to_string(),
412            description: Some("A description".to_string()),
413            acceptance: Some("All tests pass".to_string()),
414            notes: Some("Some notes".to_string()),
415            verify: Some("cargo test".to_string()),
416            priority: Some(1),
417            by: Some("agent-x".to_string()),
418            produces: Some("FooStruct,bar_function".to_string()),
419            requires: Some("BazTrait".to_string()),
420            parent: None,
421            on_fail: None,
422            pass_ok: true,
423            verify_timeout: None,
424        };
425
426        cmd_quick(&beans_dir, args).unwrap();
427
428        let bean = Bean::from_file(beans_dir.join("1-full-bean.md")).unwrap();
429        assert_eq!(bean.title, "Full bean");
430        assert_eq!(bean.description, Some("A description".to_string()));
431        assert_eq!(bean.acceptance, Some("All tests pass".to_string()));
432        assert_eq!(bean.notes, Some("Some notes".to_string()));
433        assert_eq!(bean.verify, Some("cargo test".to_string()));
434        assert_eq!(bean.priority, 1);
435        assert_eq!(bean.status, Status::InProgress);
436        assert_eq!(bean.claimed_by, Some("agent-x".to_string()));
437    }
438
439    #[test]
440    fn default_rejects_passing_verify() {
441        let (_dir, beans_dir) = setup_beans_dir_with_config();
442
443        let args = QuickArgs {
444            title: "Cheating test".to_string(),
445            description: None,
446            acceptance: None,
447            notes: None,
448            verify: Some("true".to_string()), // always passes
449            priority: None,
450            by: None,
451            produces: None,
452            requires: None,
453            parent: None,
454            on_fail: None,
455            pass_ok: false, // default: fail-first enforced
456            verify_timeout: None,
457        };
458
459        let result = cmd_quick(&beans_dir, args);
460        assert!(result.is_err());
461        let err_msg = result.unwrap_err().to_string();
462        assert!(err_msg.contains("verify command already passes"));
463    }
464
465    #[test]
466    fn default_accepts_failing_verify() {
467        let (_dir, beans_dir) = setup_beans_dir_with_config();
468
469        let args = QuickArgs {
470            title: "Real test".to_string(),
471            description: None,
472            acceptance: None,
473            notes: None,
474            verify: Some("false".to_string()), // always fails
475            priority: None,
476            by: None,
477            produces: None,
478            requires: None,
479            parent: None,
480            on_fail: None,
481            pass_ok: false, // default: fail-first enforced
482            verify_timeout: None,
483        };
484
485        let result = cmd_quick(&beans_dir, args);
486        assert!(result.is_ok());
487
488        // Bean should be created
489        let bean_path = beans_dir.join("1-real-test.md");
490        assert!(bean_path.exists());
491
492        // Should have fail_first set in the bean
493        let bean = Bean::from_file(&bean_path).unwrap();
494        assert!(bean.fail_first);
495    }
496
497    #[test]
498    fn pass_ok_skips_fail_first_check() {
499        let (_dir, beans_dir) = setup_beans_dir_with_config();
500
501        let args = QuickArgs {
502            title: "Passing verify ok".to_string(),
503            description: None,
504            acceptance: None,
505            notes: None,
506            verify: Some("true".to_string()), // always passes — allowed with --pass-ok
507            priority: None,
508            by: None,
509            produces: None,
510            requires: None,
511            parent: None,
512            on_fail: None,
513            pass_ok: true,
514            verify_timeout: None,
515        };
516
517        let result = cmd_quick(&beans_dir, args);
518        assert!(result.is_ok());
519
520        // Bean should be created
521        let bean_path = beans_dir.join("1-passing-verify-ok.md");
522        assert!(bean_path.exists());
523
524        // Should NOT have fail_first set
525        let bean = Bean::from_file(&bean_path).unwrap();
526        assert!(!bean.fail_first);
527    }
528
529    #[test]
530    fn no_verify_skips_fail_first_check() {
531        let (_dir, beans_dir) = setup_beans_dir_with_config();
532
533        let args = QuickArgs {
534            title: "No verify".to_string(),
535            description: None,
536            acceptance: Some("Done".to_string()),
537            notes: None,
538            verify: None, // no verify command — fail-first not applicable
539            priority: None,
540            by: None,
541            produces: None,
542            requires: None,
543            parent: None,
544            on_fail: None,
545            pass_ok: false,
546            verify_timeout: None,
547        };
548
549        let result = cmd_quick(&beans_dir, args);
550        assert!(result.is_ok());
551
552        // Should NOT have fail_first set (no verify)
553        let bean_path = beans_dir.join("1-no-verify.md");
554        let bean = Bean::from_file(&bean_path).unwrap();
555        assert!(!bean.fail_first);
556    }
557}