1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{anyhow, Context, Result};
5
6use crate::config::Config;
7use crate::hooks::{execute_hook, HookEvent};
8use crate::index::{Index, LockedIndex};
9use crate::unit::{validate_priority, OnFailAction, Unit, UnitType};
10use crate::util::title_to_slug;
11use crate::verify_lint::{lint_verify, VerifyLintLevel};
12
13fn next_top_level_id(mana_dir: &Path, config: &mut Config) -> Result<u32> {
14 let dir_entries = fs::read_dir(mana_dir)
15 .with_context(|| format!("Failed to read directory: {}", mana_dir.display()))?;
16 let mut max_existing = 0u32;
17
18 for entry in dir_entries {
19 let entry = entry?;
20 let filename = entry
21 .path()
22 .file_name()
23 .and_then(|n| n.to_str())
24 .unwrap_or_default()
25 .to_string();
26
27 let base = filename
28 .strip_suffix(".md")
29 .or_else(|| filename.strip_suffix(".yaml"));
30 let Some(base) = base else {
31 continue;
32 };
33
34 let Some(first_segment) = base.split(['-', '.']).next() else {
35 continue;
36 };
37
38 if let Ok(id) = first_segment.parse::<u32>() {
39 max_existing = max_existing.max(id);
40 }
41 }
42
43 if config.next_id <= max_existing {
44 config.next_id = max_existing + 1;
45 }
46
47 Ok(config.increment_id())
48}
49
50#[derive(Default)]
52pub struct CreateParams {
53 pub title: String,
54 pub handle: Option<String>,
55 pub description: Option<String>,
56 pub acceptance: Option<String>,
57 pub notes: Option<String>,
58 pub design: Option<String>,
59 pub verify: Option<String>,
60 pub priority: Option<u8>,
61 pub labels: Vec<String>,
62 pub assignee: Option<String>,
63 pub dependencies: Vec<String>,
64 pub parent: Option<String>,
65 pub produces: Vec<String>,
66 pub requires: Vec<String>,
67 pub paths: Vec<String>,
68 pub on_fail: Option<OnFailAction>,
69 pub fail_first: bool,
70 pub feature: bool,
71 pub kind: Option<UnitType>,
72 pub verify_timeout: Option<u64>,
73 pub decisions: Vec<String>,
74 pub force: bool,
76}
77
78#[derive(serde::Serialize)]
80pub struct CreateResult {
81 pub unit: Unit,
82 pub path: PathBuf,
83}
84
85pub fn create(mana_dir: &Path, params: CreateParams) -> Result<CreateResult> {
87 if let Some(priority) = params.priority {
88 validate_priority(priority)?;
89 }
90
91 if let Some(ref verify_cmd) = params.verify {
95 let findings = lint_verify(verify_cmd);
96 if !findings.is_empty() {
97 let has_errors = findings.iter().any(|f| f.level == VerifyLintLevel::Error);
98
99 if has_errors && !params.force {
100 let mut message =
101 String::from("Verify command has lint errors. Use --force to override.");
102 for finding in findings
103 .iter()
104 .filter(|f| f.level == VerifyLintLevel::Error)
105 {
106 message.push_str("\n- ");
107 message.push_str(&finding.message);
108 }
109 return Err(anyhow!(message));
110 }
111 }
112 }
113
114 let mut config = Config::load(mana_dir)?;
115
116 let unit_id = if let Some(ref parent_id) = params.parent {
117 assign_child_id(mana_dir, parent_id)?
118 } else {
119 next_top_level_id(mana_dir, &mut config)?.to_string()
120 };
121
122 let slug = title_to_slug(¶ms.title);
123 let mut unit = Unit::new(&unit_id, ¶ms.title);
124 unit.slug = Some(slug.clone());
125 unit.handle = params.handle;
126 unit.ensure_handle();
127 unit.description = params.description;
128 unit.acceptance = params.acceptance;
129 unit.notes = params.notes;
130 unit.design = params.design;
131 unit.verify = params.verify;
132 unit.fail_first = params.fail_first;
133 unit.feature = params.feature;
134 if let Some(kind) = params.kind {
135 unit.kind = kind;
136 }
137 unit.verify_timeout = params.verify_timeout;
138 unit.on_fail = params.on_fail;
139 if let Some(priority) = params.priority {
140 unit.priority = priority;
141 }
142 unit.assignee = params.assignee;
143 unit.parent = params.parent;
144 unit.labels = params.labels;
145 unit.dependencies = params.dependencies;
146 unit.produces = params.produces;
147 unit.requires = params.requires;
148 unit.paths = params.paths;
149 unit.decisions = params.decisions;
150
151 let project_dir = mana_dir
152 .parent()
153 .ok_or_else(|| anyhow!("Failed to determine project directory"))?;
154
155 let pre_passed = execute_hook(HookEvent::PreCreate, &unit, project_dir, None)
156 .context("Pre-create hook execution failed")?;
157 if !pre_passed {
158 return Err(anyhow!("Pre-create hook rejected unit creation"));
159 }
160
161 let unit_path = mana_dir.join(format!("{}-{}.md", unit_id, slug));
162 unit.to_file(&unit_path)?;
163 config.save(mana_dir)?;
164 let mut locked = LockedIndex::acquire(mana_dir)?;
165 locked.index = Index::build(mana_dir)?;
166 locked.save_and_release()?;
167
168 if let Err(e) = execute_hook(HookEvent::PostCreate, &unit, project_dir, None) {
169 eprintln!("Warning: post-create hook failed: {}", e);
170 }
171
172 Ok(CreateResult {
173 unit,
174 path: unit_path,
175 })
176}
177
178pub fn assign_child_id(mana_dir: &Path, parent_id: &str) -> Result<String> {
180 let mut max_child: u32 = 0;
181 let dir_entries = fs::read_dir(mana_dir)
182 .with_context(|| format!("Failed to read directory: {}", mana_dir.display()))?;
183 for entry in dir_entries {
184 let entry = entry?;
185 let filename = entry
186 .path()
187 .file_name()
188 .and_then(|n| n.to_str())
189 .unwrap_or_default()
190 .to_string();
191 if let Some(name) = filename.strip_suffix(".md") {
192 if let Some(rest) = name.strip_prefix(parent_id) {
193 if let Some(after_dot) = rest.strip_prefix('.') {
194 if let Ok(n) = after_dot
195 .split('-')
196 .next()
197 .unwrap_or_default()
198 .parse::<u32>()
199 {
200 max_child = max_child.max(n);
201 }
202 }
203 }
204 }
205 if let Some(name) = filename.strip_suffix(".yaml") {
206 if let Some(rest) = name.strip_prefix(parent_id) {
207 if let Some(after_dot) = rest.strip_prefix('.') {
208 if let Ok(n) = after_dot.parse::<u32>() {
209 max_child = max_child.max(n);
210 }
211 }
212 }
213 }
214 }
215 Ok(format!("{}.{}", parent_id, max_child + 1))
216}
217
218pub fn parse_on_fail(s: &str) -> Result<OnFailAction> {
220 let (action, arg) = match s.split_once(':') {
221 Some((a, b)) => (a, Some(b)),
222 None => (s, None),
223 };
224 match action {
225 "retry" => {
226 let max = arg
227 .map(|a| a.parse::<u32>())
228 .transpose()
229 .map_err(|_| anyhow!("Invalid retry max: \'{}\'", arg.unwrap_or("")))?;
230 Ok(OnFailAction::Retry {
231 max,
232 delay_secs: None,
233 })
234 }
235 "escalate" => {
236 let priority = match arg {
237 Some(a) => {
238 let stripped = a
239 .strip_prefix('P')
240 .or_else(|| a.strip_prefix('p'))
241 .unwrap_or(a);
242 let p = stripped
243 .parse::<u8>()
244 .map_err(|_| anyhow!("Invalid priority: \'{}\'", a))?;
245 validate_priority(p)?;
246 Some(p)
247 }
248 None => None,
249 };
250 Ok(OnFailAction::Escalate {
251 priority,
252 message: None,
253 })
254 }
255 _ => Err(anyhow!("Unknown on-fail action: \'{}\'", action)),
256 }
257}
258
259#[cfg(test)]
260pub mod tests {
261 use super::*;
262 use tempfile::TempDir;
263
264 fn setup_mana_dir() -> (TempDir, PathBuf) {
265 let dir = TempDir::new().unwrap();
266 let mana_dir = dir.path().join(".mana");
267 fs::create_dir(&mana_dir).unwrap();
268 Config {
269 project: "test".to_string(),
270 next_id: 1,
271 auto_close_parent: true,
272 run: None,
273 plan: None,
274 max_loops: 10,
275 max_concurrent: 4,
276 poll_interval: 30,
277 extends: vec![],
278 rules_file: None,
279 file_locking: false,
280 worktree: false,
281 on_close: None,
282 on_fail: None,
283 verify_timeout: None,
284 review: None,
285 user: None,
286 user_email: None,
287 auto_commit: false,
288 commit_template: None,
289 research: None,
290 run_model: None,
291 plan_model: None,
292 review_model: None,
293 research_model: None,
294 batch_verify: false,
295 memory_reserve_mb: 0,
296 notify: None,
297 }
298 .save(&mana_dir)
299 .unwrap();
300 (dir, mana_dir)
301 }
302
303 pub fn minimal_params(title: &str) -> CreateParams {
304 CreateParams {
305 title: title.to_string(),
306 handle: None,
307 description: None,
308 acceptance: None,
309 notes: None,
310 design: None,
311 verify: None,
312 priority: None,
313 labels: vec![],
314 assignee: None,
315 dependencies: vec![],
316 parent: None,
317 produces: vec![],
318 requires: vec![],
319 paths: vec![],
320 on_fail: None,
321 fail_first: false,
322 feature: false,
323 kind: None,
324 verify_timeout: None,
325 decisions: vec![],
326 force: false,
327 }
328 }
329
330 #[test]
331 fn create_minimal() {
332 let (_dir, bd) = setup_mana_dir();
333 let r = create(&bd, minimal_params("First")).unwrap();
334 assert_eq!(r.unit.id, "1");
335 assert!(r.path.exists());
336 }
337
338 #[test]
339 fn create_reports_verify_lint_errors_without_stderr_side_effects() {
340 let (_dir, bd) = setup_mana_dir();
341 let mut params = minimal_params("Weak verify");
342 params.verify = Some("echo done".into());
343
344 let error = create(&bd, params)
345 .err()
346 .expect("weak verify should be rejected")
347 .to_string();
348 assert!(error.contains("Verify command has lint errors"));
349 assert!(error.contains("always exits successfully") || error.contains("Use --force"));
350 }
351
352 #[test]
353 fn create_increments() {
354 let (_dir, bd) = setup_mana_dir();
355 assert_eq!(create(&bd, minimal_params("A")).unwrap().unit.id, "1");
356 assert_eq!(create(&bd, minimal_params("B")).unwrap().unit.id, "2");
357 }
358
359 #[test]
360 fn create_child() {
361 let (_dir, bd) = setup_mana_dir();
362 create(&bd, minimal_params("Parent")).unwrap();
363 let mut p = minimal_params("Child");
364 p.parent = Some("1".into());
365 assert_eq!(create(&bd, p).unwrap().unit.id, "1.1");
366 }
367
368 #[test]
369 fn create_rebuilds_index() {
370 let (_dir, bd) = setup_mana_dir();
371 create(&bd, minimal_params("Indexed")).unwrap();
372 let index = Index::load(&bd).unwrap();
373 assert_eq!(index.units[0].title, "Indexed");
374 }
375
376 #[test]
377 fn create_recovers_from_stale_next_id() {
378 let (_dir, bd) = setup_mana_dir();
379
380 let mut existing = Unit::new("5", "Existing");
381 existing.slug = Some("existing".into());
382 existing.to_file(bd.join("5-existing.md")).unwrap();
383
384 let mut config = Config::load(&bd).unwrap();
385 config.next_id = 3;
386 config.save(&bd).unwrap();
387
388 let created = create(&bd, minimal_params("After stale next_id")).unwrap();
389 assert_eq!(created.unit.id, "6");
390
391 let config = Config::load(&bd).unwrap();
392 assert_eq!(config.next_id, 7);
393 }
394}