1use std::path::{Path, PathBuf};
2
3use anyhow::{anyhow, Context, Result};
4use chrono::Utc;
5
6use crate::hooks::{execute_hook, HookEvent};
7use crate::index::{Index, LockedIndex};
8use crate::resolve::resolve_unit;
9use crate::unit::{validate_priority, Unit};
10use crate::util::parse_status;
11
12#[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#[derive(serde::Serialize)]
31pub struct UpdateResult {
32 pub unit: Unit,
33 pub path: PathBuf,
34}
35
36pub fn update(mana_dir: &Path, id: &str, params: UpdateParams) -> Result<UpdateResult> {
38 if let Some(p) = params.priority {
39 validate_priority(p)?;
40 }
41
42 let resolved = resolve_unit(mana_dir, id)?;
43 let unit_path = resolved.path;
44 let mut unit = resolved.unit;
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 ¶ms.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 mut locked = LockedIndex::acquire(mana_dir)?;
128 locked.index = Index::build(mana_dir)?;
129 locked.save_and_release()?;
130
131 if let Err(e) = execute_hook(HookEvent::PostUpdate, &unit, project_root, None) {
132 eprintln!("Warning: post-update hook failed: {}", e);
133 }
134
135 Ok(UpdateResult {
136 unit,
137 path: unit_path,
138 })
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::ops::create::{self, tests::minimal_params};
145 use crate::unit::Status;
146 use std::fs;
147 use tempfile::TempDir;
148
149 fn setup() -> (TempDir, PathBuf) {
150 let dir = TempDir::new().unwrap();
151 let bd = dir.path().join(".mana");
152 fs::create_dir(&bd).unwrap();
153 crate::config::Config {
154 project: "test".to_string(),
155 next_id: 1,
156 auto_close_parent: true,
157 run: None,
158 plan: None,
159 max_loops: 10,
160 max_concurrent: 4,
161 poll_interval: 30,
162 extends: vec![],
163 rules_file: None,
164 file_locking: false,
165 worktree: false,
166 on_close: None,
167 on_fail: 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}