diff --git a/src/commands.rs b/src/commands.rs index e0ff0a3..975765f 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -214,7 +214,61 @@ pub fn archive_items(note_path: &Path, archive_path: &Path, numbers: &[usize]) - } } -/// Interactive review mode that presents each item for user decision. +/// Removes specified items from the note file without archiving them. +/// Takes 1-based item numbers, validates them, and permanently deletes them. +pub fn remove_items(note_path: &Path, numbers: &[usize]) -> io::Result<()> { + if !note_path.exists() { + println!( + "{}No notes found.{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + return Ok(()); + } + + let content = fs::read_to_string(note_path)?; + let lines: Vec = content.lines().map(str::to_string).collect(); + + if lines.is_empty() { + println!( + "{}No notes to remove.{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + return Ok(()); + } + + match archive_items_in_content(&lines, numbers) { + Ok((new_content, removed_items)) => { + fs::write(note_path, new_content)?; + println!( + "{}✓ Removed {} item(s){}", + color::Fg(color::Green), + removed_items.len(), + color::Fg(color::Reset) + ); + for item in removed_items { + println!( + " {}{}{}", + color::Fg(color::Red), + item, + color::Fg(color::Reset) + ); + } + Ok(()) + } + Err(msg) => { + println!( + "{}{}{}", + color::Fg(color::Red), + msg, + color::Fg(color::Reset) + ); + Ok(()) + } + } +} + /// Supports prioritizing, archiving, skipping items with keyboard navigation and undo functionality. pub fn review_note(note_path: &Path, archive_path: &Path) -> io::Result<()> { if !note_path.exists() { diff --git a/src/main.rs b/src/main.rs index f97dcbc..04e0540 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,6 +56,12 @@ enum Commands { /// Item numbers to archive (from "parts ls") numbers: Vec, }, + /// Remove items without archiving + #[command(aliases = ["remove", "delete", "destroy", "forget"])] + Rm { + /// Item numbers to remove (from "parts ls") + numbers: Vec, + }, /// Scan and review items interactively, from top to bottom #[command(alias = "review")] Scan, @@ -82,6 +88,7 @@ fn main() -> io::Result<()> { } Commands::Up { numbers } => commands::prioritize_items(¬e_path, &numbers), Commands::Do { numbers } => commands::archive_items(¬e_path, &archive_path, &numbers), + Commands::Rm { numbers } => commands::remove_items(¬e_path, &numbers), Commands::Scan => commands::review_note(¬e_path, &archive_path), Commands::Edit => commands::edit_note(¬e_path), } diff --git a/src/tests.rs b/src/tests.rs index fcc78b8..3bc6618 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -442,3 +442,148 @@ mod review_tests { assert_eq!(prioritized, expected_prioritized); } } + +#[cfg(test)] +mod remove_tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + /// Helper function to create temporary test files with content + fn create_test_file_with_content(content: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().unwrap(); + write!(file, "{}", content).unwrap(); + file + } + + /// Tests removing a single item + #[test] + fn test_remove_single_item() { + let content = "First item\nSecond item\nThird item\n"; + let file = create_test_file_with_content(content); + + let result = std::panic::catch_unwind(|| { + remove_items(file.path(), &[2]).unwrap(); + }); + assert!(result.is_ok()); + + // Verify the item was removed from the file + let updated_content = std::fs::read_to_string(file.path()).unwrap(); + assert_eq!(updated_content, "First item\nThird item\n"); + } + + /// Tests removing multiple items + #[test] + fn test_remove_multiple_items() { + let content = "First item\nSecond item\nThird item\nFourth item\n"; + let file = create_test_file_with_content(content); + + let result = std::panic::catch_unwind(|| { + remove_items(file.path(), &[1, 3]).unwrap(); + }); + assert!(result.is_ok()); + + // Verify the items were removed from the file + let updated_content = std::fs::read_to_string(file.path()).unwrap(); + assert_eq!(updated_content, "Second item\nFourth item\n"); + } + + /// Tests removing all items results in empty file + #[test] + fn test_remove_all_items() { + let content = "First item\nSecond item\n"; + let file = create_test_file_with_content(content); + + let result = std::panic::catch_unwind(|| { + remove_items(file.path(), &[1, 2]).unwrap(); + }); + assert!(result.is_ok()); + + // Verify the file is empty + let updated_content = std::fs::read_to_string(file.path()).unwrap(); + assert_eq!(updated_content, ""); + } + + /// Tests handling of invalid item numbers + #[test] + fn test_remove_invalid_item_number() { + let content = "First item\nSecond item\n"; + let file = create_test_file_with_content(content); + + let result = std::panic::catch_unwind(|| { + remove_items(file.path(), &[3]).unwrap(); + }); + assert!(result.is_ok()); + + // Verify the file content is unchanged + let updated_content = std::fs::read_to_string(file.path()).unwrap(); + assert_eq!(updated_content, content); + } + + /// Tests handling of duplicate item numbers + #[test] + fn test_remove_duplicate_item_numbers() { + let content = "First item\nSecond item\nThird item\n"; + let file = create_test_file_with_content(content); + + let result = std::panic::catch_unwind(|| { + remove_items(file.path(), &[2, 2, 1]).unwrap(); + }); + assert!(result.is_ok()); + + // Verify only unique items were removed + let updated_content = std::fs::read_to_string(file.path()).unwrap(); + assert_eq!(updated_content, "Third item\n"); + } + + /// Tests handling of non-existent file + #[test] + fn test_remove_from_nonexistent_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let nonexistent_path = temp_dir.path().join("nonexistent.txt"); + + let result = std::panic::catch_unwind(|| { + remove_items(&nonexistent_path, &[1]).unwrap(); + }); + assert!(result.is_ok()); + + // Verify no file was created + assert!(!nonexistent_path.exists()); + } + + /// Tests handling of empty file + #[test] + fn test_remove_from_empty_file() { + let file = create_test_file_with_content(""); + + let result = std::panic::catch_unwind(|| { + remove_items(file.path(), &[1]).unwrap(); + }); + assert!(result.is_ok()); + + // Verify the file remains empty + let updated_content = std::fs::read_to_string(file.path()).unwrap(); + assert_eq!(updated_content, ""); + } + + /// Tests that remove doesn't create an archive file (unlike do command) + #[test] + fn test_remove_does_not_archive() { + let content = "First item\nSecond item\n"; + let file = create_test_file_with_content(content); + let temp_dir = tempfile::tempdir().unwrap(); + let archive_path = temp_dir.path().join("archive.txt"); + + let result = std::panic::catch_unwind(|| { + remove_items(file.path(), &[1]).unwrap(); + }); + assert!(result.is_ok()); + + // Verify no archive file was created + assert!(!archive_path.exists()); + + // Verify the item was removed from the original file + let updated_content = std::fs::read_to_string(file.path()).unwrap(); + assert_eq!(updated_content, "Second item\n"); + } +}