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