mana/commands/create/
mod.rs1use 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
14pub 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 pub paths: Option<String>,
31 pub on_fail: Option<OnFailAction>,
33 pub pass_ok: bool,
35 pub claim: bool,
37 pub by: Option<String>,
39 pub verify_timeout: Option<u64>,
41 pub feature: bool,
43 pub decisions: Vec<String>,
45 pub force: bool,
47}
48
49pub fn assign_child_id(mana_dir: &Path, parent_id: &str) -> Result<String> {
52 create::assign_child_id(mana_dir, parent_id)
53}
54
55pub(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
103pub 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 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
221pub fn cmd_create_next(mana_dir: &Path, args: CreateArgs) -> Result<String> {
233 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 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;