added undo feature
feature added by Agent
This commit is contained in:
111
README.md
111
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
|
- **📝 Text File Support** - Inspects 30+ text formats for better categorization
|
||||||
- **✅ Interactive Confirmation** - Review organization plan before execution
|
- **✅ Interactive Confirmation** - Review organization plan before execution
|
||||||
- **🎯 Configurable** - Adjust concurrency limits and model settings
|
- **🎯 Configurable** - Adjust concurrency limits and model settings
|
||||||
|
- **↩️ Undo Support** - Revert file organization changes if needed
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -135,11 +136,31 @@ cargo run --release -- --max-concurrent 10
|
|||||||
### Combined Options
|
### Combined Options
|
||||||
|
|
||||||
Use multiple options together:
|
Use multiple options together:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run --release -- --dry-run --max-concurrent 3
|
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
|
### Recursive Mode
|
||||||
|
|
||||||
Organize files in subdirectories recursively:
|
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 |
|
| `--dry-run` | `-d` | `false` | Preview changes without moving files |
|
||||||
| `--max-concurrent` | `-m` | `5` | Maximum concurrent API requests |
|
| `--max-concurrent` | `-m` | `5` | Maximum concurrent API requests |
|
||||||
| `--recursive` | None | `false` | Recursively search files in subdirectories |
|
| `--recursive` | None | `false` | Recursively search files in subdirectories |
|
||||||
|
| `--undo` | None | `false` | Undo the last file organization |
|
||||||
| `--help` | `-h` | - | Show help message |
|
| `--help` | `-h` | - | Show help message |
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
@@ -223,6 +245,36 @@ Files moved: 47, Errors: 0
|
|||||||
Done!
|
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
|
## Supported Categories
|
||||||
|
|
||||||
NoEntropy organizes files into these 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
|
1. **First Run**: Files are analyzed and categorized via Gemini API
|
||||||
2. **Response Cached**: Organization plan saved with file metadata
|
2. **Response Cached**: Organization plan saved with file metadata
|
||||||
3. **Subsequent Runs**:
|
3. **Subsequent Runs**:
|
||||||
- Checks if files changed (size/modification time)
|
- Checks if files changed (size/modification time)
|
||||||
- If unchanged, uses cached categorization
|
- If unchanged, uses cached categorization
|
||||||
- If changed, re-analyzes via API
|
- If changed, re-analyzes via API
|
||||||
4. **Auto-Cleanup**: Removes cache entries older than 7 days
|
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
|
## Troubleshooting
|
||||||
|
|
||||||
### "API key not configured"
|
### "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.
|
**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
|
## Development
|
||||||
|
|
||||||
### Build in Debug Mode
|
### Build in Debug Mode
|
||||||
@@ -346,7 +448,8 @@ noentropy/
|
|||||||
│ ├── gemini.rs # Gemini API client
|
│ ├── gemini.rs # Gemini API client
|
||||||
│ ├── gemini_errors.rs # Error handling
|
│ ├── gemini_errors.rs # Error handling
|
||||||
│ ├── cache.rs # Caching system
|
│ ├── cache.rs # Caching system
|
||||||
│ └── files.rs # File operations
|
│ ├── files.rs # File operations
|
||||||
|
│ └── undo.rs # Undo functionality
|
||||||
├── Cargo.toml # Dependencies
|
├── Cargo.toml # Dependencies
|
||||||
├── config.example.toml # Configuration template
|
├── config.example.toml # Configuration template
|
||||||
└── README.md # This file
|
└── README.md # This file
|
||||||
@@ -358,7 +461,7 @@ Based on community feedback, we're planning:
|
|||||||
|
|
||||||
- [ ] **Custom Categories** - Define custom categories in `config.toml`
|
- [ ] **Custom Categories** - Define custom categories in `config.toml`
|
||||||
- [x] **Recursive Mode** - Organize files in subdirectories with `--recursive` flag
|
- [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
|
- [ ] **Custom Models** - Support for other AI providers
|
||||||
- [ ] **GUI Version** - Desktop application for non-CLI users
|
- [ ] **GUI Version** - Desktop application for non-CLI users
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use colored::*;
|
use colored::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
@@ -70,6 +71,17 @@ impl Config {
|
|||||||
fn get_config_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
fn get_config_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||||
Ok(Self::get_config_dir()?.join("config.toml"))
|
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>> {
|
pub fn get_or_prompt_api_key() -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
|||||||
158
src/files.rs
158
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());
|
println!("\n{}", "--- EXECUTION PLAN ---".bold().underline());
|
||||||
|
|
||||||
if plan.files.is_empty() {
|
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 let Ok(metadata) = fs::metadata(&source) {
|
||||||
if metadata.is_file() {
|
if metadata.is_file() {
|
||||||
match move_file_cross_platform(&source, &target) {
|
match move_file_cross_platform(&source, &target) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
if item.sub_category.is_empty() {
|
if item.sub_category.is_empty() {
|
||||||
println!("Moved: {} -> {}/", item.filename, item.category.green());
|
println!("Moved: {} -> {}/", item.filename, item.category.green());
|
||||||
@@ -153,10 +153,18 @@ pub fn execute_move(base_path: &Path, plan: OrganizationPlan) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
moved_count += 1;
|
moved_count += 1;
|
||||||
|
|
||||||
|
if let Some(ref mut log) = undo_log {
|
||||||
|
log.record_move(source, target);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("{} Failed to move {}: {}", "ERROR:".red(), item.filename, e);
|
eprintln!("{} Failed to move {}: {}", "ERROR:".red(), item.filename, e);
|
||||||
error_count += 1;
|
error_count += 1;
|
||||||
|
|
||||||
|
if let Some(ref mut log) = undo_log {
|
||||||
|
log.record_failed_move(source, target);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -183,6 +191,152 @@ pub fn execute_move(base_path: &Path, plan: OrganizationPlan) {
|
|||||||
error_count.to_string().red()
|
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 {
|
pub fn is_text_file(path: &Path) -> bool {
|
||||||
let text_extensions = [
|
let text_extensions = [
|
||||||
"txt", "md", "rs", "py", "js", "ts", "jsx", "tsx", "html", "css", "json", "xml", "csv",
|
"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_helpers;
|
||||||
pub mod gemini_types;
|
pub mod gemini_types;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
|
pub mod undo;
|
||||||
|
|||||||
52
src/main.rs
52
src/main.rs
@@ -2,11 +2,12 @@ use clap::Parser;
|
|||||||
use colored::*;
|
use colored::*;
|
||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
use noentropy::cache::Cache;
|
use noentropy::cache::Cache;
|
||||||
use noentropy::config;
|
use noentropy::config::{self, Config};
|
||||||
use noentropy::files::{FileBatch, OrganizationPlan, execute_move};
|
use noentropy::files::{FileBatch, OrganizationPlan, execute_move};
|
||||||
use noentropy::gemini::GeminiClient;
|
use noentropy::gemini::GeminiClient;
|
||||||
use noentropy::gemini_errors::GeminiError;
|
use noentropy::gemini_errors::GeminiError;
|
||||||
use std::path::Path;
|
use noentropy::undo::UndoLog;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -24,21 +25,56 @@ struct Args {
|
|||||||
max_concurrent: usize,
|
max_concurrent: usize,
|
||||||
#[arg(long, help = "Recursively searches files in subdirectory")]
|
#[arg(long, help = "Recursively searches files in subdirectory")]
|
||||||
recursive: bool,
|
recursive: bool,
|
||||||
|
#[arg(long, help = "Undo the last file organization")]
|
||||||
|
undo: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let args = Args::parse();
|
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 api_key = config::get_or_prompt_api_key()?;
|
||||||
let download_path = config::get_or_prompt_download_folder()?;
|
let download_path = config::get_or_prompt_download_folder()?;
|
||||||
|
|
||||||
let client: GeminiClient = GeminiClient::new(api_key);
|
let client: GeminiClient = GeminiClient::new(api_key);
|
||||||
|
|
||||||
let cache_path = Path::new(".noentropy_cache.json");
|
let mut cache_path = std::env::var("HOME")
|
||||||
let mut cache = Cache::load_or_create(cache_path);
|
.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);
|
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);
|
let batch = FileBatch::from_path(download_path.clone(), args.recursive);
|
||||||
|
|
||||||
if batch.filenames.is_empty() {
|
if batch.filenames.is_empty() {
|
||||||
@@ -110,14 +146,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if args.dry_run {
|
if args.dry_run {
|
||||||
println!("{} Dry run mode - skipping file moves.", "INFO:".cyan());
|
println!("{} Dry run mode - skipping file moves.", "INFO:".cyan());
|
||||||
} else {
|
} else {
|
||||||
execute_move(&download_path, plan);
|
execute_move(&download_path, plan, Some(&mut undo_log));
|
||||||
}
|
}
|
||||||
println!("{}", "Done!".green().bold());
|
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);
|
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(())
|
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