1use std::path::Path;
2
3use anyhow::Result;
4use mana_core::ops::update as ops_update;
5
6#[allow(clippy::too_many_arguments)]
13pub fn cmd_update(
14 mana_dir: &Path,
15 id: &str,
16 title: Option<String>,
17 description: Option<String>,
18 acceptance: Option<String>,
19 notes: Option<String>,
20 design: Option<String>,
21 status: Option<String>,
22 priority: Option<u8>,
23 assignee: Option<String>,
24 add_label: Option<String>,
25 remove_label: Option<String>,
26 decisions: Vec<String>,
27 resolve_decisions: Vec<String>,
28) -> Result<()> {
29 let result = ops_update::update(
30 mana_dir,
31 id,
32 ops_update::UpdateParams {
33 title,
34 description,
35 acceptance,
36 notes,
37 design,
38 status,
39 priority,
40 assignee,
41 add_label,
42 remove_label,
43 decisions,
44 resolve_decisions,
45 },
46 )?;
47
48 println!("Updated unit {}: {}", id, result.unit.title);
49 Ok(())
50}
51
52#[cfg(test)]
53mod tests {
54 use super::*;
55 use std::fs;
56
57 use crate::index::Index;
58 use crate::unit::{Status, Unit};
59 use crate::util::title_to_slug;
60 use tempfile::TempDir;
61
62 fn setup_test_mana_dir() -> (TempDir, std::path::PathBuf) {
63 let dir = TempDir::new().unwrap();
64 let mana_dir = dir.path().join(".mana");
65 fs::create_dir(&mana_dir).unwrap();
66 (dir, mana_dir)
67 }
68
69 #[test]
70 fn test_update_title() {
71 let (_dir, mana_dir) = setup_test_mana_dir();
72 let unit = Unit::new("1", "Original title");
73 let slug = title_to_slug(&unit.title);
74 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
75 .unwrap();
76
77 cmd_update(
78 &mana_dir,
79 "1",
80 Some("New title".to_string()),
81 None,
82 None,
83 None,
84 None,
85 None,
86 None,
87 None,
88 None,
89 None,
90 vec![],
91 vec![],
92 )
93 .unwrap();
94
95 let updated =
96 Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
97 assert_eq!(updated.title, "New title");
98 }
99
100 #[test]
101 fn test_update_notes_appends() {
102 let (_dir, mana_dir) = setup_test_mana_dir();
103 let mut unit = Unit::new("1", "Test");
104 unit.notes = Some("First note".to_string());
105 let slug = title_to_slug(&unit.title);
106 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
107 .unwrap();
108
109 cmd_update(
110 &mana_dir,
111 "1",
112 None,
113 None,
114 None,
115 Some("Second note".to_string()),
116 None,
117 None,
118 None,
119 None,
120 None,
121 None,
122 vec![],
123 vec![],
124 )
125 .unwrap();
126
127 let updated =
128 Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
129 let notes = updated.notes.unwrap();
130 assert!(notes.contains("First note"));
131 assert!(notes.contains("Second note"));
132 assert!(notes.contains("---"));
133 }
134
135 #[test]
136 fn test_update_notes_creates_with_timestamp() {
137 let (_dir, mana_dir) = setup_test_mana_dir();
138 let unit = Unit::new("1", "Test");
139 let slug = title_to_slug(&unit.title);
140 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
141 .unwrap();
142
143 cmd_update(
144 &mana_dir,
145 "1",
146 None,
147 None,
148 None,
149 Some("First note".to_string()),
150 None,
151 None,
152 None,
153 None,
154 None,
155 None,
156 vec![],
157 vec![],
158 )
159 .unwrap();
160
161 let updated =
162 Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
163 let notes = updated.notes.unwrap();
164 assert!(notes.contains("First note"));
165 assert!(notes.contains("---"));
166 assert!(notes.contains("T")); }
168
169 #[test]
170 fn test_update_status() {
171 let (_dir, mana_dir) = setup_test_mana_dir();
172 let unit = Unit::new("1", "Test");
173 let slug = title_to_slug(&unit.title);
174 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
175 .unwrap();
176
177 cmd_update(
178 &mana_dir,
179 "1",
180 None,
181 None,
182 None,
183 None,
184 None,
185 Some("in_progress".to_string()),
186 None,
187 None,
188 None,
189 None,
190 vec![],
191 vec![],
192 )
193 .unwrap();
194
195 let updated =
196 Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
197 assert_eq!(updated.status, Status::InProgress);
198 }
199
200 #[test]
201 fn test_update_priority() {
202 let (_dir, mana_dir) = setup_test_mana_dir();
203 let unit = Unit::new("1", "Test");
204 let slug = title_to_slug(&unit.title);
205 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
206 .unwrap();
207
208 cmd_update(
209 &mana_dir,
210 "1",
211 None,
212 None,
213 None,
214 None,
215 None,
216 None,
217 Some(1),
218 None,
219 None,
220 None,
221 vec![],
222 vec![],
223 )
224 .unwrap();
225
226 let updated =
227 Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
228 assert_eq!(updated.priority, 1);
229 }
230
231 #[test]
232 fn test_update_add_label() {
233 let (_dir, mana_dir) = setup_test_mana_dir();
234 let unit = Unit::new("1", "Test");
235 let slug = title_to_slug(&unit.title);
236 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
237 .unwrap();
238
239 cmd_update(
240 &mana_dir,
241 "1",
242 None,
243 None,
244 None,
245 None,
246 None,
247 None,
248 None,
249 None,
250 Some("urgent".to_string()),
251 None,
252 vec![],
253 vec![],
254 )
255 .unwrap();
256
257 let updated =
258 Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
259 assert!(updated.labels.contains(&"urgent".to_string()));
260 }
261
262 #[test]
263 fn test_update_remove_label() {
264 let (_dir, mana_dir) = setup_test_mana_dir();
265 let mut unit = Unit::new("1", "Test");
266 unit.labels = vec!["urgent".to_string(), "bug".to_string()];
267 let slug = title_to_slug(&unit.title);
268 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
269 .unwrap();
270
271 cmd_update(
272 &mana_dir,
273 "1",
274 None,
275 None,
276 None,
277 None,
278 None,
279 None,
280 None,
281 None,
282 None,
283 Some("urgent".to_string()),
284 vec![],
285 vec![],
286 )
287 .unwrap();
288
289 let updated =
290 Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
291 assert!(!updated.labels.contains(&"urgent".to_string()));
292 assert!(updated.labels.contains(&"bug".to_string()));
293 }
294
295 #[test]
296 fn test_update_nonexistent_unit() {
297 let (_dir, mana_dir) = setup_test_mana_dir();
298 let result = cmd_update(
299 &mana_dir,
300 "99",
301 Some("Title".to_string()),
302 None,
303 None,
304 None,
305 None,
306 None,
307 None,
308 None,
309 None,
310 None,
311 vec![],
312 vec![],
313 );
314 assert!(result.is_err());
315 }
316
317 #[test]
318 fn test_update_multiple_fields() {
319 let (_dir, mana_dir) = setup_test_mana_dir();
320 let unit = Unit::new("1", "Original");
321 let slug = title_to_slug(&unit.title);
322 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
323 .unwrap();
324
325 cmd_update(
326 &mana_dir,
327 "1",
328 Some("New title".to_string()),
329 Some("New desc".to_string()),
330 None,
331 None,
332 None,
333 Some("closed".to_string()),
334 Some(0),
335 None,
336 None,
337 None,
338 vec![],
339 vec![],
340 )
341 .unwrap();
342
343 let updated =
344 Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
345 assert_eq!(updated.title, "New title");
346 assert_eq!(updated.description, Some("New desc".to_string()));
347 assert_eq!(updated.status, Status::Closed);
348 assert_eq!(updated.priority, 0);
349 }
350
351 #[test]
352 fn test_update_rebuilds_index() {
353 let (_dir, mana_dir) = setup_test_mana_dir();
354 let unit = Unit::new("1", "Original");
355 let slug = title_to_slug(&unit.title);
356 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
357 .unwrap();
358
359 assert!(!mana_dir.join("index.yaml").exists());
361
362 cmd_update(
363 &mana_dir,
364 "1",
365 Some("New title".to_string()),
366 None,
367 None,
368 None,
369 None,
370 None,
371 None,
372 None,
373 None,
374 None,
375 vec![],
376 vec![],
377 )
378 .unwrap();
379
380 assert!(mana_dir.join("index.yaml").exists());
382
383 let index = Index::load(&mana_dir).unwrap();
384 assert_eq!(index.units.len(), 1);
385 assert_eq!(index.units[0].title, "New title");
386 }
387
388 #[test]
389 fn test_update_rejects_priority_too_high() {
390 let (_dir, mana_dir) = setup_test_mana_dir();
391 let unit = Unit::new("1", "Test");
392 let slug = title_to_slug(&unit.title);
393 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
394 .unwrap();
395
396 let result = cmd_update(
397 &mana_dir,
398 "1",
399 None,
400 None,
401 None,
402 None,
403 None,
404 None,
405 Some(5),
406 None,
407 None,
408 None,
409 vec![],
410 vec![],
411 );
412 assert!(result.is_err(), "Should reject priority > 4");
413 let err_msg = result.unwrap_err().to_string();
414 assert!(
415 err_msg.contains("priority"),
416 "Error should mention priority"
417 );
418 }
419
420 #[test]
421 fn test_update_accepts_valid_priorities() {
422 for priority in 0..=4 {
423 let (_dir, mana_dir) = setup_test_mana_dir();
424 let unit = Unit::new("1", "Test");
425 let slug = title_to_slug(&unit.title);
426 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
427 .unwrap();
428
429 let result = cmd_update(
430 &mana_dir,
431 "1",
432 None,
433 None,
434 None,
435 None,
436 None,
437 None,
438 Some(priority),
439 None,
440 None,
441 None,
442 vec![],
443 vec![],
444 );
445 assert!(result.is_ok(), "Priority {} should be valid", priority);
446
447 let updated =
448 Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
449 assert_eq!(updated.priority, priority);
450 }
451 }
452
453 #[test]
458 fn test_pre_update_hook_skipped_when_not_trusted() {
459 let (_dir, mana_dir) = setup_test_mana_dir();
460 let unit = Unit::new("1", "Original");
461 let slug = title_to_slug(&unit.title);
462 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
463 .unwrap();
464
465 let result = cmd_update(
467 &mana_dir,
468 "1",
469 Some("New title".to_string()),
470 None,
471 None,
472 None,
473 None,
474 None,
475 None,
476 None,
477 None,
478 None,
479 vec![],
480 vec![],
481 );
482 assert!(
483 result.is_ok(),
484 "Update should succeed when hooks not trusted"
485 );
486
487 let updated =
488 Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
489 assert_eq!(updated.title, "New title");
490 }
491
492 #[test]
493 fn test_pre_update_hook_rejects_update_when_fails() {
494 use crate::hooks::create_trust;
495 use std::os::unix::fs::PermissionsExt;
496
497 let (dir, mana_dir) = setup_test_mana_dir();
498 let project_root = dir.path();
499 let unit = Unit::new("1", "Original");
500 let slug = title_to_slug(&unit.title);
501 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
502 .unwrap();
503
504 create_trust(project_root).unwrap();
506 let hooks_dir = project_root.join(".mana").join("hooks");
507 fs::create_dir_all(&hooks_dir).unwrap();
508 let hook_path = hooks_dir.join("pre-update");
509 fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
510
511 #[cfg(unix)]
512 {
513 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
514 }
515
516 let result = cmd_update(
518 &mana_dir,
519 "1",
520 Some("New title".to_string()),
521 None,
522 None,
523 None,
524 None,
525 None,
526 None,
527 None,
528 None,
529 None,
530 vec![],
531 vec![],
532 );
533 assert!(
534 result.is_err(),
535 "Update should fail when pre-update hook rejects"
536 );
537 assert!(result.unwrap_err().to_string().contains("rejected"));
538
539 let unchanged =
541 Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
542 assert_eq!(unchanged.title, "Original");
543 }
544
545 #[test]
546 fn test_pre_update_hook_allows_update_when_passes() {
547 use crate::hooks::create_trust;
548 use std::os::unix::fs::PermissionsExt;
549
550 let (dir, mana_dir) = setup_test_mana_dir();
551 let project_root = dir.path();
552 let unit = Unit::new("1", "Original");
553 let slug = title_to_slug(&unit.title);
554 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
555 .unwrap();
556
557 create_trust(project_root).unwrap();
559 let hooks_dir = project_root.join(".mana").join("hooks");
560 fs::create_dir_all(&hooks_dir).unwrap();
561 let hook_path = hooks_dir.join("pre-update");
562 fs::write(&hook_path, "#!/bin/bash\nexit 0").unwrap();
563
564 #[cfg(unix)]
565 {
566 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
567 }
568
569 let result = cmd_update(
571 &mana_dir,
572 "1",
573 Some("New title".to_string()),
574 None,
575 None,
576 None,
577 None,
578 None,
579 None,
580 None,
581 None,
582 None,
583 vec![],
584 vec![],
585 );
586 assert!(
587 result.is_ok(),
588 "Update should succeed when pre-update hook passes"
589 );
590
591 let updated =
593 Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
594 assert_eq!(updated.title, "New title");
595 }
596
597 #[test]
598 fn test_post_update_hook_runs_after_successful_update() {
599 use crate::hooks::create_trust;
600 use std::os::unix::fs::PermissionsExt;
601
602 let (dir, mana_dir) = setup_test_mana_dir();
603 let project_root = dir.path();
604 let unit = Unit::new("1", "Original");
605 let slug = title_to_slug(&unit.title);
606 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
607 .unwrap();
608
609 create_trust(project_root).unwrap();
611 let hooks_dir = project_root.join(".mana").join("hooks");
612 fs::create_dir_all(&hooks_dir).unwrap();
613 let hook_path = hooks_dir.join("post-update");
614 let marker_path = project_root.join("post_update_ran");
615 let marker_path_str = marker_path.to_string_lossy();
616
617 fs::write(
618 &hook_path,
619 format!(
620 "#!/bin/bash\necho 'post-update hook ran' > {}",
621 marker_path_str
622 ),
623 )
624 .unwrap();
625
626 #[cfg(unix)]
627 {
628 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
629 }
630
631 cmd_update(
633 &mana_dir,
634 "1",
635 Some("New title".to_string()),
636 None,
637 None,
638 None,
639 None,
640 None,
641 None,
642 None,
643 None,
644 None,
645 vec![],
646 vec![],
647 )
648 .unwrap();
649
650 let updated =
652 Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
653 assert_eq!(updated.title, "New title");
654
655 assert!(marker_path.exists(), "Post-update hook should have run");
657 }
658
659 #[test]
660 fn test_post_update_hook_failure_does_not_prevent_update() {
661 use crate::hooks::create_trust;
662 use std::os::unix::fs::PermissionsExt;
663
664 let (dir, mana_dir) = setup_test_mana_dir();
665 let project_root = dir.path();
666 let unit = Unit::new("1", "Original");
667 let slug = title_to_slug(&unit.title);
668 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
669 .unwrap();
670
671 create_trust(project_root).unwrap();
673 let hooks_dir = project_root.join(".mana").join("hooks");
674 fs::create_dir_all(&hooks_dir).unwrap();
675 let hook_path = hooks_dir.join("post-update");
676 fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
677
678 #[cfg(unix)]
679 {
680 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
681 }
682
683 let result = cmd_update(
685 &mana_dir,
686 "1",
687 Some("New title".to_string()),
688 None,
689 None,
690 None,
691 None,
692 None,
693 None,
694 None,
695 None,
696 None,
697 vec![],
698 vec![],
699 );
700 assert!(
701 result.is_ok(),
702 "Update should succeed even if post-update hook fails"
703 );
704
705 let updated =
707 Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
708 assert_eq!(updated.title, "New title");
709 }
710
711 #[test]
712 fn test_update_with_multiple_fields_triggers_hooks() {
713 use crate::hooks::create_trust;
714 use std::os::unix::fs::PermissionsExt;
715
716 let (dir, mana_dir) = setup_test_mana_dir();
717 let project_root = dir.path();
718 let unit = Unit::new("1", "Original");
719 let slug = title_to_slug(&unit.title);
720 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
721 .unwrap();
722
723 create_trust(project_root).unwrap();
725 let hooks_dir = project_root.join(".mana").join("hooks");
726 fs::create_dir_all(&hooks_dir).unwrap();
727
728 let pre_hook = hooks_dir.join("pre-update");
729 fs::write(&pre_hook, "#!/bin/bash\nexit 0").unwrap();
730
731 let post_hook = hooks_dir.join("post-update");
732 fs::write(&post_hook, "#!/bin/bash\nexit 0").unwrap();
733
734 #[cfg(unix)]
735 {
736 fs::set_permissions(&pre_hook, fs::Permissions::from_mode(0o755)).unwrap();
737 fs::set_permissions(&post_hook, fs::Permissions::from_mode(0o755)).unwrap();
738 }
739
740 let result = cmd_update(
742 &mana_dir,
743 "1",
744 Some("New title".to_string()),
745 Some("New desc".to_string()),
746 None,
747 None,
748 None,
749 Some("in_progress".to_string()),
750 None,
751 None,
752 None,
753 None,
754 vec![],
755 vec![],
756 );
757 assert!(result.is_ok());
758
759 let updated =
761 Unit::from_file(crate::discovery::find_unit_file(&mana_dir, "1").unwrap()).unwrap();
762 assert_eq!(updated.title, "New title");
763 assert_eq!(updated.description, Some("New desc".to_string()));
764 assert_eq!(updated.status, Status::InProgress);
765 }
766}