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::tokens::calculate_tokens;
11use crate::util::parse_status;
12
13/// Update a bean's fields based on provided flags.
14///
15/// - title, description, acceptance, design, priority, assignee, status: replace
16/// - notes: append with timestamp separator
17/// - labels: add/remove operations
18/// - updates updated_at and rebuilds index
19#[allow(clippy::too_many_arguments)]
20pub fn cmd_update(
21    beans_dir: &Path,
22    id: &str,
23    title: Option<String>,
24    description: Option<String>,
25    acceptance: Option<String>,
26    notes: Option<String>,
27    design: Option<String>,
28    status: Option<String>,
29    priority: Option<u8>,
30    assignee: Option<String>,
31    add_label: Option<String>,
32    remove_label: Option<String>,
33) -> Result<()> {
34    // Validate priority if provided
35    if let Some(p) = priority {
36        crate::bean::validate_priority(p)?;
37    }
38
39    // Load the bean using find_bean_file
40    let bean_path =
41        find_bean_file(beans_dir, id).with_context(|| format!("Bean not found: {}", id))?;
42
43    let mut bean =
44        Bean::from_file(&bean_path).with_context(|| format!("Failed to load bean: {}", id))?;
45
46    // Get project root for hooks (parent of .beans)
47    let project_root = beans_dir
48        .parent()
49        .ok_or_else(|| anyhow!("Cannot determine project root from beans dir"))?;
50
51    // Call pre-update hook (blocking - abort if it fails)
52    let pre_passed = execute_hook(HookEvent::PreUpdate, &bean, project_root, None)
53        .context("Pre-update hook execution failed")?;
54
55    if !pre_passed {
56        return Err(anyhow!("Pre-update hook rejected bean update"));
57    }
58
59    // Track if content changes that affect token count
60    let mut content_changed = false;
61
62    // Apply updates
63    if let Some(new_title) = title {
64        bean.title = new_title;
65    }
66
67    if let Some(new_description) = description {
68        bean.description = Some(new_description);
69        content_changed = true;
70    }
71
72    if let Some(new_acceptance) = acceptance {
73        bean.acceptance = Some(new_acceptance);
74        content_changed = true;
75    }
76
77    if let Some(new_notes) = notes {
78        // Append notes with timestamp separator
79        let timestamp = Utc::now().to_rfc3339();
80        if let Some(existing) = bean.notes {
81            bean.notes = Some(format!("{}\n\n---\n{}\n{}", existing, timestamp, new_notes));
82        } else {
83            bean.notes = Some(format!("---\n{}\n{}", timestamp, new_notes));
84        }
85        content_changed = true;
86    }
87
88    if let Some(new_design) = design {
89        bean.design = Some(new_design);
90    }
91
92    if let Some(new_status) = status {
93        bean.status =
94            parse_status(&new_status).ok_or_else(|| anyhow!("Invalid status: {}", new_status))?;
95    }
96
97    if let Some(new_priority) = priority {
98        bean.priority = new_priority;
99    }
100
101    if let Some(new_assignee) = assignee {
102        bean.assignee = Some(new_assignee);
103    }
104
105    if let Some(label) = add_label {
106        if !bean.labels.contains(&label) {
107            bean.labels.push(label);
108        }
109    }
110
111    if let Some(label) = remove_label {
112        bean.labels.retain(|l| l != &label);
113    }
114
115    // Recalculate tokens if content changed
116    if content_changed {
117        let tokens = calculate_tokens(&bean, project_root);
118        bean.tokens = Some(tokens);
119        bean.tokens_updated = Some(Utc::now());
120    }
121
122    // Update timestamp
123    bean.updated_at = Utc::now();
124
125    // Write back to the discovered path (preserves slug)
126    bean.to_file(&bean_path)
127        .with_context(|| format!("Failed to save bean: {}", id))?;
128
129    // Rebuild index
130    let index = Index::build(beans_dir).with_context(|| "Failed to rebuild index")?;
131    index
132        .save(beans_dir)
133        .with_context(|| "Failed to save index")?;
134
135    println!("Updated bean {}: {}", id, bean.title);
136
137    // Call post-update hook (non-blocking - log warning if it fails)
138    if let Err(e) = execute_hook(HookEvent::PostUpdate, &bean, project_root, None) {
139        eprintln!("Warning: post-update hook failed: {}", e);
140    }
141
142    Ok(())
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::bean::Status;
149    use crate::util::title_to_slug;
150    use std::fs;
151    use tempfile::TempDir;
152
153    fn setup_test_beans_dir() -> (TempDir, std::path::PathBuf) {
154        let dir = TempDir::new().unwrap();
155        let beans_dir = dir.path().join(".beans");
156        fs::create_dir(&beans_dir).unwrap();
157        (dir, beans_dir)
158    }
159
160    #[test]
161    fn test_update_title() {
162        let (_dir, beans_dir) = setup_test_beans_dir();
163        let bean = Bean::new("1", "Original title");
164        let slug = title_to_slug(&bean.title);
165        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
166            .unwrap();
167
168        cmd_update(
169            &beans_dir,
170            "1",
171            Some("New title".to_string()),
172            None,
173            None,
174            None,
175            None,
176            None,
177            None,
178            None,
179            None,
180            None,
181        )
182        .unwrap();
183
184        let updated =
185            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
186        assert_eq!(updated.title, "New title");
187    }
188
189    #[test]
190    fn test_update_notes_appends() {
191        let (_dir, beans_dir) = setup_test_beans_dir();
192        let mut bean = Bean::new("1", "Test");
193        bean.notes = Some("First note".to_string());
194        let slug = title_to_slug(&bean.title);
195        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
196            .unwrap();
197
198        cmd_update(
199            &beans_dir,
200            "1",
201            None,
202            None,
203            None,
204            Some("Second note".to_string()),
205            None,
206            None,
207            None,
208            None,
209            None,
210            None,
211        )
212        .unwrap();
213
214        let updated =
215            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
216        let notes = updated.notes.unwrap();
217        assert!(notes.contains("First note"));
218        assert!(notes.contains("Second note"));
219        assert!(notes.contains("---"));
220    }
221
222    #[test]
223    fn test_update_notes_creates_with_timestamp() {
224        let (_dir, beans_dir) = setup_test_beans_dir();
225        let bean = Bean::new("1", "Test");
226        let slug = title_to_slug(&bean.title);
227        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
228            .unwrap();
229
230        cmd_update(
231            &beans_dir,
232            "1",
233            None,
234            None,
235            None,
236            Some("First note".to_string()),
237            None,
238            None,
239            None,
240            None,
241            None,
242            None,
243        )
244        .unwrap();
245
246        let updated =
247            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
248        let notes = updated.notes.unwrap();
249        assert!(notes.contains("First note"));
250        assert!(notes.contains("---"));
251        assert!(notes.contains("T")); // ISO 8601 has T for date-time
252    }
253
254    #[test]
255    fn test_update_status() {
256        let (_dir, beans_dir) = setup_test_beans_dir();
257        let bean = Bean::new("1", "Test");
258        let slug = title_to_slug(&bean.title);
259        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
260            .unwrap();
261
262        cmd_update(
263            &beans_dir,
264            "1",
265            None,
266            None,
267            None,
268            None,
269            None,
270            Some("in_progress".to_string()),
271            None,
272            None,
273            None,
274            None,
275        )
276        .unwrap();
277
278        let updated =
279            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
280        assert_eq!(updated.status, Status::InProgress);
281    }
282
283    #[test]
284    fn test_update_priority() {
285        let (_dir, beans_dir) = setup_test_beans_dir();
286        let bean = Bean::new("1", "Test");
287        let slug = title_to_slug(&bean.title);
288        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
289            .unwrap();
290
291        cmd_update(
292            &beans_dir,
293            "1",
294            None,
295            None,
296            None,
297            None,
298            None,
299            None,
300            Some(1),
301            None,
302            None,
303            None,
304        )
305        .unwrap();
306
307        let updated =
308            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
309        assert_eq!(updated.priority, 1);
310    }
311
312    #[test]
313    fn test_update_add_label() {
314        let (_dir, beans_dir) = setup_test_beans_dir();
315        let bean = Bean::new("1", "Test");
316        let slug = title_to_slug(&bean.title);
317        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
318            .unwrap();
319
320        cmd_update(
321            &beans_dir,
322            "1",
323            None,
324            None,
325            None,
326            None,
327            None,
328            None,
329            None,
330            None,
331            Some("urgent".to_string()),
332            None,
333        )
334        .unwrap();
335
336        let updated =
337            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
338        assert!(updated.labels.contains(&"urgent".to_string()));
339    }
340
341    #[test]
342    fn test_update_remove_label() {
343        let (_dir, beans_dir) = setup_test_beans_dir();
344        let mut bean = Bean::new("1", "Test");
345        bean.labels = vec!["urgent".to_string(), "bug".to_string()];
346        let slug = title_to_slug(&bean.title);
347        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
348            .unwrap();
349
350        cmd_update(
351            &beans_dir,
352            "1",
353            None,
354            None,
355            None,
356            None,
357            None,
358            None,
359            None,
360            None,
361            None,
362            Some("urgent".to_string()),
363        )
364        .unwrap();
365
366        let updated =
367            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
368        assert!(!updated.labels.contains(&"urgent".to_string()));
369        assert!(updated.labels.contains(&"bug".to_string()));
370    }
371
372    #[test]
373    fn test_update_nonexistent_bean() {
374        let (_dir, beans_dir) = setup_test_beans_dir();
375        let result = cmd_update(
376            &beans_dir,
377            "99",
378            Some("Title".to_string()),
379            None,
380            None,
381            None,
382            None,
383            None,
384            None,
385            None,
386            None,
387            None,
388        );
389        assert!(result.is_err());
390    }
391
392    #[test]
393    fn test_update_multiple_fields() {
394        let (_dir, beans_dir) = setup_test_beans_dir();
395        let bean = Bean::new("1", "Original");
396        let slug = title_to_slug(&bean.title);
397        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
398            .unwrap();
399
400        cmd_update(
401            &beans_dir,
402            "1",
403            Some("New title".to_string()),
404            Some("New desc".to_string()),
405            None,
406            None,
407            None,
408            Some("closed".to_string()),
409            Some(0),
410            None,
411            None,
412            None,
413        )
414        .unwrap();
415
416        let updated =
417            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
418        assert_eq!(updated.title, "New title");
419        assert_eq!(updated.description, Some("New desc".to_string()));
420        assert_eq!(updated.status, Status::Closed);
421        assert_eq!(updated.priority, 0);
422    }
423
424    #[test]
425    fn test_update_rebuilds_index() {
426        let (_dir, beans_dir) = setup_test_beans_dir();
427        let bean = Bean::new("1", "Original");
428        let slug = title_to_slug(&bean.title);
429        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
430            .unwrap();
431
432        // Index doesn't exist yet
433        assert!(!beans_dir.join("index.yaml").exists());
434
435        cmd_update(
436            &beans_dir,
437            "1",
438            Some("New title".to_string()),
439            None,
440            None,
441            None,
442            None,
443            None,
444            None,
445            None,
446            None,
447            None,
448        )
449        .unwrap();
450
451        // Index should be created
452        assert!(beans_dir.join("index.yaml").exists());
453
454        let index = Index::load(&beans_dir).unwrap();
455        assert_eq!(index.beans.len(), 1);
456        assert_eq!(index.beans[0].title, "New title");
457    }
458
459    #[test]
460    fn test_update_rejects_priority_too_high() {
461        let (_dir, beans_dir) = setup_test_beans_dir();
462        let bean = Bean::new("1", "Test");
463        let slug = title_to_slug(&bean.title);
464        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
465            .unwrap();
466
467        let result = cmd_update(
468            &beans_dir,
469            "1",
470            None,
471            None,
472            None,
473            None,
474            None,
475            None,
476            Some(5),
477            None,
478            None,
479            None,
480        );
481        assert!(result.is_err(), "Should reject priority > 4");
482        let err_msg = result.unwrap_err().to_string();
483        assert!(
484            err_msg.contains("priority"),
485            "Error should mention priority"
486        );
487    }
488
489    #[test]
490    fn test_update_accepts_valid_priorities() {
491        for priority in 0..=4 {
492            let (_dir, beans_dir) = setup_test_beans_dir();
493            let bean = Bean::new("1", "Test");
494            let slug = title_to_slug(&bean.title);
495            bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
496                .unwrap();
497
498            let result = cmd_update(
499                &beans_dir,
500                "1",
501                None,
502                None,
503                None,
504                None,
505                None,
506                None,
507                Some(priority),
508                None,
509                None,
510                None,
511            );
512            assert!(result.is_ok(), "Priority {} should be valid", priority);
513
514            let updated =
515                Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap())
516                    .unwrap();
517            assert_eq!(updated.priority, priority);
518        }
519    }
520
521    // =====================================================================
522    // Hook Tests
523    // =====================================================================
524
525    #[test]
526    fn test_pre_update_hook_skipped_when_not_trusted() {
527        let (_dir, beans_dir) = setup_test_beans_dir();
528        let bean = Bean::new("1", "Original");
529        let slug = title_to_slug(&bean.title);
530        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
531            .unwrap();
532
533        // Update should succeed even without hooks (untrusted)
534        let result = cmd_update(
535            &beans_dir,
536            "1",
537            Some("New title".to_string()),
538            None,
539            None,
540            None,
541            None,
542            None,
543            None,
544            None,
545            None,
546            None,
547        );
548        assert!(
549            result.is_ok(),
550            "Update should succeed when hooks not trusted"
551        );
552
553        let updated =
554            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
555        assert_eq!(updated.title, "New title");
556    }
557
558    #[test]
559    fn test_pre_update_hook_rejects_update_when_fails() {
560        use crate::hooks::create_trust;
561        use std::os::unix::fs::PermissionsExt;
562
563        let (dir, beans_dir) = setup_test_beans_dir();
564        let project_root = dir.path();
565        let bean = Bean::new("1", "Original");
566        let slug = title_to_slug(&bean.title);
567        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
568            .unwrap();
569
570        // Enable trust and create failing hook
571        create_trust(project_root).unwrap();
572        let hooks_dir = project_root.join(".beans").join("hooks");
573        fs::create_dir_all(&hooks_dir).unwrap();
574        let hook_path = hooks_dir.join("pre-update");
575        fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
576
577        #[cfg(unix)]
578        {
579            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
580        }
581
582        // Update should fail
583        let result = cmd_update(
584            &beans_dir,
585            "1",
586            Some("New title".to_string()),
587            None,
588            None,
589            None,
590            None,
591            None,
592            None,
593            None,
594            None,
595            None,
596        );
597        assert!(
598            result.is_err(),
599            "Update should fail when pre-update hook rejects"
600        );
601        assert!(result.unwrap_err().to_string().contains("rejected"));
602
603        // Bean should not be modified
604        let unchanged =
605            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
606        assert_eq!(unchanged.title, "Original");
607    }
608
609    #[test]
610    fn test_pre_update_hook_allows_update_when_passes() {
611        use crate::hooks::create_trust;
612        use std::os::unix::fs::PermissionsExt;
613
614        let (dir, beans_dir) = setup_test_beans_dir();
615        let project_root = dir.path();
616        let bean = Bean::new("1", "Original");
617        let slug = title_to_slug(&bean.title);
618        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
619            .unwrap();
620
621        // Enable trust and create passing hook
622        create_trust(project_root).unwrap();
623        let hooks_dir = project_root.join(".beans").join("hooks");
624        fs::create_dir_all(&hooks_dir).unwrap();
625        let hook_path = hooks_dir.join("pre-update");
626        fs::write(&hook_path, "#!/bin/bash\nexit 0").unwrap();
627
628        #[cfg(unix)]
629        {
630            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
631        }
632
633        // Update should succeed
634        let result = cmd_update(
635            &beans_dir,
636            "1",
637            Some("New title".to_string()),
638            None,
639            None,
640            None,
641            None,
642            None,
643            None,
644            None,
645            None,
646            None,
647        );
648        assert!(
649            result.is_ok(),
650            "Update should succeed when pre-update hook passes"
651        );
652
653        // Bean should be modified
654        let updated =
655            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
656        assert_eq!(updated.title, "New title");
657    }
658
659    #[test]
660    fn test_post_update_hook_runs_after_successful_update() {
661        use crate::hooks::create_trust;
662        use std::os::unix::fs::PermissionsExt;
663
664        let (dir, beans_dir) = setup_test_beans_dir();
665        let project_root = dir.path();
666        let bean = Bean::new("1", "Original");
667        let slug = title_to_slug(&bean.title);
668        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
669            .unwrap();
670
671        // Enable trust and create post-update hook that writes a marker file
672        create_trust(project_root).unwrap();
673        let hooks_dir = project_root.join(".beans").join("hooks");
674        fs::create_dir_all(&hooks_dir).unwrap();
675        let hook_path = hooks_dir.join("post-update");
676        let marker_path = project_root.join("post_update_ran");
677        let marker_path_str = marker_path.to_string_lossy();
678
679        fs::write(
680            &hook_path,
681            format!(
682                "#!/bin/bash\necho 'post-update hook ran' > {}",
683                marker_path_str
684            ),
685        )
686        .unwrap();
687
688        #[cfg(unix)]
689        {
690            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
691        }
692
693        // Update bean
694        cmd_update(
695            &beans_dir,
696            "1",
697            Some("New title".to_string()),
698            None,
699            None,
700            None,
701            None,
702            None,
703            None,
704            None,
705            None,
706            None,
707        )
708        .unwrap();
709
710        // Bean should be updated
711        let updated =
712            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
713        assert_eq!(updated.title, "New title");
714
715        // Post-update hook should have run (marker file created)
716        assert!(marker_path.exists(), "Post-update hook should have run");
717    }
718
719    #[test]
720    fn test_post_update_hook_failure_does_not_prevent_update() {
721        use crate::hooks::create_trust;
722        use std::os::unix::fs::PermissionsExt;
723
724        let (dir, beans_dir) = setup_test_beans_dir();
725        let project_root = dir.path();
726        let bean = Bean::new("1", "Original");
727        let slug = title_to_slug(&bean.title);
728        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
729            .unwrap();
730
731        // Enable trust and create failing post-update hook
732        create_trust(project_root).unwrap();
733        let hooks_dir = project_root.join(".beans").join("hooks");
734        fs::create_dir_all(&hooks_dir).unwrap();
735        let hook_path = hooks_dir.join("post-update");
736        fs::write(&hook_path, "#!/bin/bash\nexit 1").unwrap();
737
738        #[cfg(unix)]
739        {
740            fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755)).unwrap();
741        }
742
743        // Update should still succeed even though post-hook fails
744        let result = cmd_update(
745            &beans_dir,
746            "1",
747            Some("New title".to_string()),
748            None,
749            None,
750            None,
751            None,
752            None,
753            None,
754            None,
755            None,
756            None,
757        );
758        assert!(
759            result.is_ok(),
760            "Update should succeed even if post-update hook fails"
761        );
762
763        // Bean should still be modified
764        let updated =
765            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
766        assert_eq!(updated.title, "New title");
767    }
768
769    #[test]
770    fn test_update_with_multiple_fields_triggers_hooks() {
771        use crate::hooks::create_trust;
772        use std::os::unix::fs::PermissionsExt;
773
774        let (dir, beans_dir) = setup_test_beans_dir();
775        let project_root = dir.path();
776        let bean = Bean::new("1", "Original");
777        let slug = title_to_slug(&bean.title);
778        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
779            .unwrap();
780
781        // Enable trust and create hooks
782        create_trust(project_root).unwrap();
783        let hooks_dir = project_root.join(".beans").join("hooks");
784        fs::create_dir_all(&hooks_dir).unwrap();
785
786        let pre_hook = hooks_dir.join("pre-update");
787        fs::write(&pre_hook, "#!/bin/bash\nexit 0").unwrap();
788
789        let post_hook = hooks_dir.join("post-update");
790        fs::write(&post_hook, "#!/bin/bash\nexit 0").unwrap();
791
792        #[cfg(unix)]
793        {
794            fs::set_permissions(&pre_hook, fs::Permissions::from_mode(0o755)).unwrap();
795            fs::set_permissions(&post_hook, fs::Permissions::from_mode(0o755)).unwrap();
796        }
797
798        // Update multiple fields
799        let result = cmd_update(
800            &beans_dir,
801            "1",
802            Some("New title".to_string()),
803            Some("New desc".to_string()),
804            None,
805            None,
806            None,
807            Some("in_progress".to_string()),
808            None,
809            None,
810            None,
811            None,
812        );
813        assert!(result.is_ok());
814
815        // Verify all changes applied
816        let updated =
817            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
818        assert_eq!(updated.title, "New title");
819        assert_eq!(updated.description, Some("New desc".to_string()));
820        assert_eq!(updated.status, Status::InProgress);
821    }
822
823    // =====================================================================
824    // Token Recalculation Tests
825    // =====================================================================
826
827    #[test]
828    fn test_update_description_recalculates_tokens() {
829        let (_dir, beans_dir) = setup_test_beans_dir();
830        let bean = Bean::new("1", "Test");
831        let slug = title_to_slug(&bean.title);
832        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
833            .unwrap();
834
835        cmd_update(
836            &beans_dir,
837            "1",
838            None,
839            Some("New description with content".to_string()),
840            None,
841            None,
842            None,
843            None,
844            None,
845            None,
846            None,
847            None,
848        )
849        .unwrap();
850
851        let updated =
852            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
853        assert!(
854            updated.tokens.is_some(),
855            "Tokens should be calculated after description update"
856        );
857        assert!(
858            updated.tokens_updated.is_some(),
859            "tokens_updated should be set"
860        );
861    }
862
863    #[test]
864    fn test_update_acceptance_recalculates_tokens() {
865        let (_dir, beans_dir) = setup_test_beans_dir();
866        let bean = Bean::new("1", "Test");
867        let slug = title_to_slug(&bean.title);
868        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
869            .unwrap();
870
871        cmd_update(
872            &beans_dir,
873            "1",
874            None,
875            None,
876            Some("New acceptance criteria".to_string()),
877            None,
878            None,
879            None,
880            None,
881            None,
882            None,
883            None,
884        )
885        .unwrap();
886
887        let updated =
888            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
889        assert!(
890            updated.tokens.is_some(),
891            "Tokens should be calculated after acceptance update"
892        );
893        assert!(
894            updated.tokens_updated.is_some(),
895            "tokens_updated should be set"
896        );
897    }
898
899    #[test]
900    fn test_update_notes_recalculates_tokens() {
901        let (_dir, beans_dir) = setup_test_beans_dir();
902        let bean = Bean::new("1", "Test");
903        let slug = title_to_slug(&bean.title);
904        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
905            .unwrap();
906
907        cmd_update(
908            &beans_dir,
909            "1",
910            None,
911            None,
912            None,
913            Some("New note content".to_string()),
914            None,
915            None,
916            None,
917            None,
918            None,
919            None,
920        )
921        .unwrap();
922
923        let updated =
924            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
925        assert!(
926            updated.tokens.is_some(),
927            "Tokens should be calculated after notes update"
928        );
929        assert!(
930            updated.tokens_updated.is_some(),
931            "tokens_updated should be set"
932        );
933    }
934
935    #[test]
936    fn test_update_title_does_not_recalculate_tokens() {
937        let (_dir, beans_dir) = setup_test_beans_dir();
938        let bean = Bean::new("1", "Original Title");
939        let slug = title_to_slug(&bean.title);
940        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
941            .unwrap();
942
943        cmd_update(
944            &beans_dir,
945            "1",
946            Some("New Title".to_string()),
947            None,
948            None,
949            None,
950            None,
951            None,
952            None,
953            None,
954            None,
955            None,
956        )
957        .unwrap();
958
959        let updated =
960            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
961        assert!(
962            updated.tokens.is_none(),
963            "Tokens should not be calculated for title-only update"
964        );
965        assert!(
966            updated.tokens_updated.is_none(),
967            "tokens_updated should not be set"
968        );
969    }
970
971    #[test]
972    fn test_update_label_does_not_recalculate_tokens() {
973        let (_dir, beans_dir) = setup_test_beans_dir();
974        let bean = Bean::new("1", "Test");
975        let slug = title_to_slug(&bean.title);
976        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
977            .unwrap();
978
979        cmd_update(
980            &beans_dir,
981            "1",
982            None,
983            None,
984            None,
985            None,
986            None,
987            None,
988            None,
989            None,
990            Some("bug".to_string()),
991            None,
992        )
993        .unwrap();
994
995        let updated =
996            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
997        assert!(
998            updated.tokens.is_none(),
999            "Tokens should not be calculated for label-only update"
1000        );
1001        assert!(
1002            updated.tokens_updated.is_none(),
1003            "tokens_updated should not be set"
1004        );
1005    }
1006
1007    #[test]
1008    fn test_update_tokens_updated_timestamp_changes() {
1009        let (_dir, beans_dir) = setup_test_beans_dir();
1010        let bean = Bean::new("1", "Test");
1011        let slug = title_to_slug(&bean.title);
1012        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
1013            .unwrap();
1014
1015        // First update
1016        cmd_update(
1017            &beans_dir,
1018            "1",
1019            None,
1020            Some("First description".to_string()),
1021            None,
1022            None,
1023            None,
1024            None,
1025            None,
1026            None,
1027            None,
1028            None,
1029        )
1030        .unwrap();
1031        let first_update =
1032            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
1033        let first_tokens_updated = first_update.tokens_updated.unwrap();
1034
1035        // Brief pause to ensure timestamp difference
1036        std::thread::sleep(std::time::Duration::from_millis(10));
1037
1038        // Second update
1039        cmd_update(
1040            &beans_dir,
1041            "1",
1042            None,
1043            Some("Second description with more content".to_string()),
1044            None,
1045            None,
1046            None,
1047            None,
1048            None,
1049            None,
1050            None,
1051            None,
1052        )
1053        .unwrap();
1054        let second_update =
1055            Bean::from_file(crate::discovery::find_bean_file(&beans_dir, "1").unwrap()).unwrap();
1056        let second_tokens_updated = second_update.tokens_updated.unwrap();
1057
1058        assert!(
1059            second_tokens_updated > first_tokens_updated,
1060            "tokens_updated should change on content update"
1061        );
1062    }
1063}