Skip to main content

mana_core/ops/
create.rs

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/// Parameters for creating a new unit.
51#[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    /// Skip verify lint errors (allow anti-pattern verify commands)
75    pub force: bool,
76}
77
78/// Result of creating a unit.
79#[derive(serde::Serialize)]
80pub struct CreateResult {
81    pub unit: Unit,
82    pub path: PathBuf,
83}
84
85/// Create a new unit and persist it to disk.
86pub fn create(mana_dir: &Path, params: CreateParams) -> Result<CreateResult> {
87    if let Some(priority) = params.priority {
88        validate_priority(priority)?;
89    }
90
91    // Lint the verify command for anti-patterns. Keep library behavior side-effect free:
92    // return structured failure text instead of writing directly to stderr, since callers like
93    // imp may invoke this in-process under a live TUI.
94    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(&params.title);
123    let mut unit = Unit::new(&unit_id, &params.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
178/// Assign a child ID for a parent unit.
179pub 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
218/// Parse an on-fail string into an OnFailAction.
219pub 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}