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 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 let bean_path = beans_dir.join("1-quick-task.md");
263 assert!(bean_path.exists());
264
265 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 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 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 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 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()), priority: None,
450 by: None,
451 produces: None,
452 requires: None,
453 parent: None,
454 on_fail: None,
455 pass_ok: false, 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()), priority: None,
476 by: None,
477 produces: None,
478 requires: None,
479 parent: None,
480 on_fail: None,
481 pass_ok: false, verify_timeout: None,
483 };
484
485 let result = cmd_quick(&beans_dir, args);
486 assert!(result.is_ok());
487
488 let bean_path = beans_dir.join("1-real-test.md");
490 assert!(bean_path.exists());
491
492 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()), 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 let bean_path = beans_dir.join("1-passing-verify-ok.md");
522 assert!(bean_path.exists());
523
524 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, 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 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}