added undo feature
feature added by Agent
This commit is contained in:
109
README.md
109
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:
|
||||
@@ -266,6 +318,37 @@ NoEntropy includes an intelligent caching system to minimize API calls:
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
156
src/files.rs
156
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() {
|
||||
@@ -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<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",
|
||||
|
||||
@@ -6,3 +6,4 @@ pub mod gemini_errors;
|
||||
pub mod gemini_helpers;
|
||||
pub mod gemini_types;
|
||||
pub mod prompt;
|
||||
pub mod undo;
|
||||
|
||||
52
src/main.rs
52
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<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(())
|
||||
}
|
||||
|
||||
|
||||
226
src/undo.rs
Normal file
226
src/undo.rs
Normal file
@@ -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<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;
|
||||
178
src/undo_tests.rs
Normal file
178
src/undo_tests.rs
Normal file
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user