1use std::path::Path;
2use std::process::Command as ShellCommand;
3
4use anyhow::{anyhow, Context, Result};
5use chrono::Utc;
6
7use crate::commands::create::{assign_child_id, lint_verify_command};
8use crate::config::Config;
9use crate::hooks::{execute_hook, HookEvent};
10use crate::index::Index;
11use crate::project::suggest_verify_command;
12use crate::unit::{validate_priority, OnFailAction, Status, Unit};
13use crate::util::{find_similar_titles, title_to_slug, DEFAULT_SIMILARITY_THRESHOLD};
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 pub force: bool,
36}
37
38pub fn cmd_quick(mana_dir: &Path, args: QuickArgs) -> Result<()> {
43 if let Some(priority) = args.priority {
45 validate_priority(priority)?;
46 }
47
48 if args.acceptance.is_none() && args.verify.is_none() {
50 anyhow::bail!(
51 "Unit must have validation criteria: provide --acceptance or --verify (or both)"
52 );
53 }
54
55 lint_verify_command(args.verify.as_deref(), args.force)?;
56
57 if !args.pass_ok {
61 if let Some(verify_cmd) = args.verify.as_ref() {
62 let project_root = mana_dir
63 .parent()
64 .ok_or_else(|| anyhow!("Cannot determine project root"))?;
65
66 println!("Running verify (must fail): {}", verify_cmd);
67
68 let status = ShellCommand::new("sh")
69 .args(["-c", verify_cmd])
70 .current_dir(project_root)
71 .status()
72 .with_context(|| format!("Failed to execute verify command: {}", verify_cmd))?;
73
74 if status.success() {
75 anyhow::bail!(
76 "Cannot create unit: verify command already passes!\n\n\
77 The test must FAIL on current code to prove it tests something real.\n\
78 Either:\n\
79 - The test doesn't actually test the new behavior\n\
80 - The feature is already implemented\n\
81 - The test is a no-op (assert True)\n\n\
82 Use --pass-ok / -p to skip this check."
83 );
84 }
85
86 println!("✓ Verify failed as expected - test is real");
87 }
88 }
89
90 if !args.force {
92 if let Ok(index) = Index::load_or_rebuild(mana_dir) {
93 let similar = find_similar_titles(&index, &args.title, DEFAULT_SIMILARITY_THRESHOLD);
94 if !similar.is_empty() {
95 let mut msg = String::from("Similar unit(s) already exist:\n");
96 for s in &similar {
97 msg.push_str(&format!(
98 " [{}] {} (similarity: {:.0}%)\n",
99 s.id,
100 s.title,
101 s.score * 100.0
102 ));
103 }
104 msg.push_str("\nUse --force to create anyway.");
105 anyhow::bail!(msg);
106 }
107 }
108 }
109
110 let unit_id = if let Some(ref parent_id) = args.parent {
112 assign_child_id(mana_dir, parent_id)?
113 } else {
114 let mut config = Config::load(mana_dir)?;
115 let id = config.increment_id().to_string();
116 config.save(mana_dir)?;
117 id
118 };
119
120 let slug = title_to_slug(&args.title);
122
123 let has_verify = args.verify.is_some();
125
126 let now = Utc::now();
128 let mut unit = Unit::new(&unit_id, &args.title);
129 unit.slug = Some(slug.clone());
130 unit.status = Status::InProgress;
131 unit.claimed_by = args.by.clone();
132 unit.claimed_at = Some(now);
133
134 if let Some(desc) = args.description {
135 unit.description = Some(desc);
136 }
137 if let Some(acceptance) = args.acceptance {
138 unit.acceptance = Some(acceptance);
139 }
140 if let Some(notes) = args.notes {
141 unit.notes = Some(notes);
142 }
143 let has_fail_first = !args.pass_ok && args.verify.is_some();
144 if let Some(verify) = args.verify {
145 unit.verify = Some(verify);
146 }
147 if has_fail_first {
148 unit.fail_first = true;
149 }
150 if let Some(priority) = args.priority {
151 unit.priority = priority;
152 }
153 if let Some(parent) = args.parent {
154 unit.parent = Some(parent);
155 }
156
157 if let Some(produces_str) = args.produces {
159 unit.produces = produces_str
160 .split(',')
161 .map(|s| s.trim().to_string())
162 .collect();
163 }
164
165 if let Some(requires_str) = args.requires {
167 unit.requires = requires_str
168 .split(',')
169 .map(|s| s.trim().to_string())
170 .collect();
171 }
172
173 if let Some(on_fail) = args.on_fail {
175 unit.on_fail = Some(on_fail);
176 }
177
178 if let Some(timeout) = args.verify_timeout {
180 unit.verify_timeout = Some(timeout);
181 }
182
183 let project_dir = mana_dir
185 .parent()
186 .ok_or_else(|| anyhow!("Failed to determine project directory"))?;
187
188 let pre_passed = execute_hook(HookEvent::PreCreate, &unit, project_dir, None)
190 .context("Pre-create hook execution failed")?;
191
192 if !pre_passed {
193 return Err(anyhow!("Pre-create hook rejected unit creation"));
194 }
195
196 let unit_path = mana_dir.join(format!("{}-{}.md", unit_id, slug));
198 unit.to_file(&unit_path)?;
199
200 let index = Index::build(mana_dir)?;
202 index.save(mana_dir)?;
203
204 let claimer = args.by.as_deref().unwrap_or("anonymous");
205 println!(
206 "Created and claimed unit {}: {} (by {})",
207 unit_id, args.title, claimer
208 );
209
210 if !has_verify {
212 if let Some(suggested) = suggest_verify_command(project_dir) {
213 println!(
214 "Tip: Consider adding a verify command: --verify \"{}\"",
215 suggested
216 );
217 }
218 }
219
220 if let Err(e) = execute_hook(HookEvent::PostCreate, &unit, project_dir, None) {
222 eprintln!("Warning: post-create hook failed: {}", e);
223 }
224
225 Ok(())
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use std::fs;
232 use tempfile::TempDir;
233
234 fn setup_mana_dir_with_config() -> (TempDir, std::path::PathBuf) {
235 let dir = TempDir::new().unwrap();
236 let mana_dir = dir.path().join(".mana");
237 fs::create_dir(&mana_dir).unwrap();
238
239 let config = Config {
240 project: "test".to_string(),
241 next_id: 1,
242 auto_close_parent: true,
243 run: None,
244 plan: None,
245 max_loops: 10,
246 max_concurrent: 4,
247 poll_interval: 30,
248 extends: vec![],
249 rules_file: None,
250 file_locking: false,
251 worktree: false,
252 on_close: None,
253 on_fail: None,
254 post_plan: None,
255 verify_timeout: None,
256 review: None,
257 user: None,
258 user_email: None,
259 auto_commit: false,
260 commit_template: None,
261 research: None,
262 run_model: None,
263 plan_model: None,
264 review_model: None,
265 research_model: None,
266 batch_verify: false,
267 memory_reserve_mb: 0,
268 notify: None,
269 };
270 config.save(&mana_dir).unwrap();
271
272 (dir, mana_dir)
273 }
274
275 #[test]
276 fn quick_creates_and_claims_unit() {
277 let (_dir, mana_dir) = setup_mana_dir_with_config();
278
279 let args = QuickArgs {
280 title: "Quick task".to_string(),
281 description: None,
282 acceptance: Some("Done".to_string()),
283 notes: None,
284 verify: None,
285 priority: None,
286 by: Some("agent-1".to_string()),
287 produces: None,
288 requires: None,
289 parent: None,
290 on_fail: None,
291 pass_ok: true,
292 verify_timeout: None,
293 force: false,
294 };
295
296 cmd_quick(&mana_dir, args).unwrap();
297
298 let unit_path = mana_dir.join("1-quick-task.md");
300 assert!(unit_path.exists());
301
302 let unit = Unit::from_file(&unit_path).unwrap();
304 assert_eq!(unit.id, "1");
305 assert_eq!(unit.title, "Quick task");
306 assert_eq!(unit.status, Status::InProgress);
307 assert_eq!(unit.claimed_by, Some("agent-1".to_string()));
308 assert!(unit.claimed_at.is_some());
309 }
310
311 #[test]
312 fn quick_works_without_by() {
313 let (_dir, mana_dir) = setup_mana_dir_with_config();
314
315 let args = QuickArgs {
316 title: "Anonymous task".to_string(),
317 description: None,
318 acceptance: None,
319 notes: None,
320 verify: Some("cargo test".to_string()),
321 priority: None,
322 by: None,
323 produces: None,
324 requires: None,
325 parent: None,
326 on_fail: None,
327 pass_ok: true,
328 verify_timeout: None,
329 force: false,
330 };
331
332 cmd_quick(&mana_dir, args).unwrap();
333
334 let unit_path = mana_dir.join("1-anonymous-task.md");
335 let unit = Unit::from_file(&unit_path).unwrap();
336 assert_eq!(unit.status, Status::InProgress);
337 assert_eq!(unit.claimed_by, None);
338 assert!(unit.claimed_at.is_some());
339 }
340
341 #[test]
342 fn quick_rejects_missing_validation_criteria() {
343 let (_dir, mana_dir) = setup_mana_dir_with_config();
344
345 let args = QuickArgs {
346 title: "No criteria".to_string(),
347 description: None,
348 acceptance: None,
349 notes: None,
350 verify: None,
351 priority: None,
352 by: None,
353 produces: None,
354 requires: None,
355 parent: None,
356 on_fail: None,
357 pass_ok: true,
358 verify_timeout: None,
359 force: false,
360 };
361
362 let result = cmd_quick(&mana_dir, args);
363 assert!(result.is_err());
364 let err_msg = result.unwrap_err().to_string();
365 assert!(err_msg.contains("validation criteria"));
366 }
367
368 #[test]
369 fn quick_increments_id() {
370 let (_dir, mana_dir) = setup_mana_dir_with_config();
371
372 let args1 = QuickArgs {
374 title: "First".to_string(),
375 description: None,
376 acceptance: Some("Done".to_string()),
377 notes: None,
378 verify: None,
379 priority: None,
380 by: None,
381 produces: None,
382 requires: None,
383 parent: None,
384 on_fail: None,
385 pass_ok: true,
386 verify_timeout: None,
387 force: false,
388 };
389 cmd_quick(&mana_dir, args1).unwrap();
390
391 let args2 = QuickArgs {
393 title: "Second".to_string(),
394 description: None,
395 acceptance: None,
396 notes: None,
397 verify: Some("false".to_string()),
398 priority: None,
399 by: None,
400 produces: None,
401 requires: None,
402 parent: None,
403 on_fail: None,
404 pass_ok: true,
405 verify_timeout: None,
406 force: false,
407 };
408 cmd_quick(&mana_dir, args2).unwrap();
409
410 let unit1 = Unit::from_file(mana_dir.join("1-first.md")).unwrap();
412 let unit2 = Unit::from_file(mana_dir.join("2-second.md")).unwrap();
413 assert_eq!(unit1.id, "1");
414 assert_eq!(unit2.id, "2");
415 }
416
417 #[test]
418 fn quick_updates_index() {
419 let (_dir, mana_dir) = setup_mana_dir_with_config();
420
421 let args = QuickArgs {
422 title: "Indexed unit".to_string(),
423 description: None,
424 acceptance: Some("Indexed correctly".to_string()),
425 notes: None,
426 verify: None,
427 priority: None,
428 by: Some("tester".to_string()),
429 produces: None,
430 requires: None,
431 parent: None,
432 on_fail: None,
433 pass_ok: true,
434 verify_timeout: None,
435 force: false,
436 };
437
438 cmd_quick(&mana_dir, args).unwrap();
439
440 let index = Index::load(&mana_dir).unwrap();
442 assert_eq!(index.units.len(), 1);
443 assert_eq!(index.units[0].id, "1");
444 assert_eq!(index.units[0].title, "Indexed unit");
445 assert_eq!(index.units[0].status, Status::InProgress);
446 }
447
448 #[test]
449 fn quick_with_all_fields() {
450 let (_dir, mana_dir) = setup_mana_dir_with_config();
451
452 let args = QuickArgs {
453 title: "Full unit".to_string(),
454 description: Some("A description".to_string()),
455 acceptance: Some("All tests pass".to_string()),
456 notes: Some("Some notes".to_string()),
457 verify: Some("cargo test".to_string()),
458 priority: Some(1),
459 by: Some("agent-x".to_string()),
460 produces: Some("FooStruct,bar_function".to_string()),
461 requires: Some("BazTrait".to_string()),
462 parent: None,
463 on_fail: None,
464 pass_ok: true,
465 verify_timeout: None,
466 force: false,
467 };
468
469 cmd_quick(&mana_dir, args).unwrap();
470
471 let unit = Unit::from_file(mana_dir.join("1-full-unit.md")).unwrap();
472 assert_eq!(unit.title, "Full unit");
473 assert_eq!(unit.description, Some("A description".to_string()));
474 assert_eq!(unit.acceptance, Some("All tests pass".to_string()));
475 assert_eq!(unit.notes, Some("Some notes".to_string()));
476 assert_eq!(unit.verify, Some("cargo test".to_string()));
477 assert_eq!(unit.priority, 1);
478 assert_eq!(unit.status, Status::InProgress);
479 assert_eq!(unit.claimed_by, Some("agent-x".to_string()));
480 }
481
482 #[test]
483 fn default_rejects_passing_verify() {
484 let (_dir, mana_dir) = setup_mana_dir_with_config();
485
486 let args = QuickArgs {
487 title: "Cheating test".to_string(),
488 description: None,
489 acceptance: None,
490 notes: None,
491 verify: Some("grep -q 'project: test' .mana/config.yaml".to_string()),
492 priority: None,
493 by: None,
494 produces: None,
495 requires: None,
496 parent: None,
497 on_fail: None,
498 pass_ok: false, verify_timeout: None,
500 force: false,
501 };
502
503 let result = cmd_quick(&mana_dir, args);
504 assert!(result.is_err());
505 let err_msg = result.unwrap_err().to_string();
506 assert!(err_msg.contains("verify command already passes"));
507 }
508
509 #[test]
510 fn default_accepts_failing_verify() {
511 let (_dir, mana_dir) = setup_mana_dir_with_config();
512
513 let args = QuickArgs {
514 title: "Real test".to_string(),
515 description: None,
516 acceptance: None,
517 notes: None,
518 verify: Some("false".to_string()), priority: None,
520 by: None,
521 produces: None,
522 requires: None,
523 parent: None,
524 on_fail: None,
525 pass_ok: false, verify_timeout: None,
527 force: false,
528 };
529
530 let result = cmd_quick(&mana_dir, args);
531 assert!(result.is_ok());
532
533 let unit_path = mana_dir.join("1-real-test.md");
535 assert!(unit_path.exists());
536
537 let unit = Unit::from_file(&unit_path).unwrap();
539 assert!(unit.fail_first);
540 }
541
542 #[test]
543 fn pass_ok_skips_fail_first_check() {
544 let (_dir, mana_dir) = setup_mana_dir_with_config();
545
546 let args = QuickArgs {
547 title: "Passing verify ok".to_string(),
548 description: None,
549 acceptance: None,
550 notes: None,
551 verify: Some("grep -q 'project: test' .mana/config.yaml".to_string()),
552 priority: None,
553 by: None,
554 produces: None,
555 requires: None,
556 parent: None,
557 on_fail: None,
558 pass_ok: true,
559 verify_timeout: None,
560 force: false,
561 };
562
563 let result = cmd_quick(&mana_dir, args);
564 assert!(result.is_ok());
565
566 let unit_path = mana_dir.join("1-passing-verify-ok.md");
568 assert!(unit_path.exists());
569
570 let unit = Unit::from_file(&unit_path).unwrap();
572 assert!(!unit.fail_first);
573 }
574
575 #[test]
576 fn no_verify_skips_fail_first_check() {
577 let (_dir, mana_dir) = setup_mana_dir_with_config();
578
579 let args = QuickArgs {
580 title: "No verify".to_string(),
581 description: None,
582 acceptance: Some("Done".to_string()),
583 notes: None,
584 verify: None, priority: None,
586 by: None,
587 produces: None,
588 requires: None,
589 parent: None,
590 on_fail: None,
591 pass_ok: false,
592 verify_timeout: None,
593 force: false,
594 };
595
596 let result = cmd_quick(&mana_dir, args);
597 assert!(result.is_ok());
598
599 let unit_path = mana_dir.join("1-no-verify.md");
601 let unit = Unit::from_file(&unit_path).unwrap();
602 assert!(!unit.fail_first);
603 }
604
605 mod lint {
606 use super::*;
607
608 #[test]
609 fn quick_verify_lint_rejects_errors_without_force() {
610 let (_dir, mana_dir) = setup_mana_dir_with_config();
611
612 let args = QuickArgs {
613 title: "Quick lint error".to_string(),
614 description: None,
615 acceptance: None,
616 notes: None,
617 verify: Some("true".to_string()),
618 priority: None,
619 by: None,
620 produces: None,
621 requires: None,
622 parent: None,
623 on_fail: None,
624 pass_ok: true,
625 verify_timeout: None,
626 force: false,
627 };
628
629 let result = cmd_quick(&mana_dir, args);
630 assert!(result.is_err());
631 assert!(result.unwrap_err().to_string().contains("lint error"));
632 }
633
634 #[test]
635 fn quick_verify_lint_allows_errors_with_force() {
636 let (_dir, mana_dir) = setup_mana_dir_with_config();
637
638 let args = QuickArgs {
639 title: "Forced quick lint error".to_string(),
640 description: None,
641 acceptance: None,
642 notes: None,
643 verify: Some("echo done".to_string()),
644 priority: None,
645 by: None,
646 produces: None,
647 requires: None,
648 parent: None,
649 on_fail: None,
650 pass_ok: true,
651 verify_timeout: None,
652 force: true,
653 };
654
655 let result = cmd_quick(&mana_dir, args);
656 assert!(result.is_ok());
657 }
658 }
659}