Skip to main content

bn/commands/
update.rs

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/// Update a bean's fields based on provided flags.
13///
14/// - title, description, acceptance, design, priority, assignee, status: replace
15/// - notes: append with timestamp separator
16/// - labels: add/remove operations
17/// - updates updated_at and rebuilds index
18#[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    // Validate priority if provided
34    if let Some(p) = priority {
35        crate::bean::validate_priority(p)?;
36    }
37
38    // Load the bean using find_bean_file
39    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    // Get project root for hooks (parent of .beans)
46    let project_root = beans_dir
47        .parent()
48        .ok_or_else(|| anyhow!("Cannot determine project root from beans dir"))?;
49
50    // Call pre-update hook (blocking - abort if it fails)
51    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    // Apply updates
59    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        // Append notes with timestamp separator
73        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    // Update timestamp
109    bean.updated_at = Utc::now();
110
111    // Write back to the discovered path (preserves slug)
112    bean.to_file(&bean_path)
113        .with_context(|| format!("Failed to save bean: {}", id))?;
114
115    // Rebuild index
116    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    // Call post-update hook (non-blocking - log warning if it fails)
124    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")); // ISO 8601 has T for date-time
238    }
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        // Index doesn't exist yet
419        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        // Index should be created
438        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    // =====================================================================
508    // Hook Tests
509    // =====================================================================
510
511    #[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        // Update should succeed even without hooks (untrusted)
520        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        // Enable trust and create failing hook
557        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        // Update should fail
569        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        // Bean should not be modified
590        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        // Enable trust and create passing hook
608        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        // Update should succeed
620        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        // Bean should be modified
640        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        // Enable trust and create post-update hook that writes a marker file
658        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        // Update bean
680        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        // Bean should be updated
697        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        // Post-update hook should have run (marker file created)
702        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        // Enable trust and create failing post-update hook
718        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        // Update should still succeed even though post-hook fails
730        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        // Bean should still be modified
750        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        // Enable trust and create hooks
768        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        // Update multiple fields
785        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        // Verify all changes applied
802        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}