Merge pull request #3 from glitchySid/feature/undo

added undo feature
This commit is contained in:
Siddhesh Mhatre
2025-12-29 21:55:11 +05:30
committed by GitHub
7 changed files with 726 additions and 11 deletions

111
README.md
View File

@@ -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

View File

@@ -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<PathBuf, Box<dyn std::error::Error>> {
Ok(Self::get_config_dir()?.join("config.toml"))
}
pub fn get_data_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
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<PathBuf, Box<dyn std::error::Error>> {
Ok(Self::get_data_dir()?.join("undo_log.json"))
}
}
pub fn get_or_prompt_api_key() -> Result<String, Box<dyn std::error::Error>> {

View File

@@ -73,7 +73,11 @@ 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() {
@@ -153,10 +157,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 +195,153 @@ 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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",

View File

@@ -6,3 +6,4 @@ pub mod gemini_errors;
pub mod gemini_helpers;
pub mod gemini_types;
pub mod prompt;
pub mod undo;

View File

@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}

221
src/undo.rs Normal file
View File

@@ -0,0 +1,221 @@
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<FileMoveRecord>,
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::<UndoLog>(&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<dyn std::error::Error>> {
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<String, usize> {
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;

179
src/undo_tests.rs Normal file
View File

@@ -0,0 +1,179 @@
use super::*;
use std::fs;
use tempfile::TempDir;
#[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);
}