From 79cfae74eae2321483c1e2998097be271813c0a2 Mon Sep 17 00:00:00 2001 From: glitchySid Date: Mon, 29 Dec 2025 21:47:57 +0530 Subject: [PATCH] added undo feature feature added by Agent --- README.md | 111 ++++++++++++++++++++++- src/config.rs | 12 +++ src/files.rs | 158 +++++++++++++++++++++++++++++++- src/lib.rs | 1 + src/main.rs | 52 +++++++++-- src/undo.rs | 226 ++++++++++++++++++++++++++++++++++++++++++++++ src/undo_tests.rs | 178 ++++++++++++++++++++++++++++++++++++ 7 files changed, 726 insertions(+), 12 deletions(-) create mode 100644 src/undo.rs create mode 100644 src/undo_tests.rs diff --git a/README.md b/README.md index 1832f27..09bc738 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ NoEntropy is a smart command-line tool that organizes your cluttered Downloads f - **📝 Text File Support** - Inspects 30+ text formats for better categorization - **✅ Interactive Confirmation** - Review organization plan before execution - **đŸŽ¯ Configurable** - Adjust concurrency limits and model settings +- **â†Šī¸ Undo Support** - Revert file organization changes if needed ## Prerequisites @@ -135,11 +136,31 @@ cargo run --release -- --max-concurrent 10 ### Combined Options Use multiple options together: - ```bash cargo run --release -- --dry-run --max-concurrent 3 ``` +### Undo Mode + +Revert the last file organization: + +```bash +cargo run --release -- --undo +``` + +Preview what would be undone without actually reversing changes: + +```bash +cargo run --release -- --undo --dry-run +``` + +The undo feature: +- Tracks all file moves in `~/.config/noentropy/data/undo_log.json` +- Shows a preview of files that will be restored before execution +- Handles edge cases (missing files, conflicts, permission errors) +- Automatically cleans up empty directories after undo +- Keeps undo history for 30 days with auto-cleanup + ### Recursive Mode Organize files in subdirectories recursively: @@ -157,6 +178,7 @@ This scans all subdirectories within your download folder and organizes files fr | `--dry-run` | `-d` | `false` | Preview changes without moving files | | `--max-concurrent` | `-m` | `5` | Maximum concurrent API requests | | `--recursive` | None | `false` | Recursively search files in subdirectories | +| `--undo` | None | `false` | Undo the last file organization | | `--help` | `-h` | - | Show help message | ## How It Works @@ -223,6 +245,36 @@ Files moved: 47, Errors: 0 Done! ``` +#### Undo Example + +```bash +$ cargo run --release -- --undo + +--- UNDO PREVIEW --- +INFO: will restore 5 files: + Documents/report.pdf -> Downloads/ + Documents/Notes/notes.txt -> Downloads/ + Code/Config/config.yaml -> Downloads/ + Code/Scripts/script.py -> Downloads/ + Images/photo.png -> Downloads/ + +Do you want to undo these changes? [y/N]: y + +--- UNDOING MOVES --- +Restored: Documents/report.pdf -> Downloads/ +Restored: Documents/Notes/notes.txt -> Downloads/ +Restored: Code/Config/config.yaml -> Downloads/ +Restored: Code/Scripts/script.py -> Downloads/ +Restored: Images/photo.png -> Downloads/ + +INFO: Removed empty directory: Documents/Notes +INFO: Removed empty directory: Code/Config +INFO: Removed empty directory: Code/Scripts + +UNDO COMPLETE! +Files restored: 5, Skipped: 0, Failed: 0 +``` + ## Supported Categories NoEntropy organizes files into these categories: @@ -260,12 +312,43 @@ NoEntropy includes an intelligent caching system to minimize API calls: 1. **First Run**: Files are analyzed and categorized via Gemini API 2. **Response Cached**: Organization plan saved with file metadata -3. **Subsequent Runs**: +3. **Subsequent Runs**: - Checks if files changed (size/modification time) - If unchanged, uses cached categorization - If changed, re-analyzes via API 4. **Auto-Cleanup**: Removes cache entries older than 7 days +## Undo Log + +NoEntropy tracks all file moves to enable undo functionality: + +- **Location**: `~/.config/noentropy/data/undo_log.json` +- **Retention**: 30 days (old entries auto-removed) +- **Max Entries**: 1000 entries (oldest evicted when limit reached) +- **Status Tracking**: Completed, Undone, Failed states for each move +- **Conflict Handling**: Skips files with conflicts and reports warnings + +### How Undo Works + +1. **During Organization**: Every file move is recorded with source/destination paths +2. **Undo Execution**: + - Lists all completed moves to be reversed + - Shows preview of what will be restored + - Asks for user confirmation + - Moves files back to original locations + - Handles conflicts (source exists, destination missing) + - Cleans up empty directories left behind +3. **Status Updates**: Marks successfully undone operations +4. **Auto-Cleanup**: Removes undo log entries older than 30 days + +### Undo Safety Features + +- **Preview Before Action**: Always shows what will be undone before executing +- **Conflict Detection**: Checks if source path already exists before restoring +- **Missing File Handling**: Gracefully handles files that were deleted after move +- **Partial Undo Support**: Continues even if some operations fail +- **Dry-Run Mode**: Preview undo operations without executing them + ## Troubleshooting ### "API key not configured" @@ -308,6 +391,25 @@ download_folder = "/path/to/your/Downloads" **Solution**: Delete `.noentropy_cache.json` and run again. A new cache will be created. +### "No completed moves to undo" + +**Solution**: This means there are no file moves that can be undone. Either: +- No files have been organized yet +- All previous moves have already been undone +- The undo log was deleted + +### "Undo log not found" + +**Solution**: No undo history exists. Run organization first to create undo log, or check that `~/.config/noentropy/data/` directory exists. + +### "Skipping [file] - source already exists" + +**Solution**: A file already exists at the original location. The undo operation will skip it to prevent data loss. Manually check and resolve the conflict if needed. + +### "Failed to restore [file]" + +**Solution**: Check file permissions and ensure the file exists at the destination location. Other files will continue to be restored. + ## Development ### Build in Debug Mode @@ -346,7 +448,8 @@ noentropy/ │ ├── gemini.rs # Gemini API client │ ├── gemini_errors.rs # Error handling │ ├── cache.rs # Caching system -│ └── files.rs # File operations +│ ├── files.rs # File operations +│ └── undo.rs # Undo functionality ├── Cargo.toml # Dependencies ├── config.example.toml # Configuration template └── README.md # This file @@ -358,7 +461,7 @@ Based on community feedback, we're planning: - [ ] **Custom Categories** - Define custom categories in `config.toml` - [x] **Recursive Mode** - Organize files in subdirectories with `--recursive` flag -- [ ] **Undo Functionality** - Revert file organization changes +- [x] **Undo Functionality** - Revert file organization changes - [ ] **Custom Models** - Support for other AI providers - [ ] **GUI Version** - Desktop application for non-CLI users diff --git a/src/config.rs b/src/config.rs index 209e825..590de8c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ use colored::*; use serde::{Deserialize, Serialize}; +use std::fs; use std::path::PathBuf; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -70,6 +71,17 @@ impl Config { fn get_config_path() -> Result> { Ok(Self::get_config_dir()?.join("config.toml")) } + + pub fn get_data_dir() -> Result> { + let config_dir = Self::get_config_dir()?; + let data_dir = config_dir.join("data"); + fs::create_dir_all(&data_dir)?; + Ok(data_dir) + } + + pub fn get_undo_log_path() -> Result> { + Ok(Self::get_data_dir()?.join("undo_log.json")) + } } pub fn get_or_prompt_api_key() -> Result> { diff --git a/src/files.rs b/src/files.rs index c647931..21222d8 100644 --- a/src/files.rs +++ b/src/files.rs @@ -73,7 +73,7 @@ fn move_file_cross_platform(source: &Path, target: &Path) -> io::Result<()> { } } -pub fn execute_move(base_path: &Path, plan: OrganizationPlan) { +pub fn execute_move(base_path: &Path, plan: OrganizationPlan, mut undo_log: Option<&mut crate::undo::UndoLog>) { println!("\n{}", "--- EXECUTION PLAN ---".bold().underline()); if plan.files.is_empty() { @@ -140,7 +140,7 @@ pub fn execute_move(base_path: &Path, plan: OrganizationPlan) { if let Ok(metadata) = fs::metadata(&source) { if metadata.is_file() { - match move_file_cross_platform(&source, &target) { + match move_file_cross_platform(&source, &target) { Ok(_) => { if item.sub_category.is_empty() { println!("Moved: {} -> {}/", item.filename, item.category.green()); @@ -153,10 +153,18 @@ pub fn execute_move(base_path: &Path, plan: OrganizationPlan) { ); } moved_count += 1; + + if let Some(ref mut log) = undo_log { + log.record_move(source, target); + } } Err(e) => { eprintln!("{} Failed to move {}: {}", "ERROR:".red(), item.filename, e); error_count += 1; + + if let Some(ref mut log) = undo_log { + log.record_failed_move(source, target); + } } } } else { @@ -183,6 +191,152 @@ pub fn execute_move(base_path: &Path, plan: OrganizationPlan) { error_count.to_string().red() ); } + +pub fn undo_moves( + base_path: &Path, + undo_log: &mut crate::undo::UndoLog, + dry_run: bool, +) -> Result<(usize, usize, usize), Box> { + let completed_moves: Vec<_> = undo_log.get_completed_moves() + .into_iter() + .cloned() + .collect(); + + if completed_moves.is_empty() { + println!("{}", "No completed moves to undo.".yellow()); + return Ok((0, 0, 0)); + } + + println!("\n{}", "--- UNDO PREVIEW ---".bold().underline()); + println!("{} will restore {} files:", "INFO:".cyan(), completed_moves.len()); + + for record in &completed_moves { + if let Ok(rel_dest) = record.destination_path.strip_prefix(base_path) { + if let Ok(rel_source) = record.source_path.strip_prefix(base_path) { + println!( + " {} -> {}", + rel_dest.display().to_string().red(), + rel_source.display().to_string().green() + ); + } else { + println!( + " {} -> {}", + record.destination_path.display(), + record.source_path.display() + ); + } + } + } + + if dry_run { + println!("\n{}", "Dry run mode - skipping undo operation.".cyan()); + return Ok((completed_moves.len(), 0, 0)); + } + + eprint!("\nDo you want to undo these changes? [y/N]: "); + + let mut input = String::new(); + if io::stdin().read_line(&mut input).is_err() { + eprintln!("\n{}", "Failed to read input. Undo cancelled.".red()); + return Ok((0, 0, 0)); + } + + let input = input.trim().to_lowercase(); + + if input != "y" && input != "yes" { + println!("\n{}", "Undo cancelled.".red()); + return Ok((0, 0, 0)); + } + + println!("\n{}", "--- UNDOING MOVES ---".bold().underline()); + + let mut restored_count = 0; + let mut skipped_count = 0; + let mut failed_count = 0; + + for record in completed_moves { + let source = &record.source_path; + let destination = &record.destination_path; + + if !destination.exists() { + eprintln!( + "{} File not found at destination: {}", + "WARN:".yellow(), + destination.display() + ); + failed_count += 1; + continue; + } + + if source.exists() { + eprintln!( + "{} Skipping {} - source already exists", + "WARN:".yellow(), + source.display() + ); + skipped_count += 1; + continue; + } + + match move_file_cross_platform(destination, source) { + Ok(_) => { + println!( + "Restored: {} -> {}", + destination.display().to_string().red(), + source.display().to_string().green() + ); + restored_count += 1; + undo_log.mark_as_undone(destination); + } + Err(e) => { + eprintln!( + "{} Failed to restore {}: {}", + "ERROR:".red(), + source.display(), + e + ); + failed_count += 1; + } + } + } + + cleanup_empty_directories(base_path, undo_log)?; + + println!("\n{}", "UNDO COMPLETE!".bold().green()); + println!( + "Files restored: {}, Skipped: {}, Failed: {}", + restored_count.to_string().green(), + skipped_count.to_string().yellow(), + failed_count.to_string().red() + ); + + Ok((restored_count, skipped_count, failed_count)) +} + +fn cleanup_empty_directories( + base_path: &Path, + undo_log: &mut crate::undo::UndoLog, +) -> Result<(), Box> { + let directory_usage = undo_log.get_directory_usage(base_path); + + for dir_path in directory_usage.keys() { + let full_path = base_path.join(dir_path); + if full_path.is_dir() + && let Ok(mut entries) = fs::read_dir(&full_path) + && entries.next().is_none() + && fs::remove_dir(&full_path).is_ok() + { + println!( + "{} Removed empty directory: {}", + "INFO:".cyan(), + dir_path + ); + } + } + + Ok(()) +} + pub fn is_text_file(path: &Path) -> bool { let text_extensions = [ "txt", "md", "rs", "py", "js", "ts", "jsx", "tsx", "html", "css", "json", "xml", "csv", diff --git a/src/lib.rs b/src/lib.rs index 33c7cf2..e7487a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,3 +6,4 @@ pub mod gemini_errors; pub mod gemini_helpers; pub mod gemini_types; pub mod prompt; +pub mod undo; diff --git a/src/main.rs b/src/main.rs index 44c0cd8..1b3ade7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,11 +2,12 @@ use clap::Parser; use colored::*; use futures::future::join_all; use noentropy::cache::Cache; -use noentropy::config; +use noentropy::config::{self, Config}; use noentropy::files::{FileBatch, OrganizationPlan, execute_move}; use noentropy::gemini::GeminiClient; use noentropy::gemini_errors::GeminiError; -use std::path::Path; +use noentropy::undo::UndoLog; +use std::path::PathBuf; use std::sync::Arc; #[derive(Parser, Debug)] @@ -24,21 +25,56 @@ struct Args { max_concurrent: usize, #[arg(long, help = "Recursively searches files in subdirectory")] recursive: bool, + #[arg(long, help = "Undo the last file organization")] + undo: bool, } #[tokio::main] async fn main() -> Result<(), Box> { let args = Args::parse(); + + if args.undo { + let download_path = config::get_or_prompt_download_folder()?; + let undo_log_path = Config::get_undo_log_path()?; + + if !undo_log_path.exists() { + println!("{}", "No undo log found. Nothing to undo.".yellow()); + return Ok(()); + } + + let mut undo_log = UndoLog::load_or_create(&undo_log_path); + + if !undo_log.has_completed_moves() { + println!("{}", "No completed moves to undo.".yellow()); + return Ok(()); + } + + noentropy::files::undo_moves(&download_path, &mut undo_log, args.dry_run)?; + + if let Err(e) = undo_log.save(&undo_log_path) { + eprintln!("Warning: Failed to save undo log: {}", e); + } + + return Ok(()); + } + let api_key = config::get_or_prompt_api_key()?; let download_path = config::get_or_prompt_download_folder()?; let client: GeminiClient = GeminiClient::new(api_key); - let cache_path = Path::new(".noentropy_cache.json"); - let mut cache = Cache::load_or_create(cache_path); + let mut cache_path = std::env::var("HOME") + .map(PathBuf::from) + .expect("No Home found"); + cache_path.push(".config/noentropy/data/.noentropy_cache.json"); + let mut cache = Cache::load_or_create(cache_path.as_path()); cache.cleanup_old_entries(7 * 24 * 60 * 60); + let undo_log_path = Config::get_undo_log_path()?; + let mut undo_log = UndoLog::load_or_create(&undo_log_path); + undo_log.cleanup_old_entries(30 * 24 * 60 * 60); + let batch = FileBatch::from_path(download_path.clone(), args.recursive); if batch.filenames.is_empty() { @@ -110,14 +146,18 @@ async fn main() -> Result<(), Box> { if args.dry_run { println!("{} Dry run mode - skipping file moves.", "INFO:".cyan()); } else { - execute_move(&download_path, plan); + execute_move(&download_path, plan, Some(&mut undo_log)); } println!("{}", "Done!".green().bold()); - if let Err(e) = cache.save(cache_path) { + if let Err(e) = cache.save(cache_path.as_path()) { eprintln!("Warning: Failed to save cache: {}", e); } + if let Err(e) = undo_log.save(&undo_log_path) { + eprintln!("Warning: Failed to save undo log: {}", e); + } + Ok(()) } diff --git a/src/undo.rs b/src/undo.rs new file mode 100644 index 0000000..a2ab1bb --- /dev/null +++ b/src/undo.rs @@ -0,0 +1,226 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct FileMoveRecord { + pub source_path: PathBuf, + pub destination_path: PathBuf, + pub timestamp: u64, + pub status: MoveStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub enum MoveStatus { + Completed, + Undone, + Failed, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct UndoLog { + entries: Vec, + max_entries: usize, +} + +impl Default for UndoLog { + fn default() -> Self { + Self::new() + } +} + +impl UndoLog { + pub fn new() -> Self { + Self::with_max_entries(1000) + } + + pub fn with_max_entries(max_entries: usize) -> Self { + Self { + entries: Vec::new(), + max_entries, + } + } + + pub fn load_or_create(undo_log_path: &Path) -> Self { + if undo_log_path.exists() { + match fs::read_to_string(undo_log_path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(log) => { + println!( + "Loaded undo log with {} entries", + log.get_completed_count() + ); + log + } + Err(_) => { + println!("Undo log corrupted, creating new log"); + Self::new() + } + }, + Err(_) => { + println!("Failed to read undo log, creating new log"); + Self::new() + } + } + } else { + println!("Creating new undo log file"); + Self::new() + } + } + + pub fn save(&self, undo_log_path: &Path) -> Result<(), Box> { + if let Some(parent) = undo_log_path.parent() { + fs::create_dir_all(parent)?; + } + + let content = serde_json::to_string_pretty(self)?; + fs::write(undo_log_path, content)?; + Ok(()) + } + + pub fn record_move(&mut self, source_path: PathBuf, destination_path: PathBuf) { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let record = FileMoveRecord { + source_path, + destination_path, + timestamp, + status: MoveStatus::Completed, + }; + + self.entries.push(record); + + if self.entries.len() > self.max_entries { + self.evict_oldest(); + } + } + + pub fn record_failed_move(&mut self, source_path: PathBuf, destination_path: PathBuf) { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let record = FileMoveRecord { + source_path, + destination_path, + timestamp, + status: MoveStatus::Failed, + }; + + self.entries.push(record); + + if self.entries.len() > self.max_entries { + self.evict_oldest(); + } + } + + pub fn get_completed_moves(&self) -> Vec<&FileMoveRecord> { + self.entries + .iter() + .filter(|entry| entry.status == MoveStatus::Completed) + .collect() + } + + pub fn mark_as_undone(&mut self, source_path: &Path) { + for entry in &mut self.entries { + if entry.status == MoveStatus::Completed && entry.destination_path == source_path { + entry.status = MoveStatus::Undone; + break; + } + } + } + + pub fn get_completed_count(&self) -> usize { + self.entries + .iter() + .filter(|entry| entry.status == MoveStatus::Completed) + .count() + } + + pub fn has_completed_moves(&self) -> bool { + self.get_completed_count() > 0 + } + + pub fn cleanup_old_entries(&mut self, max_age_seconds: u64) { + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let initial_count = self.entries.len(); + + self.entries.retain(|entry| { + current_time - entry.timestamp < max_age_seconds + || entry.status == MoveStatus::Completed + }); + + let removed_count = initial_count - self.entries.len(); + if removed_count > 0 { + println!( + "Cleaned up {} old undo log entries", + removed_count + ); + } + + if self.entries.len() > self.max_entries { + self.compact_log(); + } + } + + fn evict_oldest(&mut self) { + if let Some(oldest_index) = self + .entries + .iter() + .enumerate() + .filter(|(_, entry)| entry.status == MoveStatus::Undone) + .min_by_key(|(_, entry)| entry.timestamp) + .map(|(i, _)| i) + { + self.entries.remove(oldest_index); + println!("Evicted oldest undone log entry to maintain limit"); + return; + } + + if let Some(oldest_index) = self + .entries + .iter() + .enumerate() + .min_by_key(|(_, entry)| entry.timestamp) + .map(|(i, _)| i) + { + self.entries.remove(oldest_index); + println!("Evicted oldest log entry to maintain limit"); + } + } + + fn compact_log(&mut self) { + while self.entries.len() > self.max_entries { + self.evict_oldest(); + } + } + + pub fn get_directory_usage(&self, base_path: &Path) -> HashMap { + let mut usage = HashMap::new(); + + for entry in &self.entries { + if entry.status == MoveStatus::Completed + && let Ok(rel_path) = entry.destination_path.strip_prefix(base_path) + && let Some(parent) = rel_path.parent() { + let dir_path = parent.to_string_lossy().into_owned(); + *usage.entry(dir_path).or_insert(0) += 1; + } + } + + usage + } +} + +#[cfg(test)] +#[path = "undo_tests.rs"] +mod tests; diff --git a/src/undo_tests.rs b/src/undo_tests.rs new file mode 100644 index 0000000..7087e61 --- /dev/null +++ b/src/undo_tests.rs @@ -0,0 +1,178 @@ +use super::*; +use tempfile::TempDir; +use std::fs; + +#[test] +fn test_undo_log_creation() { + let log = UndoLog::new(); + assert_eq!(log.get_completed_count(), 0); + assert!(!log.has_completed_moves()); +} + +#[test] +fn test_record_move() { + let mut log = UndoLog::new(); + let source = PathBuf::from("/test/source.txt"); + let dest = PathBuf::from("/test/dest/source.txt"); + + log.record_move(source.clone(), dest.clone()); + + assert_eq!(log.get_completed_count(), 1); + assert!(log.has_completed_moves()); + + let completed = log.get_completed_moves(); + assert_eq!(completed.len(), 1); + assert_eq!(completed[0].source_path, source); + assert_eq!(completed[0].destination_path, dest); + assert_eq!(completed[0].status, MoveStatus::Completed); +} + +#[test] +fn test_record_failed_move() { + let mut log = UndoLog::new(); + let source = PathBuf::from("/test/source.txt"); + let dest = PathBuf::from("/test/dest/source.txt"); + + log.record_failed_move(source.clone(), dest.clone()); + + assert_eq!(log.get_completed_count(), 0); + assert!(!log.has_completed_moves()); +} + +#[test] +fn test_mark_as_undone() { + let mut log = UndoLog::new(); + let source = PathBuf::from("/test/source.txt"); + let dest = PathBuf::from("/test/dest/source.txt"); + + log.record_move(source.clone(), dest.clone()); + assert_eq!(log.get_completed_count(), 1); + + log.mark_as_undone(&dest); + assert_eq!(log.get_completed_count(), 0); +} + +#[test] +fn test_save_and_load() { + let temp_dir = TempDir::new().unwrap(); + let undo_log_path = temp_dir.path().join("undo_log.json"); + + let mut log = UndoLog::new(); + log.record_move( + PathBuf::from("/test/source.txt"), + PathBuf::from("/test/dest/source.txt"), + ); + + log.save(&undo_log_path).unwrap(); + assert!(undo_log_path.exists()); + + let loaded_log = UndoLog::load_or_create(&undo_log_path); + assert_eq!(loaded_log.get_completed_count(), 1); +} + +#[test] +fn test_cleanup_old_entries() { + let mut log = UndoLog::new(); + + let old_timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() - (10 * 24 * 60 * 60); + + let source = PathBuf::from("/test/source.txt"); + let dest = PathBuf::from("/test/dest/source.txt"); + + let old_record = FileMoveRecord { + source_path: source.clone(), + destination_path: dest.clone(), + timestamp: old_timestamp, + status: MoveStatus::Undone, + }; + + log.entries.push(old_record.clone()); + log.record_move(source.clone(), dest); + + assert_eq!(log.entries.len(), 2); + + log.cleanup_old_entries(7 * 24 * 60 * 60); + + assert_eq!(log.entries.len(), 1); + assert_eq!(log.get_completed_count(), 1); +} + +#[test] +fn test_evict_oldest() { + let mut log = UndoLog::with_max_entries(2); + + log.record_move( + PathBuf::from("/test/source1.txt"), + PathBuf::from("/test/dest/source1.txt"), + ); + + std::thread::sleep(std::time::Duration::from_millis(10)); + + log.record_move( + PathBuf::from("/test/source2.txt"), + PathBuf::from("/test/dest/source2.txt"), + ); + + log.record_move( + PathBuf::from("/test/source3.txt"), + PathBuf::from("/test/dest/source3.txt"), + ); + + assert_eq!(log.get_completed_count(), 2); +} + +#[test] +fn test_get_directory_usage() { + let mut log = UndoLog::new(); + let base_path = PathBuf::from("/test"); + + log.record_move( + PathBuf::from("/test/source1.txt"), + PathBuf::from("/test/Documents/report.txt"), + ); + + log.record_move( + PathBuf::from("/test/source2.txt"), + PathBuf::from("/test/Documents/notes.txt"), + ); + + log.record_move( + PathBuf::from("/test/source3.txt"), + PathBuf::from("/test/Images/photo.png"), + ); + + let usage = log.get_directory_usage(&base_path); + + assert_eq!(usage.get("Documents"), Some(&2)); + assert_eq!(usage.get("Images"), Some(&1)); +} + +#[test] +fn test_load_corrupted_log() { + let temp_dir = TempDir::new().unwrap(); + let undo_log_path = temp_dir.path().join("undo_log.json"); + + fs::write(&undo_log_path, "invalid json").unwrap(); + + let log = UndoLog::load_or_create(&undo_log_path); + assert_eq!(log.get_completed_count(), 0); +} + +#[test] +fn test_multiple_moves_same_file() { + let mut log = UndoLog::new(); + let source = PathBuf::from("/test/source.txt"); + let dest1 = PathBuf::from("/test/dest1/source.txt"); + let dest2 = PathBuf::from("/test/dest2/source.txt"); + + log.record_move(source.clone(), dest1.clone()); + log.record_move(source.clone(), dest2); + + assert_eq!(log.get_completed_count(), 2); + + log.mark_as_undone(&dest1); + assert_eq!(log.get_completed_count(), 1); +}