1use anyhow::{anyhow, Context, Result};
11use chrono::Utc;
12use std::env;
13use std::fs;
14use std::io::{self, Write};
15use std::path::Path;
16use std::process::Command;
17
18use crate::bean::Bean;
19use crate::discovery::find_bean_file;
20use crate::index::Index;
21
22pub fn validate_and_save(path: &Path, content: &str) -> Result<()> {
43 let mut bean =
45 Bean::from_string(content).with_context(|| "Failed to parse bean: invalid YAML schema")?;
46
47 bean.updated_at = Utc::now();
49
50 let validated_yaml =
52 serde_yml::to_string(&bean).with_context(|| "Failed to serialize validated bean")?;
53
54 fs::write(path, validated_yaml)
56 .with_context(|| format!("Failed to write bean to {}", path.display()))?;
57
58 Ok(())
59}
60
61pub fn rebuild_index_after_edit(beans_dir: &Path) -> Result<()> {
82 let index = Index::build(beans_dir).with_context(|| "Failed to build index from bean files")?;
83
84 index
85 .save(beans_dir)
86 .with_context(|| "Failed to save index to .beans/index.yaml")?;
87
88 Ok(())
89}
90
91pub fn prompt_rollback(backup: &[u8], path: &Path) -> Result<()> {
118 println!("\nValidation failed. What would you like to do?");
120 println!(" (y) Retry in editor");
121 println!(" (r) Rollback and discard changes");
122 println!(" (n) Abort");
123 print!("\nChoice: ");
124 io::stdout().flush()?;
125
126 let mut input = String::new();
128 io::stdin().read_line(&mut input)?;
129 let choice = input.trim().to_lowercase();
130
131 match choice.as_str() {
132 "y" | "retry" => {
133 Ok(())
135 }
136 "r" | "rollback" => {
137 fs::write(path, backup)
139 .with_context(|| format!("Failed to restore backup to {}", path.display()))?;
140 println!("Rollback complete. Original file restored.");
141 Ok(())
142 }
143 "n" => {
144 Err(anyhow!("Edit aborted by user"))
146 }
147 _ => {
148 Err(anyhow!("Edit aborted by user"))
150 }
151 }
152}
153
154pub fn open_editor(path: &Path) -> Result<()> {
175 let editor = env::var("EDITOR")
177 .context("$EDITOR environment variable not set. Please set it to your preferred editor (e.g., vim, nano, emacs)")?;
178
179 if !path.exists() {
181 return Err(anyhow!("File does not exist: {}", path.display()));
182 }
183
184 let path_str = path
186 .to_str()
187 .ok_or_else(|| anyhow!("Path contains invalid UTF-8: {}", path.display()))?;
188
189 let mut cmd = Command::new(&editor);
191 cmd.arg(path_str);
192
193 let status = cmd.status().with_context(|| {
194 anyhow!(
195 "Failed to launch editor '{}'. Make sure it is installed and in your PATH",
196 editor
197 )
198 })?;
199
200 if !status.success() {
202 let exit_code = status.code().unwrap_or(-1);
203 return Err(anyhow!(
204 "Editor '{}' exited with code {}",
205 editor,
206 exit_code
207 ));
208 }
209
210 Ok(())
211}
212
213pub fn load_backup(path: &Path) -> Result<Vec<u8>> {
233 fs::read(path).with_context(|| anyhow!("Failed to read file for backup: {}", path.display()))
234}
235
236pub fn cmd_edit(beans_dir: &Path, id: &str) -> Result<()> {
268 let bean_path =
270 find_bean_file(beans_dir, id).with_context(|| format!("Bean not found: {}", id))?;
271
272 let backup = load_backup(&bean_path)
274 .with_context(|| format!("Failed to load bean for editing: {}", id))?;
275
276 loop {
278 match open_editor(&bean_path) {
279 Ok(()) => {
280 let edited_content = fs::read_to_string(&bean_path)
282 .with_context(|| format!("Failed to read edited bean file: {}", id))?;
283
284 match validate_and_save(&bean_path, &edited_content) {
286 Ok(()) => {
287 rebuild_index_after_edit(beans_dir)
289 .with_context(|| "Failed to rebuild index after edit")?;
290
291 println!("Bean {} updated successfully.", id);
292 return Ok(());
293 }
294 Err(validation_err) => {
295 eprintln!("Validation error: {}", validation_err);
297
298 match prompt_rollback(&backup, &bean_path) {
299 Ok(()) => {
300 let current = fs::read(&bean_path)
302 .with_context(|| "Failed to read bean file")?;
303
304 if current == backup {
305 println!("Edit cancelled.");
307 return Ok(());
308 } else {
309 continue;
311 }
312 }
313 Err(e) => {
314 let _ = fs::write(&bean_path, &backup);
316 return Err(e).context("Edit aborted by user");
317 }
318 }
319 }
320 }
321 }
322 Err(editor_err) => {
323 eprintln!("Editor error: {}", editor_err);
325
326 match fs::write(&bean_path, &backup) {
328 Ok(()) => {
329 return Err(editor_err).context("Editor failed; backup restored");
330 }
331 Err(rollback_err) => {
332 return Err(anyhow!(
333 "Editor failed and rollback failed: {} (rollback: {})",
334 editor_err,
335 rollback_err
336 ));
337 }
338 }
339 }
340 }
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use std::fs;
348 use std::io::Write;
349 use tempfile::TempDir;
350
351 fn create_temp_file(content: &str) -> (TempDir, std::path::PathBuf) {
352 let dir = TempDir::new().unwrap();
353 let file_path = dir.path().join("test.md");
354 let mut file = fs::File::create(&file_path).unwrap();
355 file.write_all(content.as_bytes()).unwrap();
356 (dir, file_path)
357 }
358
359 fn create_valid_bean_file(content: &str) -> (TempDir, std::path::PathBuf) {
360 let dir = TempDir::new().unwrap();
361 let file_path = dir.path().join("1-test.md");
362 fs::write(&file_path, content).unwrap();
363 (dir, file_path)
364 }
365
366 #[test]
371 fn test_load_backup_reads_content() {
372 let (_dir, path) = create_temp_file("Hello, World!");
373 let backup = load_backup(&path).unwrap();
374 assert_eq!(backup, b"Hello, World!");
375 }
376
377 #[test]
378 fn test_load_backup_reads_empty_file() {
379 let (_dir, path) = create_temp_file("");
380 let backup = load_backup(&path).unwrap();
381 assert_eq!(backup.len(), 0);
382 }
383
384 #[test]
385 fn test_load_backup_reads_multiline_content() {
386 let (_dir, path) = create_temp_file("Line 1\nLine 2\nLine 3");
387 let backup = load_backup(&path).unwrap();
388 assert_eq!(backup, b"Line 1\nLine 2\nLine 3");
389 }
390
391 #[test]
392 fn test_load_backup_reads_binary_content() {
393 let (_dir, path) = create_temp_file("Binary\x00\x01\x02");
394 let backup = load_backup(&path).unwrap();
395 assert_eq!(backup, b"Binary\x00\x01\x02");
396 }
397
398 #[test]
399 fn test_load_backup_nonexistent_file() {
400 let path = Path::new("/nonexistent/path/to/file.md");
401 let result = load_backup(path);
402 assert!(result.is_err());
403 let err_msg = result.unwrap_err().to_string();
404 assert!(err_msg.contains("Failed to read file"));
405 }
406
407 #[test]
408 fn test_load_backup_large_file() {
409 let (_dir, path) = create_temp_file(&"X".repeat(1024 * 1024)); let backup = load_backup(&path).unwrap();
411 assert_eq!(backup.len(), 1024 * 1024);
412 }
413
414 #[test]
415 fn test_open_editor_nonexistent_file() {
416 env::set_var("EDITOR", "echo");
417 let path = Path::new("/nonexistent/path/to/file.md");
418 let result = open_editor(path);
419 assert!(result.is_err());
420 let err_msg = result.unwrap_err().to_string();
421 assert!(err_msg.contains("does not exist"));
422 }
423
424 #[test]
425 fn test_open_editor_success_with_echo() {
426 env::set_var("EDITOR", "echo");
428 let (_dir, path) = create_temp_file("test content");
429 let result = open_editor(&path);
430 assert!(result.is_ok());
431 }
432
433 #[test]
434 fn test_open_editor_success_with_true() {
435 env::set_var("EDITOR", "true");
437 let (_dir, path) = create_temp_file("test content");
438 let result = open_editor(&path);
439 assert!(result.is_ok());
440 }
441
442 #[test]
443 fn test_backup_preserves_exact_content() {
444 let test_content = "# Bean Title\n\nsome description\n\nstatus: open";
445 let (_dir, path) = create_temp_file(test_content);
446
447 let backup = load_backup(&path).unwrap();
448 assert_eq!(backup, test_content.as_bytes());
449 }
450
451 #[test]
452 fn test_backup_backup_before_edit_workflow() {
453 let original = "original content";
454 let (_dir, path) = create_temp_file(original);
455
456 let backup = load_backup(&path).unwrap();
458 assert_eq!(backup, original.as_bytes());
459
460 fs::write(&path, "modified content").unwrap();
462
463 assert_eq!(backup, original.as_bytes());
465
466 let current = fs::read(&path).unwrap();
468 assert_ne!(current, backup);
469 }
470
471 #[test]
476 fn test_validate_and_save_parses_and_validates_yaml() {
477 let bean_content = r#"id: "1"
478title: Test Bean
479status: open
480priority: 2
481created_at: "2026-01-26T15:00:00Z"
482updated_at: "2026-01-26T15:00:00Z"
483"#;
484 let (_dir, path) = create_valid_bean_file(bean_content);
485
486 let result = validate_and_save(&path, bean_content);
487 assert!(result.is_ok());
488
489 let saved = fs::read_to_string(&path).unwrap();
491 assert!(saved.contains("id: '1'") || saved.contains("id: \"1\""));
492 }
493
494 #[test]
495 fn test_validate_and_save_updates_timestamp() {
496 let bean_content = r#"id: "1"
497title: Test Bean
498status: open
499priority: 2
500created_at: "2026-01-26T15:00:00Z"
501updated_at: "2026-01-26T15:00:00Z"
502"#;
503 let (_dir, path) = create_valid_bean_file(bean_content);
504
505 let before = Bean::from_string(bean_content).unwrap();
507 let before_ts = before.updated_at;
508
509 std::thread::sleep(std::time::Duration::from_millis(10));
511
512 validate_and_save(&path, bean_content).unwrap();
514
515 let saved_bean = Bean::from_file(&path).unwrap();
517 assert!(saved_bean.updated_at > before_ts);
518 }
519
520 #[test]
521 fn test_validate_and_save_rejects_invalid_yaml() {
522 let invalid_content = "id: 1\ntitle: Test\nstatus: invalid_status\n";
523 let (_dir, path) = create_valid_bean_file(invalid_content);
524
525 let result = validate_and_save(&path, invalid_content);
526 assert!(result.is_err());
527 let err_msg = result.unwrap_err().to_string();
528 assert!(err_msg.contains("invalid YAML"));
529 }
530
531 #[test]
532 fn test_validate_and_save_persists_to_disk() {
533 let bean_content = r#"id: "1"
534title: Original Title
535status: open
536priority: 2
537created_at: "2026-01-26T15:00:00Z"
538updated_at: "2026-01-26T15:00:00Z"
539"#;
540 let (_dir, path) = create_valid_bean_file(bean_content);
541
542 validate_and_save(&path, bean_content).unwrap();
543
544 let bean = Bean::from_file(&path).unwrap();
546 assert_eq!(bean.id, "1");
547 assert_eq!(bean.title, "Original Title");
548 }
549
550 #[test]
551 fn test_validate_and_save_with_markdown_frontmatter() {
552 let md_content = r#"---
553id: "2"
554title: Markdown Bean
555status: open
556priority: 2
557created_at: "2026-01-26T15:00:00Z"
558updated_at: "2026-01-26T15:00:00Z"
559---
560
561# Description
562
563This is a markdown body.
564"#;
565 let (_dir, path) = create_valid_bean_file(md_content);
566
567 validate_and_save(&path, md_content).unwrap();
568
569 let bean = Bean::from_file(&path).unwrap();
570 assert_eq!(bean.id, "2");
571 assert_eq!(bean.title, "Markdown Bean");
572 assert!(bean.description.is_some());
573 }
574
575 #[test]
576 fn test_validate_and_save_missing_required_field() {
577 let invalid_content = r#"id: "1"
578title: Test
579status: open
580"#; let (_dir, path) = create_valid_bean_file(invalid_content);
582
583 let result = validate_and_save(&path, invalid_content);
584 assert!(result.is_err());
585 }
586
587 #[test]
592 fn test_rebuild_index_after_edit_creates_index() {
593 let dir = TempDir::new().unwrap();
594 let beans_dir = dir.path().join(".beans");
595 fs::create_dir(&beans_dir).unwrap();
596
597 let bean_content = r#"id: "1"
599title: Test Bean
600status: open
601priority: 2
602created_at: "2026-01-26T15:00:00Z"
603updated_at: "2026-01-26T15:00:00Z"
604"#;
605 fs::write(beans_dir.join("1-test.md"), bean_content).unwrap();
606
607 rebuild_index_after_edit(&beans_dir).unwrap();
609
610 assert!(beans_dir.join("index.yaml").exists());
612
613 let index = Index::load(&beans_dir).unwrap();
615 assert_eq!(index.beans.len(), 1);
616 assert_eq!(index.beans[0].id, "1");
617 assert_eq!(index.beans[0].title, "Test Bean");
618 }
619
620 #[test]
621 fn test_rebuild_index_after_edit_includes_all_beans() {
622 let dir = TempDir::new().unwrap();
623 let beans_dir = dir.path().join(".beans");
624 fs::create_dir(&beans_dir).unwrap();
625
626 let bean1 = Bean::new("1", "First Bean");
628 let bean2 = Bean::new("2", "Second Bean");
629 let bean3 = Bean::new("3", "Third Bean");
630
631 bean1.to_file(beans_dir.join("1-first.md")).unwrap();
632 bean2.to_file(beans_dir.join("2-second.md")).unwrap();
633 bean3.to_file(beans_dir.join("3-third.md")).unwrap();
634
635 rebuild_index_after_edit(&beans_dir).unwrap();
636
637 let index = Index::load(&beans_dir).unwrap();
638 assert_eq!(index.beans.len(), 3);
639 }
640
641 #[test]
642 fn test_rebuild_index_after_edit_saves_to_correct_location() {
643 let dir = TempDir::new().unwrap();
644 let beans_dir = dir.path().join(".beans");
645 fs::create_dir(&beans_dir).unwrap();
646
647 let bean = Bean::new("1", "Test");
648 bean.to_file(beans_dir.join("1-test.md")).unwrap();
649
650 rebuild_index_after_edit(&beans_dir).unwrap();
651
652 let index_path = beans_dir.join("index.yaml");
653 assert!(index_path.exists(), "index.yaml should be saved to .beans/");
654 }
655
656 #[test]
657 fn test_rebuild_index_after_edit_empty_directory() {
658 let dir = TempDir::new().unwrap();
659 let beans_dir = dir.path().join(".beans");
660 fs::create_dir(&beans_dir).unwrap();
661
662 rebuild_index_after_edit(&beans_dir).unwrap();
664
665 let index = Index::load(&beans_dir).unwrap();
667 assert_eq!(index.beans.len(), 0);
668 }
669
670 #[test]
671 fn test_rebuild_index_after_edit_invalid_beans_dir() {
672 let nonexistent = Path::new("/nonexistent/.beans");
673 let result = rebuild_index_after_edit(nonexistent);
674 assert!(result.is_err());
675 }
676
677 #[test]
682 fn test_prompt_rollback_restores_file_from_backup() {
683 let (_dir, path) = create_temp_file("modified content");
684 let backup = b"original content";
685
686 let result = fs::write(&path, backup);
691 assert!(result.is_ok());
692
693 let saved = fs::read(&path).unwrap();
694 assert_eq!(saved, backup);
695 }
696
697 #[test]
698 fn test_prompt_rollback_backup_preserves_content() {
699 let original = "original bean content";
700 let (_dir, path) = create_temp_file(original);
701
702 let backup = load_backup(&path).unwrap();
703 assert_eq!(backup, original.as_bytes());
704
705 fs::write(&path, "modified content").unwrap();
707
708 fs::write(&path, &backup).unwrap();
710
711 let restored = fs::read(&path).unwrap();
713 assert_eq!(restored, original.as_bytes());
714 }
715
716 #[test]
717 fn test_validate_and_save_workflow_full() {
718 let bean_content = r#"id: "1"
720title: Original
721status: open
722priority: 2
723created_at: "2026-01-26T15:00:00Z"
724updated_at: "2026-01-26T15:00:00Z"
725"#;
726 let (_dir, path) = create_valid_bean_file(bean_content);
727
728 let backup = load_backup(&path).unwrap();
730 assert_eq!(backup, bean_content.as_bytes());
731
732 let edited_content = r#"id: "1"
734title: Modified
735status: open
736priority: 2
737created_at: "2026-01-26T15:00:00Z"
738updated_at: "2026-01-26T15:00:00Z"
739"#;
740
741 validate_and_save(&path, edited_content).unwrap();
743
744 let saved_bean = Bean::from_file(&path).unwrap();
746 assert_eq!(saved_bean.title, "Modified");
747 }
748
749 #[test]
750 fn test_rebuild_index_reflects_recent_edits() {
751 let dir = TempDir::new().unwrap();
752 let beans_dir = dir.path().join(".beans");
753 fs::create_dir(&beans_dir).unwrap();
754
755 let bean1 = Bean::new("1", "First");
757 bean1.to_file(beans_dir.join("1-first.md")).unwrap();
758
759 rebuild_index_after_edit(&beans_dir).unwrap();
761 let index1 = Index::load(&beans_dir).unwrap();
762 assert_eq!(index1.beans.len(), 1);
763
764 let bean2 = Bean::new("2", "Second");
766 bean2.to_file(beans_dir.join("2-second.md")).unwrap();
767
768 rebuild_index_after_edit(&beans_dir).unwrap();
769 let index2 = Index::load(&beans_dir).unwrap();
770 assert_eq!(index2.beans.len(), 2);
771 }
772
773 #[test]
778 fn test_cmd_edit_finds_bean_by_id() {
779 let dir = TempDir::new().unwrap();
780 let beans_dir = dir.path().join(".beans");
781 fs::create_dir(&beans_dir).unwrap();
782
783 let bean = Bean::new("1", "Original title");
784 bean.to_file(beans_dir.join("1-original.md")).unwrap();
785
786 let found = crate::discovery::find_bean_file(&beans_dir, "1");
788 assert!(found.is_ok(), "Should find bean by ID");
789 }
790
791 #[test]
792 fn test_cmd_edit_fails_for_nonexistent_bean() {
793 let dir = TempDir::new().unwrap();
794 let beans_dir = dir.path().join(".beans");
795 fs::create_dir(&beans_dir).unwrap();
796
797 let found = crate::discovery::find_bean_file(&beans_dir, "999");
799 assert!(found.is_err(), "Should fail for nonexistent bean");
800 }
801
802 #[test]
803 fn test_cmd_edit_loads_backup_correctly() {
804 let bean_content = r#"id: "1"
805title: Test Bean
806status: open
807priority: 2
808created_at: "2026-01-26T15:00:00Z"
809updated_at: "2026-01-26T15:00:00Z"
810"#;
811 let (_dir, path) = create_valid_bean_file(bean_content);
812
813 let backup = load_backup(&path).unwrap();
815
816 assert_eq!(backup, bean_content.as_bytes());
818 }
819
820 #[test]
821 fn test_cmd_edit_workflow_backup_edit_save() {
822 let bean_content = r#"id: "1"
824title: Original
825status: open
826priority: 2
827created_at: "2026-01-26T15:00:00Z"
828updated_at: "2026-01-26T15:00:00Z"
829"#;
830 let (_dir, path) = create_valid_bean_file(bean_content);
831 let dir = TempDir::new().unwrap();
832 let beans_dir = dir.path().join(".beans");
833 fs::create_dir(&beans_dir).unwrap();
834
835 fs::copy(&path, beans_dir.join("1-original.md")).unwrap();
837
838 let backup = load_backup(&path).unwrap();
840 assert_eq!(backup, bean_content.as_bytes());
841
842 let edited_content = r#"id: "1"
844title: Modified
845status: open
846priority: 2
847created_at: "2026-01-26T15:00:00Z"
848updated_at: "2026-01-26T15:00:00Z"
849"#;
850
851 fs::write(&path, edited_content).unwrap();
853
854 validate_and_save(&path, edited_content).unwrap();
856
857 let saved_bean = Bean::from_file(&path).unwrap();
859 assert_eq!(saved_bean.title, "Modified");
860 assert_ne!(saved_bean.updated_at.to_string(), "2026-01-26T15:00:00Z");
861
862 rebuild_index_after_edit(&beans_dir).unwrap();
864 let index = Index::load(&beans_dir).unwrap();
865 assert!(index.beans.iter().any(|b| b.id == "1"));
866 }
867
868 #[test]
869 fn test_cmd_edit_validates_schema_before_save() {
870 let invalid_content = "id: 1\ntitle: Test\nstatus: invalid_status\n";
871 let (_dir, path) = create_valid_bean_file(invalid_content);
872
873 let result = validate_and_save(&path, invalid_content);
874 assert!(result.is_err(), "Should reject invalid schema");
875 let err_msg = result.unwrap_err().to_string();
876 assert!(err_msg.contains("invalid YAML") || err_msg.contains("Invalid status"));
877 }
878
879 #[test]
880 fn test_cmd_edit_preserves_bean_naming_convention() {
881 let dir = TempDir::new().unwrap();
882 let beans_dir = dir.path().join(".beans");
883 fs::create_dir(&beans_dir).unwrap();
884
885 let bean = Bean::new("1", "My Task");
887 let original_path = beans_dir.join("1-my-task.md");
888 bean.to_file(&original_path).unwrap();
889
890 assert!(
892 original_path.exists(),
893 "Bean file should be named 1-my-task.md"
894 );
895
896 let content = fs::read_to_string(&original_path).unwrap();
898 let modified = content.replace("My Task", "Updated Task");
899
900 validate_and_save(&original_path, &modified).unwrap();
902
903 assert!(
905 original_path.exists(),
906 "Naming should be preserved after edit"
907 );
908
909 let updated_bean = Bean::from_file(&original_path).unwrap();
911 assert_eq!(updated_bean.title, "Updated Task");
912 }
913
914 #[test]
915 fn test_cmd_edit_index_rebuild_includes_edited_bean() {
916 let dir = TempDir::new().unwrap();
917 let beans_dir = dir.path().join(".beans");
918 fs::create_dir(&beans_dir).unwrap();
919
920 let bean = Bean::new("1", "Original");
921 bean.to_file(beans_dir.join("1-original.md")).unwrap();
922
923 rebuild_index_after_edit(&beans_dir).unwrap();
925 let index1 = Index::load(&beans_dir).unwrap();
926 assert_eq!(index1.beans[0].title, "Original");
927
928 let bean_content = fs::read_to_string(beans_dir.join("1-original.md")).unwrap();
930 let modified = bean_content.replace("Original", "Modified");
931 validate_and_save(&beans_dir.join("1-original.md"), &modified).unwrap();
932
933 rebuild_index_after_edit(&beans_dir).unwrap();
935 let index2 = Index::load(&beans_dir).unwrap();
936
937 assert_eq!(index2.beans[0].title, "Modified");
939 }
940}