Skip to main content

mana_core/ops/
update.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{anyhow, Context, Result};
4use chrono::Utc;
5
6use crate::discovery::find_unit_file;
7use crate::hooks::{execute_hook, HookEvent};
8use crate::index::Index;
9use crate::unit::{validate_priority, Unit};
10use crate::util::parse_status;
11
12/// Parameters for updating a unit.
13#[derive(Default)]
14pub struct UpdateParams {
15    pub title: Option<String>,
16    pub description: Option<String>,
17    pub acceptance: Option<String>,
18    pub notes: Option<String>,
19    pub design: Option<String>,
20    pub status: Option<String>,
21    pub priority: Option<u8>,
22    pub assignee: Option<String>,
23    pub add_label: Option<String>,
24    pub remove_label: Option<String>,
25    pub decisions: Vec<String>,
26    pub resolve_decisions: Vec<String>,
27}
28
29/// Result of updating a unit.
30pub struct UpdateResult {
31    pub unit: Unit,
32    pub path: PathBuf,
33}
34
35/// Update a unit's fields and persist changes.
36pub fn update(mana_dir: &Path, id: &str, params: UpdateParams) -> Result<UpdateResult> {
37    if let Some(p) = params.priority {
38        validate_priority(p)?;
39    }
40
41    let unit_path =
42        find_unit_file(mana_dir, id).with_context(|| format!("Unit not found: {}", id))?;
43    let mut unit =
44        Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
45
46    let project_root = mana_dir
47        .parent()
48        .ok_or_else(|| anyhow!("Cannot determine project root from units dir"))?;
49
50    let pre_passed = execute_hook(HookEvent::PreUpdate, &unit, project_root, None)
51        .context("Pre-update hook execution failed")?;
52    if !pre_passed {
53        return Err(anyhow!("Pre-update hook rejected unit update"));
54    }
55
56    if let Some(v) = params.title {
57        unit.title = v;
58    }
59    if let Some(v) = params.description {
60        unit.description = Some(v);
61    }
62    if let Some(v) = params.acceptance {
63        unit.acceptance = Some(v);
64    }
65
66    if let Some(new_notes) = params.notes {
67        let timestamp = Utc::now().to_rfc3339();
68        unit.notes = Some(match unit.notes {
69            Some(existing) => format!("{}\n\n---\n{}\n{}", existing, timestamp, new_notes),
70            None => format!("---\n{}\n{}", timestamp, new_notes),
71        });
72    }
73
74    if let Some(v) = params.design {
75        unit.design = Some(v);
76    }
77
78    if let Some(new_status) = params.status {
79        unit.status =
80            parse_status(&new_status).ok_or_else(|| anyhow!("Invalid status: {}", new_status))?;
81    }
82
83    if let Some(v) = params.priority {
84        unit.priority = v;
85    }
86    if let Some(v) = params.assignee {
87        unit.assignee = Some(v);
88    }
89
90    if let Some(label) = params.add_label {
91        if !unit.labels.contains(&label) {
92            unit.labels.push(label);
93        }
94    }
95    if let Some(label) = params.remove_label {
96        unit.labels.retain(|l| l != &label);
97    }
98
99    for decision in params.decisions {
100        unit.decisions.push(decision);
101    }
102
103    for resolve in &params.resolve_decisions {
104        if let Ok(idx) = resolve.parse::<usize>() {
105            if idx < unit.decisions.len() {
106                unit.decisions.remove(idx);
107            } else {
108                return Err(anyhow!(
109                    "Decision index {} out of range (unit has {} decisions)",
110                    idx,
111                    unit.decisions.len()
112                ));
113            }
114        } else {
115            let before = unit.decisions.len();
116            unit.decisions.retain(|d| d != resolve);
117            if unit.decisions.len() == before {
118                return Err(anyhow!("No decision matching '{}' found", resolve));
119            }
120        }
121    }
122
123    unit.updated_at = Utc::now();
124    unit.to_file(&unit_path)
125        .with_context(|| format!("Failed to save unit: {}", id))?;
126
127    let index = Index::build(mana_dir)?;
128    index.save(mana_dir)?;
129
130    if let Err(e) = execute_hook(HookEvent::PostUpdate, &unit, project_root, None) {
131        eprintln!("Warning: post-update hook failed: {}", e);
132    }
133
134    Ok(UpdateResult {
135        unit,
136        path: unit_path,
137    })
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::ops::create::{self, tests::minimal_params};
144    use crate::unit::Status;
145    use std::fs;
146    use tempfile::TempDir;
147
148    fn setup() -> (TempDir, PathBuf) {
149        let dir = TempDir::new().unwrap();
150        let bd = dir.path().join(".mana");
151        fs::create_dir(&bd).unwrap();
152        crate::config::Config {
153            project: "test".to_string(),
154            next_id: 1,
155            auto_close_parent: true,
156            run: None,
157            plan: None,
158            max_loops: 10,
159            max_concurrent: 4,
160            poll_interval: 30,
161            extends: vec![],
162            rules_file: None,
163            file_locking: false,
164            worktree: false,
165            on_close: None,
166            on_fail: None,
167            post_plan: None,
168            verify_timeout: None,
169            review: None,
170            user: None,
171            user_email: None,
172            auto_commit: false,
173            commit_template: None,
174            research: None,
175            run_model: None,
176            plan_model: None,
177            review_model: None,
178            research_model: None,
179            batch_verify: false,
180            memory_reserve_mb: 0,
181            notify: None,
182        }
183        .save(&bd)
184        .unwrap();
185        (dir, bd)
186    }
187
188    fn empty_params() -> UpdateParams {
189        UpdateParams {
190            title: None,
191            description: None,
192            acceptance: None,
193            notes: None,
194            design: None,
195            status: None,
196            priority: None,
197            assignee: None,
198            add_label: None,
199            remove_label: None,
200            decisions: vec![],
201            resolve_decisions: vec![],
202        }
203    }
204
205    #[test]
206    fn update_title() {
207        let (_dir, bd) = setup();
208        create::create(&bd, minimal_params("Old")).unwrap();
209        let r = update(
210            &bd,
211            "1",
212            UpdateParams {
213                title: Some("New".into()),
214                ..empty_params()
215            },
216        )
217        .unwrap();
218        assert_eq!(r.unit.title, "New");
219    }
220
221    #[test]
222    fn update_status() {
223        let (_dir, bd) = setup();
224        create::create(&bd, minimal_params("Task")).unwrap();
225        let r = update(
226            &bd,
227            "1",
228            UpdateParams {
229                status: Some("in_progress".into()),
230                ..empty_params()
231            },
232        )
233        .unwrap();
234        assert_eq!(r.unit.status, Status::InProgress);
235    }
236
237    #[test]
238    fn update_appends_notes() {
239        let (_dir, bd) = setup();
240        create::create(&bd, minimal_params("Task")).unwrap();
241        update(
242            &bd,
243            "1",
244            UpdateParams {
245                notes: Some("First".into()),
246                ..empty_params()
247            },
248        )
249        .unwrap();
250        let r = update(
251            &bd,
252            "1",
253            UpdateParams {
254                notes: Some("Second".into()),
255                ..empty_params()
256            },
257        )
258        .unwrap();
259        let notes = r.unit.notes.unwrap();
260        assert!(notes.contains("First"));
261        assert!(notes.contains("Second"));
262    }
263
264    #[test]
265    fn update_nonexistent() {
266        let (_dir, bd) = setup();
267        assert!(update(
268            &bd,
269            "99",
270            UpdateParams {
271                title: Some("x".into()),
272                ..empty_params()
273            }
274        )
275        .is_err());
276    }
277
278    #[test]
279    fn update_rebuilds_index() {
280        let (_dir, bd) = setup();
281        create::create(&bd, minimal_params("Task")).unwrap();
282        update(
283            &bd,
284            "1",
285            UpdateParams {
286                title: Some("Updated".into()),
287                ..empty_params()
288            },
289        )
290        .unwrap();
291        let index = Index::load(&bd).unwrap();
292        assert_eq!(index.units[0].title, "Updated");
293    }
294}