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#[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 if let Some(p) = priority {
36 crate::bean::validate_priority(p)?;
37 }
38
39 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 let project_root = beans_dir
48 .parent()
49 .ok_or_else(|| anyhow!("Cannot determine project root from beans dir"))?;
50
51 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 let mut content_changed = false;
61
62 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 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 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 bean.updated_at = Utc::now();
124
125 bean.to_file(&bean_path)
127 .with_context(|| format!("Failed to save bean: {}", id))?;
128
129 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 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")); }
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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 std::thread::sleep(std::time::Duration::from_millis(10));
1037
1038 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}