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#[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
29pub struct UpdateResult {
31 pub unit: Unit,
32 pub path: PathBuf,
33}
34
35pub 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 ¶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 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}