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;
9use crate::unit::{validate_priority, OnFailAction, Unit};
10use crate::util::title_to_slug;
11use crate::verify_lint::{lint_verify, VerifyLintLevel};
12
13#[derive(Default)]
15pub struct CreateParams {
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: Vec<String>,
24 pub assignee: Option<String>,
25 pub dependencies: Vec<String>,
26 pub parent: Option<String>,
27 pub produces: Vec<String>,
28 pub requires: Vec<String>,
29 pub paths: Vec<String>,
30 pub on_fail: Option<OnFailAction>,
31 pub fail_first: bool,
32 pub feature: bool,
33 pub verify_timeout: Option<u64>,
34 pub decisions: Vec<String>,
35 pub force: bool,
37}
38
39pub struct CreateResult {
41 pub unit: Unit,
42 pub path: PathBuf,
43}
44
45pub fn create(mana_dir: &Path, params: CreateParams) -> Result<CreateResult> {
47 if let Some(priority) = params.priority {
48 validate_priority(priority)?;
49 }
50
51 if let Some(ref verify_cmd) = params.verify {
53 let findings = lint_verify(verify_cmd);
54 if !findings.is_empty() {
55 let has_errors = findings.iter().any(|f| f.level == VerifyLintLevel::Error);
56
57 for finding in &findings {
59 let prefix = match finding.level {
60 VerifyLintLevel::Error => "error",
61 VerifyLintLevel::Warning => "warning",
62 };
63 eprintln!("[verify-lint {}] {}", prefix, finding.message);
64 }
65
66 if has_errors && !params.force {
68 return Err(anyhow!(
69 "Verify command has lint errors. Use --force to override."
70 ));
71 }
72 }
73 }
74
75 let mut config = Config::load(mana_dir)?;
76
77 let unit_id = if let Some(ref parent_id) = params.parent {
78 assign_child_id(mana_dir, parent_id)?
79 } else {
80 let id = config.increment_id();
81 config.save(mana_dir)?;
82 id.to_string()
83 };
84
85 let slug = title_to_slug(¶ms.title);
86 let mut unit = Unit::new(&unit_id, ¶ms.title);
87 unit.slug = Some(slug.clone());
88 unit.description = params.description;
89 unit.acceptance = params.acceptance;
90 unit.notes = params.notes;
91 unit.design = params.design;
92 unit.verify = params.verify;
93 unit.fail_first = params.fail_first;
94 unit.feature = params.feature;
95 unit.verify_timeout = params.verify_timeout;
96 unit.on_fail = params.on_fail;
97 if let Some(priority) = params.priority {
98 unit.priority = priority;
99 }
100 unit.assignee = params.assignee;
101 unit.parent = params.parent;
102 unit.labels = params.labels;
103 unit.dependencies = params.dependencies;
104 unit.produces = params.produces;
105 unit.requires = params.requires;
106 unit.paths = params.paths;
107 unit.decisions = params.decisions;
108
109 let project_dir = mana_dir
110 .parent()
111 .ok_or_else(|| anyhow!("Failed to determine project directory"))?;
112
113 let pre_passed = execute_hook(HookEvent::PreCreate, &unit, project_dir, None)
114 .context("Pre-create hook execution failed")?;
115 if !pre_passed {
116 return Err(anyhow!("Pre-create hook rejected unit creation"));
117 }
118
119 let unit_path = mana_dir.join(format!("{}-{}.md", unit_id, slug));
120 unit.to_file(&unit_path)?;
121 let index = Index::build(mana_dir)?;
122 index.save(mana_dir)?;
123
124 if let Err(e) = execute_hook(HookEvent::PostCreate, &unit, project_dir, None) {
125 eprintln!("Warning: post-create hook failed: {}", e);
126 }
127
128 Ok(CreateResult {
129 unit,
130 path: unit_path,
131 })
132}
133
134pub fn assign_child_id(mana_dir: &Path, parent_id: &str) -> Result<String> {
136 let mut max_child: u32 = 0;
137 let dir_entries = fs::read_dir(mana_dir)
138 .with_context(|| format!("Failed to read directory: {}", mana_dir.display()))?;
139 for entry in dir_entries {
140 let entry = entry?;
141 let filename = entry
142 .path()
143 .file_name()
144 .and_then(|n| n.to_str())
145 .unwrap_or_default()
146 .to_string();
147 if let Some(name) = filename.strip_suffix(".md") {
148 if let Some(rest) = name.strip_prefix(parent_id) {
149 if let Some(after_dot) = rest.strip_prefix('.') {
150 if let Ok(n) = after_dot
151 .split('-')
152 .next()
153 .unwrap_or_default()
154 .parse::<u32>()
155 {
156 max_child = max_child.max(n);
157 }
158 }
159 }
160 }
161 if let Some(name) = filename.strip_suffix(".yaml") {
162 if let Some(rest) = name.strip_prefix(parent_id) {
163 if let Some(after_dot) = rest.strip_prefix('.') {
164 if let Ok(n) = after_dot.parse::<u32>() {
165 max_child = max_child.max(n);
166 }
167 }
168 }
169 }
170 }
171 Ok(format!("{}.{}", parent_id, max_child + 1))
172}
173
174pub fn parse_on_fail(s: &str) -> Result<OnFailAction> {
176 let (action, arg) = match s.split_once(':') {
177 Some((a, b)) => (a, Some(b)),
178 None => (s, None),
179 };
180 match action {
181 "retry" => {
182 let max = arg
183 .map(|a| a.parse::<u32>())
184 .transpose()
185 .map_err(|_| anyhow!("Invalid retry max: \'{}\'", arg.unwrap_or("")))?;
186 Ok(OnFailAction::Retry {
187 max,
188 delay_secs: None,
189 })
190 }
191 "escalate" => {
192 let priority = match arg {
193 Some(a) => {
194 let stripped = a
195 .strip_prefix('P')
196 .or_else(|| a.strip_prefix('p'))
197 .unwrap_or(a);
198 let p = stripped
199 .parse::<u8>()
200 .map_err(|_| anyhow!("Invalid priority: \'{}\'", a))?;
201 validate_priority(p)?;
202 Some(p)
203 }
204 None => None,
205 };
206 Ok(OnFailAction::Escalate {
207 priority,
208 message: None,
209 })
210 }
211 _ => Err(anyhow!("Unknown on-fail action: \'{}\'", action)),
212 }
213}
214
215#[cfg(test)]
216pub mod tests {
217 use super::*;
218 use tempfile::TempDir;
219
220 fn setup_mana_dir() -> (TempDir, PathBuf) {
221 let dir = TempDir::new().unwrap();
222 let mana_dir = dir.path().join(".mana");
223 fs::create_dir(&mana_dir).unwrap();
224 Config {
225 project: "test".to_string(),
226 next_id: 1,
227 auto_close_parent: true,
228 run: None,
229 plan: None,
230 max_loops: 10,
231 max_concurrent: 4,
232 poll_interval: 30,
233 extends: vec![],
234 rules_file: None,
235 file_locking: false,
236 worktree: false,
237 on_close: None,
238 on_fail: None,
239 post_plan: None,
240 verify_timeout: None,
241 review: None,
242 user: None,
243 user_email: None,
244 auto_commit: false,
245 commit_template: None,
246 research: None,
247 run_model: None,
248 plan_model: None,
249 review_model: None,
250 research_model: None,
251 batch_verify: false,
252 memory_reserve_mb: 0,
253 notify: None,
254 }
255 .save(&mana_dir)
256 .unwrap();
257 (dir, mana_dir)
258 }
259
260 pub fn minimal_params(title: &str) -> CreateParams {
261 CreateParams {
262 title: title.to_string(),
263 description: None,
264 acceptance: None,
265 notes: None,
266 design: None,
267 verify: None,
268 priority: None,
269 labels: vec![],
270 assignee: None,
271 dependencies: vec![],
272 parent: None,
273 produces: vec![],
274 requires: vec![],
275 paths: vec![],
276 on_fail: None,
277 fail_first: false,
278 feature: false,
279 verify_timeout: None,
280 decisions: vec![],
281 force: false,
282 }
283 }
284
285 #[test]
286 fn create_minimal() {
287 let (_dir, bd) = setup_mana_dir();
288 let r = create(&bd, minimal_params("First")).unwrap();
289 assert_eq!(r.unit.id, "1");
290 assert!(r.path.exists());
291 }
292
293 #[test]
294 fn create_increments() {
295 let (_dir, bd) = setup_mana_dir();
296 assert_eq!(create(&bd, minimal_params("A")).unwrap().unit.id, "1");
297 assert_eq!(create(&bd, minimal_params("B")).unwrap().unit.id, "2");
298 }
299
300 #[test]
301 fn create_child() {
302 let (_dir, bd) = setup_mana_dir();
303 create(&bd, minimal_params("Parent")).unwrap();
304 let mut p = minimal_params("Child");
305 p.parent = Some("1".into());
306 assert_eq!(create(&bd, p).unwrap().unit.id, "1.1");
307 }
308
309 #[test]
310 fn create_rebuilds_index() {
311 let (_dir, bd) = setup_mana_dir();
312 create(&bd, minimal_params("Indexed")).unwrap();
313 let index = Index::load(&bd).unwrap();
314 assert_eq!(index.units[0].title, "Indexed");
315 }
316}