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
15pub 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 pub parent: Option<String>,
28 pub on_fail: Option<OnFailAction>,
30 pub pass_ok: bool,
32 pub verify_timeout: Option<u64>,
34}
35
36pub fn cmd_quick(beans_dir: &Path, args: QuickArgs) -> Result<()> {
41 if let Some(priority) = args.priority {
43 validate_priority(priority)?;
44 }
45
46 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 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 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 let slug = title_to_slug(&args.title);
98
99 let has_verify = args.verify.is_some();
101
102 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 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 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 if let Some(on_fail) = args.on_fail {
151 bean.on_fail = Some(on_fail);
152 }
153
154 if let Some(timeout) = args.verify_timeout {
156 bean.verify_timeout = Some(timeout);
157 }
158
159 let project_dir = beans_dir
161 .parent()
162 .ok_or_else(|| anyhow!("Failed to determine project directory"))?;
163
164 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 let bean_path = beans_dir.join(format!("{}-{}.md", bean_id, slug));
174 bean.to_file(&bean_path)?;
175
176 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 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 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 let bean_path = beans_dir.join("1-quick-task.md");
265 assert!(bean_path.exists());
266
267 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 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 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 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 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()), priority: None,
452 by: None,
453 produces: None,
454 requires: None,
455 parent: None,
456 on_fail: None,
457 pass_ok: false, 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()), priority: None,
478 by: None,
479 produces: None,
480 requires: None,
481 parent: None,
482 on_fail: None,
483 pass_ok: false, verify_timeout: None,
485 };
486
487 let result = cmd_quick(&beans_dir, args);
488 assert!(result.is_ok());
489
490 let bean_path = beans_dir.join("1-real-test.md");
492 assert!(bean_path.exists());
493
494 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()), 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 let bean_path = beans_dir.join("1-passing-verify-ok.md");
524 assert!(bean_path.exists());
525
526 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, 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 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}