Skip to main content

mana/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::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
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 unit ID (creates child unit 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    /// Skip duplicate title check
35    pub force: bool,
36}
37
38/// Quick-create: create a unit and immediately claim it.
39///
40/// This is a convenience command that combines `mana create` + `mana claim`
41/// in a single operation. Useful for agents starting immediate work.
42pub fn cmd_quick(mana_dir: &Path, args: QuickArgs) -> Result<()> {
43    // Validate priority if provided
44    if let Some(priority) = args.priority {
45        validate_priority(priority)?;
46    }
47
48    // Require at least acceptance or verify criteria
49    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    // Fail-first check (default): verify command must FAIL before unit can be created
58    // This prevents "cheating tests" like `assert True` that always pass
59    // Use --pass-ok / -p to skip this check
60    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    // Duplicate title check (skip with --force)
91    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    // Load config and assign ID (child ID from parent, or next global ID)
111    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    // Generate slug from title
121    let slug = title_to_slug(&args.title);
122
123    // Track if verify was provided for suggestion later
124    let has_verify = args.verify.is_some();
125
126    // Create the unit with InProgress status (already claimed)
127    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    // Parse produces
158    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    // Parse requires
166    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    // Set on_fail action
174    if let Some(on_fail) = args.on_fail {
175        unit.on_fail = Some(on_fail);
176    }
177
178    // Set verify_timeout if provided
179    if let Some(timeout) = args.verify_timeout {
180        unit.verify_timeout = Some(timeout);
181    }
182
183    // Get the project directory (parent of mana_dir which is .mana)
184    let project_dir = mana_dir
185        .parent()
186        .ok_or_else(|| anyhow!("Failed to determine project directory"))?;
187
188    // Call pre-create hook (blocking - abort if it fails)
189    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    // Write the unit file with naming convention: {id}-{slug}.md
197    let unit_path = mana_dir.join(format!("{}-{}.md", unit_id, slug));
198    unit.to_file(&unit_path)?;
199
200    // Update the index by rebuilding from disk
201    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    // Suggest verify command if none was provided
211    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    // Call post-create hook (non-blocking - log warning if it fails)
221    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        // Check the unit file exists
299        let unit_path = mana_dir.join("1-quick-task.md");
300        assert!(unit_path.exists());
301
302        // Verify content
303        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        // Create first unit
373        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        // Create second unit
392        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        // Verify both exist with correct IDs
411        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        // Load and check index
441        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, // default: fail-first enforced
499            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()), // always fails
519            priority: None,
520            by: None,
521            produces: None,
522            requires: None,
523            parent: None,
524            on_fail: None,
525            pass_ok: false, // default: fail-first enforced
526            verify_timeout: None,
527            force: false,
528        };
529
530        let result = cmd_quick(&mana_dir, args);
531        assert!(result.is_ok());
532
533        // Unit should be created
534        let unit_path = mana_dir.join("1-real-test.md");
535        assert!(unit_path.exists());
536
537        // Should have fail_first set in the unit
538        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        // Unit should be created
567        let unit_path = mana_dir.join("1-passing-verify-ok.md");
568        assert!(unit_path.exists());
569
570        // Should NOT have fail_first set
571        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, // no verify command — fail-first not applicable
585            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        // Should NOT have fail_first set (no verify)
600        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}