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;
9use crate::unit::{validate_priority, OnFailAction, Unit};
10use crate::util::title_to_slug;
11use crate::verify_lint::{lint_verify, VerifyLintLevel};
12
13/// Parameters for creating a new unit.
14#[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    /// Skip verify lint errors (allow anti-pattern verify commands)
36    pub force: bool,
37}
38
39/// Result of creating a unit.
40pub struct CreateResult {
41    pub unit: Unit,
42    pub path: PathBuf,
43}
44
45/// Create a new unit and persist it to disk.
46pub fn create(mana_dir: &Path, params: CreateParams) -> Result<CreateResult> {
47    if let Some(priority) = params.priority {
48        validate_priority(priority)?;
49    }
50
51    // Lint the verify command for anti-patterns
52    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            // Print warnings to stderr regardless
58            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            // Block on errors unless force is set
67            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(&params.title);
86    let mut unit = Unit::new(&unit_id, &params.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
134/// Assign a child ID for a parent unit.
135pub 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
174/// Parse an on-fail string into an OnFailAction.
175pub 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}