Skip to main content

mana/commands/create/
mod.rs

1use std::path::Path;
2use std::process::Command as ShellCommand;
3
4use anyhow::{anyhow, Context, Result};
5use mana_core::ops::create;
6use mana_core::verify_lint::{lint_verify, VerifyLintLevel};
7
8use crate::commands::claim::cmd_claim;
9use crate::index::Index;
10use crate::project::suggest_verify_command;
11use crate::unit::{validate_priority, OnFailAction};
12use crate::util::{find_similar_titles, DEFAULT_SIMILARITY_THRESHOLD};
13
14/// Create arguments structure for organizing all the parameters passed to create.
15pub struct CreateArgs {
16    pub title: String,
17    pub description: Option<String>,
18    pub acceptance: Option<String>,
19    pub notes: Option<String>,
20    pub design: Option<String>,
21    pub verify: Option<String>,
22    pub priority: Option<u8>,
23    pub labels: Option<String>,
24    pub assignee: Option<String>,
25    pub deps: Option<String>,
26    pub parent: Option<String>,
27    pub produces: Option<String>,
28    pub requires: Option<String>,
29    /// Comma-separated file paths relevant to this unit.
30    pub paths: Option<String>,
31    /// Action on verify failure
32    pub on_fail: Option<OnFailAction>,
33    /// Skip fail-first check (allow verify to already pass)
34    pub pass_ok: bool,
35    /// Claim the unit immediately after creation
36    pub claim: bool,
37    /// Who is claiming (used with claim)
38    pub by: Option<String>,
39    /// Timeout in seconds for the verify command (kills process on expiry).
40    pub verify_timeout: Option<u64>,
41    /// Mark as a product feature (human-only close, no verify gate required).
42    pub feature: bool,
43    /// Unresolved decisions that block autonomous execution.
44    pub decisions: Vec<String>,
45    /// Skip duplicate title check
46    pub force: bool,
47}
48
49/// Assign a child ID for a parent unit.
50/// Scans .mana/ for {parent_id}.{N}-*.md, finds highest N, returns "{parent_id}.{N+1}".
51pub fn assign_child_id(mana_dir: &Path, parent_id: &str) -> Result<String> {
52    create::assign_child_id(mana_dir, parent_id)
53}
54
55/// Parse an `--on-fail` CLI string into an `OnFailAction`.
56///
57/// Accepted formats:
58/// - `retry` → Retry { max: None, delay_secs: None }
59/// - `retry:5` → Retry { max: Some(5), delay_secs: None }
60/// - `escalate` → Escalate { priority: None, message: None }
61/// - `escalate:P0` or `escalate:0` → Escalate { priority: Some(0), message: None }
62pub(crate) fn lint_verify_command(verify_cmd: Option<&str>, force: bool) -> Result<()> {
63    let Some(verify_cmd) = verify_cmd else {
64        return Ok(());
65    };
66
67    let findings = lint_verify(verify_cmd);
68    if findings.is_empty() {
69        return Ok(());
70    }
71
72    let error_count = findings
73        .iter()
74        .filter(|finding| finding.level == VerifyLintLevel::Error)
75        .count();
76
77    for finding in &findings {
78        let label = match finding.level {
79            VerifyLintLevel::Error => "verify lint error",
80            VerifyLintLevel::Warning => "verify lint warning",
81        };
82        eprintln!("{}: {}", label, finding.message);
83    }
84
85    if error_count > 0 && !force {
86        anyhow::bail!(
87            "Refusing to create unit: verify command has {} lint error(s). Use --force to create anyway.",
88            error_count
89        );
90    }
91
92    if error_count > 0 {
93        eprintln!("Proceeding despite verify lint errors because --force was used.");
94    }
95
96    Ok(())
97}
98
99pub fn parse_on_fail(s: &str) -> Result<OnFailAction> {
100    create::parse_on_fail(s)
101}
102
103/// Create a new unit.
104///
105/// If `args.parent` is given, assign a child ID ({parent_id}.{next_child}).
106/// Otherwise, use the next sequential ID from config and increment it.
107/// Returns the created unit ID on success.
108pub fn cmd_create(mana_dir: &Path, args: CreateArgs) -> Result<String> {
109    if let Some(priority) = args.priority {
110        validate_priority(priority)?;
111    }
112
113    if args.claim && args.parent.is_none() && args.acceptance.is_none() && args.verify.is_none() {
114        anyhow::bail!(
115            "Unit must have validation criteria: provide --acceptance or --verify (or both)\n\
116             Hint: parent/goal units (without --claim) don't require this."
117        );
118    }
119
120    // Verify lint is handled by mana_core::ops::create::create() at the library level.
121    // All consumers (CLI, imp, MCP) get it automatically.
122
123    if !args.pass_ok {
124        if let Some(verify_cmd) = args.verify.as_ref() {
125            let project_root = mana_dir
126                .parent()
127                .ok_or_else(|| anyhow!("Cannot determine project root"))?;
128
129            eprintln!("Running verify (must fail): {}", verify_cmd);
130
131            let status = ShellCommand::new("sh")
132                .args(["-c", verify_cmd])
133                .current_dir(project_root)
134                .status()
135                .with_context(|| format!("Failed to execute verify command: {}", verify_cmd))?;
136
137            if status.success() {
138                anyhow::bail!(
139                    "Cannot create unit: verify command already passes!\n\n\
140                     The test must FAIL on current code to prove it tests something real.\n\
141                     Either:\n\
142                     - The test doesn't actually test the new behavior\n\
143                     - The feature is already implemented\n\
144                     - The test is a no-op (assert True)\n\n\
145                     Use --pass-ok / -p to skip this check."
146                );
147            }
148
149            eprintln!("✓ Verify failed as expected - test is real");
150        }
151    }
152
153    if !args.force {
154        if let Ok(index) = Index::load_or_rebuild(mana_dir) {
155            let similar = find_similar_titles(&index, &args.title, DEFAULT_SIMILARITY_THRESHOLD);
156            if !similar.is_empty() {
157                let mut msg = String::from("Similar unit(s) already exist:\n");
158                for s in &similar {
159                    msg.push_str(&format!(
160                        "  [{}] {} (similarity: {:.0}%)\n",
161                        s.id,
162                        s.title,
163                        s.score * 100.0
164                    ));
165                }
166                msg.push_str("\nUse --force to create anyway.");
167                anyhow::bail!(msg);
168            }
169        }
170    }
171
172    let has_verify = args.verify.is_some();
173    let title = args.title.clone();
174    let params = create::CreateParams {
175        title: args.title,
176        description: args.description,
177        acceptance: args.acceptance,
178        notes: args.notes,
179        design: args.design,
180        verify: args.verify,
181        priority: args.priority,
182        labels: split_csv(args.labels),
183        assignee: args.assignee,
184        dependencies: split_csv(args.deps),
185        parent: args.parent,
186        produces: split_csv(args.produces),
187        requires: split_csv(args.requires),
188        paths: split_csv(args.paths),
189        on_fail: args.on_fail,
190        fail_first: !args.pass_ok && has_verify,
191        feature: args.feature,
192        verify_timeout: args.verify_timeout,
193        decisions: args.decisions,
194        force: args.force,
195    };
196
197    let result = create::create(mana_dir, params)?;
198    let unit_id = result.unit.id.clone();
199
200    eprintln!("Created unit {}: {}", unit_id, title);
201
202    if !has_verify {
203        let project_dir = mana_dir
204            .parent()
205            .ok_or_else(|| anyhow!("Failed to determine project directory"))?;
206        if let Some(suggested) = suggest_verify_command(project_dir) {
207            eprintln!(
208                "Tip: Consider adding a verify command: --verify \"{}\"",
209                suggested
210            );
211        }
212    }
213
214    if args.claim {
215        cmd_claim(mana_dir, &unit_id, args.by, true)?;
216    }
217
218    Ok(unit_id)
219}
220
221/// Create a new unit that automatically depends on @latest (the most recently updated unit).
222///
223/// This enables sequential chaining:
224/// ```bash
225/// mana create "Step 1" -p
226/// mana create next "Step 2" --verify "cargo test step2"
227/// mana create next "Step 3" --verify "cargo test step3"
228/// ```
229///
230/// If `args.deps` already contains dependencies, @latest is prepended.
231/// Returns the created unit ID on success.
232pub fn cmd_create_next(mana_dir: &Path, args: CreateArgs) -> Result<String> {
233    // Resolve @latest — find the most recently updated unit
234    let index = Index::load(mana_dir).or_else(|_| Index::build(mana_dir))?;
235    let latest_id = index
236        .units
237        .iter()
238        .max_by_key(|e| e.updated_at)
239        .map(|e| e.id.clone())
240        .ok_or_else(|| {
241            anyhow!(
242                "No previous unit found. 'mana create next' requires at least one existing unit.\n\
243                 Use 'mana create' for the first unit in a chain."
244            )
245        })?;
246
247    // Merge @latest dep with any explicit deps
248    let merged_deps = match args.deps {
249        Some(ref d) => Some(format!("{},{}", latest_id, d)),
250        None => Some(latest_id.clone()),
251    };
252
253    eprintln!("⛓ Chained after unit {} (@latest)", latest_id);
254
255    let new_args = CreateArgs {
256        deps: merged_deps,
257        ..args
258    };
259
260    cmd_create(mana_dir, new_args)
261}
262
263fn split_csv(value: Option<String>) -> Vec<String> {
264    value
265        .unwrap_or_default()
266        .split(',')
267        .map(str::trim)
268        .filter(|value| !value.is_empty())
269        .map(str::to_string)
270        .collect()
271}
272
273#[cfg(test)]
274mod tests;