Skip to main content

bn/commands/
edit.rs

1//! Editor integration for bn edit command.
2//!
3//! This module provides low-level file operations for editing beans:
4//! - Launching an external editor subprocess
5//! - Creating backups before editing
6//! - Validating and saving edited content
7//! - Rebuilding indices after modifications
8//! - Prompting user for rollback on validation errors
9
10use 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
22/// Validate bean content and persist it to disk with updated timestamp.
23///
24/// Parses the content using Bean::from_string() to validate the YAML schema.
25/// If validation succeeds, writes the content to the file and updates the
26/// updated_at field to the current UTC time.
27///
28/// # Arguments
29/// * `path` - Path where the validated content will be written
30/// * `content` - The edited bean content (YAML or Markdown with YAML frontmatter)
31///
32/// # Returns
33/// * Ok(()) if validation succeeds and file is written
34/// * Err with descriptive message if:
35///   - Content fails YAML schema validation
36///   - File I/O error occurs
37///
38/// # Examples
39/// ```ignore
40/// validate_and_save(Path::new(".beans/1-my-task.md"), edited_content)?;
41/// ```
42pub fn validate_and_save(path: &Path, content: &str) -> Result<()> {
43    // Parse content to validate schema
44    let mut bean =
45        Bean::from_string(content).with_context(|| "Failed to parse bean: invalid YAML schema")?;
46
47    // Update the timestamp to current time
48    bean.updated_at = Utc::now();
49
50    // Serialize the validated bean back to YAML
51    let validated_yaml =
52        serde_yml::to_string(&bean).with_context(|| "Failed to serialize validated bean")?;
53
54    // Write to disk
55    fs::write(path, validated_yaml)
56        .with_context(|| format!("Failed to write bean to {}", path.display()))?;
57
58    Ok(())
59}
60
61/// Rebuild the bean index from current bean files on disk.
62///
63/// Reads all bean files in the beans directory, builds a fresh index,
64/// and saves it to .beans/index.yaml. This should be called after any
65/// bean modification to keep the index synchronized.
66///
67/// # Arguments
68/// * `beans_dir` - Path to the .beans directory
69///
70/// # Returns
71/// * Ok(()) if index is built and saved successfully
72/// * Err if:
73///   - Directory is not readable
74///   - Bean files fail to parse
75///   - Index file cannot be written
76///
77/// # Examples
78/// ```ignore
79/// rebuild_index_after_edit(Path::new(".beans"))?;
80/// ```
81pub 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
91/// Prompt user for action when validation fails: retry, rollback, or abort.
92///
93/// Displays the validation error and presents an interactive prompt with three options:
94/// - 'y' or 'retry': Re-open the editor for another attempt (returns Ok)
95/// - 'r' or 'rollback': Restore the original file from backup and abort (returns Ok)
96/// - 'n' or any other input: Abort the edit operation (returns Err)
97///
98/// # Arguments
99/// * `backup` - The original file content before editing (in bytes)
100/// * `path` - Path to the bean file being edited
101///
102/// # Returns
103/// * Ok(()) if user chooses 'retry' (signals caller to re-open editor) or 'rollback'
104/// * Err if user chooses 'n'/'abort'
105///
106/// # Examples
107/// ```ignore
108/// match prompt_rollback(&backup, &path) {
109///     Ok(()) => {
110///         // User chose retry or rollback - check backup file to determine which
111///         if path matches backup { /* was rollback */ }
112///         else { /* was retry */ }
113///     }
114///     Err(e) => println!("Edit aborted: {}", e),
115/// }
116/// ```
117pub fn prompt_rollback(backup: &[u8], path: &Path) -> Result<()> {
118    // Present user with menu
119    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    // Read user input
127    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            // User wants to retry — return Ok to signal retry
134            Ok(())
135        }
136        "r" | "rollback" => {
137            // Restore from backup and return Ok (successful rollback)
138            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            // User aborts
145            Err(anyhow!("Edit aborted by user"))
146        }
147        _ => {
148            // Invalid input treated as abort
149            Err(anyhow!("Edit aborted by user"))
150        }
151    }
152}
153
154/// Open a file in the user's configured editor.
155///
156/// Reads the $EDITOR environment variable and spawns a subprocess with the file path.
157/// Waits for the editor to exit and validates the exit status.
158///
159/// # Arguments
160/// * `path` - Path to the file to edit
161///
162/// # Returns
163/// * Ok(()) if editor exits successfully (status 0)
164/// * Err if:
165///   - $EDITOR environment variable is not set
166///   - Editor executable is not found
167///   - Editor process exits with non-zero status
168///   - Editor subprocess crashes
169///
170/// # Examples
171/// ```ignore
172/// open_editor(Path::new(".beans/1-my-task.md"))?;
173/// ```
174pub fn open_editor(path: &Path) -> Result<()> {
175    // Get EDITOR environment variable
176    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    // Ensure file exists before opening
180    if !path.exists() {
181        return Err(anyhow!("File does not exist: {}", path.display()));
182    }
183
184    // Convert path to string for error messages
185    let path_str = path
186        .to_str()
187        .ok_or_else(|| anyhow!("Path contains invalid UTF-8: {}", path.display()))?;
188
189    // Spawn editor subprocess
190    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    // Check exit status
201    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
213/// Load file content into memory as a backup before editing.
214///
215/// Reads the entire file content into a byte vector. This is used to detect
216/// if the file was actually modified by comparing before/after content.
217///
218/// # Arguments
219/// * `path` - Path to the file to backup
220///
221/// # Returns
222/// * `Ok(Vec<u8>)` containing the file content
223/// * Err if:
224///   - File does not exist
225///   - Permission denied reading the file
226///   - I/O error occurs
227///
228/// # Examples
229/// ```ignore
230/// let backup = load_backup(Path::new(".beans/1-my-task.md"))?;
231/// ```
232pub 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
236/// Orchestrate the complete bn edit workflow for a bean.
237///
238/// The full edit workflow:
239/// 1. Validate the bean ID format
240/// 2. Find the bean file using discovery
241/// 3. Load the current bean content as a backup
242/// 4. Open the file in the user's configured editor
243/// 5. Load the edited content
244/// 6. Validate and save with schema validation (updates timestamp)
245/// 7. Rebuild the index to reflect changes
246///
247/// If validation fails, prompts user to retry, rollback, or abort.
248/// If editor subprocess fails, handles the error gracefully.
249///
250/// # Arguments
251/// * `beans_dir` - Path to the .beans directory
252/// * `id` - Bean ID to edit (e.g., "1", "1.1")
253///
254/// # Returns
255/// * Ok(()) if edit is successful and saved
256/// * Err if:
257///   - Bean ID not found
258///   - $EDITOR not set or editor not found
259///   - Editor exits with non-zero status
260///   - Validation fails and user chooses abort
261///   - I/O or index rebuild fails
262///
263/// # Examples
264/// ```ignore
265/// cmd_edit(Path::new(".beans"), "1")?;
266/// ```
267pub fn cmd_edit(beans_dir: &Path, id: &str) -> Result<()> {
268    // Step 1: Find the bean file
269    let bean_path =
270        find_bean_file(beans_dir, id).with_context(|| format!("Bean not found: {}", id))?;
271
272    // Step 2: Load the current bean content as backup
273    let backup = load_backup(&bean_path)
274        .with_context(|| format!("Failed to load bean for editing: {}", id))?;
275
276    // Step 3: Open editor for user to modify the file
277    loop {
278        match open_editor(&bean_path) {
279            Ok(()) => {
280                // Step 4: Read the edited content
281                let edited_content = fs::read_to_string(&bean_path)
282                    .with_context(|| format!("Failed to read edited bean file: {}", id))?;
283
284                // Step 5: Validate and save the edited content (updates timestamp)
285                match validate_and_save(&bean_path, &edited_content) {
286                    Ok(()) => {
287                        // Step 6: Rebuild the index to reflect changes
288                        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                        // Validation failed - present user with options
296                        eprintln!("Validation error: {}", validation_err);
297
298                        match prompt_rollback(&backup, &bean_path) {
299                            Ok(()) => {
300                                // Check if file was restored to backup or if user wants to retry
301                                let current = fs::read(&bean_path)
302                                    .with_context(|| "Failed to read bean file")?;
303
304                                if current == backup {
305                                    // User chose rollback - exit cleanly
306                                    println!("Edit cancelled.");
307                                    return Ok(());
308                                } else {
309                                    // User chose retry - loop back to open editor
310                                    continue;
311                                }
312                            }
313                            Err(e) => {
314                                // User chose abort - restore backup and return error
315                                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                // Editor subprocess failed - prompt user for action
324                eprintln!("Editor error: {}", editor_err);
325
326                // Attempt rollback
327                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    // =====================================================================
367    // Tests for load_backup (from 2.1)
368    // =====================================================================
369
370    #[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)); // 1MB file
410        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        // Use 'echo' as a harmless editor that exits successfully
427        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        // Use 'true' as a harmless editor that always succeeds
436        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        // Simulate backup before edit
457        let backup = load_backup(&path).unwrap();
458        assert_eq!(backup, original.as_bytes());
459
460        // Simulate file modification
461        fs::write(&path, "modified content").unwrap();
462
463        // Verify backup is unchanged
464        assert_eq!(backup, original.as_bytes());
465
466        // Verify file is modified
467        let current = fs::read(&path).unwrap();
468        assert_ne!(current, backup);
469    }
470
471    // =====================================================================
472    // Tests for validate_and_save (Bean 2.2)
473    // =====================================================================
474
475    #[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        // Verify file was written
490        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        // Save original timestamp
506        let before = Bean::from_string(bean_content).unwrap();
507        let before_ts = before.updated_at;
508
509        // Wait a tiny bit to ensure time difference
510        std::thread::sleep(std::time::Duration::from_millis(10));
511
512        // Validate and save
513        validate_and_save(&path, bean_content).unwrap();
514
515        // Load the saved bean and check timestamp was updated
516        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        // Read from disk and verify
545        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"#; // Missing created_at and updated_at
581        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    // =====================================================================
588    // Tests for rebuild_index_after_edit (Bean 2.2)
589    // =====================================================================
590
591    #[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        // Create a bean file
598        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
608        rebuild_index_after_edit(&beans_dir).unwrap();
609
610        // Verify index.yaml was created
611        assert!(beans_dir.join("index.yaml").exists());
612
613        // Load and verify index
614        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        // Create multiple beans
627        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 with no beans
663        rebuild_index_after_edit(&beans_dir).unwrap();
664
665        // Index should be created but empty
666        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    // =====================================================================
678    // Tests for prompt_rollback (Bean 2.2)
679    // =====================================================================
680
681    #[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        // If we could mock stdin, we'd test rollback by:
687        // 1. Verifying backup is written
688        // 2. Checking file content matches backup
689        // For now, verify the function would write backup correctly
690        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        // Modify file
706        fs::write(&path, "modified content").unwrap();
707
708        // Restore from backup
709        fs::write(&path, &backup).unwrap();
710
711        // Verify restoration
712        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        // Full workflow: backup -> edit -> validate -> save
719        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        // Step 1: Backup
729        let backup = load_backup(&path).unwrap();
730        assert_eq!(backup, bean_content.as_bytes());
731
732        // Step 2: Simulate edit (modify title)
733        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        // Step 3: Validate and save
742        validate_and_save(&path, edited_content).unwrap();
743
744        // Step 4: Verify changes persisted
745        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        // Create initial bean
756        let bean1 = Bean::new("1", "First");
757        bean1.to_file(beans_dir.join("1-first.md")).unwrap();
758
759        // Build index
760        rebuild_index_after_edit(&beans_dir).unwrap();
761        let index1 = Index::load(&beans_dir).unwrap();
762        assert_eq!(index1.beans.len(), 1);
763
764        // Add another bean and rebuild
765        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    // =====================================================================
774    // Integration tests for cmd_edit (Bean 2.3)
775    // =====================================================================
776
777    #[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        // Verify that find_bean_file can locate the bean
787        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        // Note: cmd_edit requires valid $EDITOR, so we just verify find_bean_file fails
798        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        // Load backup
814        let backup = load_backup(&path).unwrap();
815
816        // Verify backup matches original
817        assert_eq!(backup, bean_content.as_bytes());
818    }
819
820    #[test]
821    fn test_cmd_edit_workflow_backup_edit_save() {
822        // Test the complete workflow: backup -> edit -> validate -> save -> index
823        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        // Copy bean file to test beans_dir for index rebuild
836        fs::copy(&path, beans_dir.join("1-original.md")).unwrap();
837
838        // Step 1: Backup
839        let backup = load_backup(&path).unwrap();
840        assert_eq!(backup, bean_content.as_bytes());
841
842        // Step 2: Simulate edit (modify title in memory)
843        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        // Step 3: Write edited content to file
852        fs::write(&path, edited_content).unwrap();
853
854        // Step 4: Validate and save
855        validate_and_save(&path, edited_content).unwrap();
856
857        // Step 5: Verify changes persisted and timestamp updated
858        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        // Step 6: Rebuild index
863        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        // Create a bean with {id}-{slug}.md naming
886        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        // Verify the file exists with correct naming
891        assert!(
892            original_path.exists(),
893            "Bean file should be named 1-my-task.md"
894        );
895
896        // Load and modify
897        let content = fs::read_to_string(&original_path).unwrap();
898        let modified = content.replace("My Task", "Updated Task");
899
900        // Save with validate_and_save (this is what cmd_edit uses)
901        validate_and_save(&original_path, &modified).unwrap();
902
903        // Verify the file still exists and naming is preserved
904        assert!(
905            original_path.exists(),
906            "Naming should be preserved after edit"
907        );
908
909        // Verify the bean was updated
910        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        // Build initial index
924        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        // Edit the bean
929        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
934        rebuild_index_after_edit(&beans_dir).unwrap();
935        let index2 = Index::load(&beans_dir).unwrap();
936
937        // Verify index reflects the edit
938        assert_eq!(index2.beans[0].title, "Modified");
939    }
940}