Merge pull request #17 from glitchySid/refactor
refactor: reorganize file operations and extract test modules
This commit is contained in:
@@ -7,39 +7,68 @@ use crate::settings::{Config, Prompter};
|
|||||||
use crate::storage::{Cache, UndoLog};
|
use crate::storage::{Cache, UndoLog};
|
||||||
use colored::*;
|
use colored::*;
|
||||||
|
|
||||||
/// Main entry point for file organization.
|
fn initialize_cache() -> Result<(Cache, std::path::PathBuf), Box<dyn std::error::Error>> {
|
||||||
/// Coordinates cache, undo log, and delegates to online/offline handlers.
|
const CACHE_RETENTION_SECONDS: u64 = 7 * 24 * 60 * 60;
|
||||||
pub async fn handle_organization(
|
|
||||||
args: Args,
|
|
||||||
config: Config,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let data_dir = Config::get_data_dir()?;
|
let data_dir = Config::get_data_dir()?;
|
||||||
let cache_path = data_dir.join(".noentropy_cache.json");
|
let cache_path = data_dir.join(".noentropy_cache.json");
|
||||||
let mut cache = Cache::load_or_create(&cache_path);
|
let mut cache = Cache::load_or_create(&cache_path);
|
||||||
|
|
||||||
const CACHE_RETENTION_SECONDS: u64 = 7 * 24 * 60 * 60; // 7 days
|
|
||||||
const UNDO_LOG_RETENTION_SECONDS: u64 = 30 * 24 * 60 * 60; // 30 days
|
|
||||||
|
|
||||||
cache.cleanup_old_entries(CACHE_RETENTION_SECONDS);
|
cache.cleanup_old_entries(CACHE_RETENTION_SECONDS);
|
||||||
|
Ok((cache, cache_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initialize_undo_log() -> Result<(UndoLog, std::path::PathBuf), Box<dyn std::error::Error>> {
|
||||||
|
const UNDO_LOG_RETENTION_SECONDS: u64 = 30 * 24 * 60 * 60;
|
||||||
let undo_log_path = Config::get_undo_log_path()?;
|
let undo_log_path = Config::get_undo_log_path()?;
|
||||||
let mut undo_log = UndoLog::load_or_create(&undo_log_path);
|
let mut undo_log = UndoLog::load_or_create(&undo_log_path);
|
||||||
undo_log.cleanup_old_entries(UNDO_LOG_RETENTION_SECONDS);
|
undo_log.cleanup_old_entries(UNDO_LOG_RETENTION_SECONDS);
|
||||||
|
Ok((undo_log, undo_log_path))
|
||||||
|
}
|
||||||
|
|
||||||
// Use custom path if provided, otherwise fall back to configured download folder
|
async fn resolve_target_path(args: &Args, config: &Config) -> Option<std::path::PathBuf> {
|
||||||
let target_path = args
|
let target_path = args
|
||||||
.path
|
.path
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| config.download_folder.clone());
|
.unwrap_or_else(|| config.download_folder.clone());
|
||||||
|
|
||||||
// Validate and normalize the target path early
|
match validate_and_normalize_path(&target_path).await {
|
||||||
let target_path = match validate_and_normalize_path(&target_path).await {
|
Ok(normalized) => Some(normalized),
|
||||||
Ok(normalized) => normalized,
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("{}", format!("ERROR: {}", e).red());
|
println!("{}", format!("ERROR: {}", e).red());
|
||||||
return Ok(());
|
None
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn determine_offline_mode(args: &Args, config: &Config) -> Option<bool> {
|
||||||
|
if args.offline {
|
||||||
|
println!("{}", "Using offline mode (--offline flag).".cyan());
|
||||||
|
return Some(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = GeminiClient::new(&config.api_key, &config.categories);
|
||||||
|
match client.check_connectivity().await {
|
||||||
|
Ok(()) => Some(false),
|
||||||
|
Err(e) => {
|
||||||
|
if Prompter::prompt_offline_mode(&e.to_string()) {
|
||||||
|
Some(true)
|
||||||
|
} else {
|
||||||
|
println!("{}", "Exiting.".yellow());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_organization(
|
||||||
|
args: Args,
|
||||||
|
config: Config,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let (mut cache, cache_path) = initialize_cache()?;
|
||||||
|
let (mut undo_log, undo_log_path) = initialize_undo_log()?;
|
||||||
|
|
||||||
|
let Some(target_path) = resolve_target_path(&args, &config).await else {
|
||||||
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
let batch = FileBatch::from_path(&target_path, args.recursive);
|
let batch = FileBatch::from_path(&target_path, args.recursive);
|
||||||
@@ -51,23 +80,8 @@ pub async fn handle_organization(
|
|||||||
|
|
||||||
println!("Found {} files to organize.", batch.count());
|
println!("Found {} files to organize.", batch.count());
|
||||||
|
|
||||||
// Determine if we should use offline mode
|
let Some(use_offline) = determine_offline_mode(&args, &config).await else {
|
||||||
let use_offline = if args.offline {
|
return Ok(());
|
||||||
println!("{}", "Using offline mode (--offline flag).".cyan());
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
let client = GeminiClient::new(&config.api_key, &config.categories);
|
|
||||||
match client.check_connectivity().await {
|
|
||||||
Ok(()) => false,
|
|
||||||
Err(e) => {
|
|
||||||
if Prompter::prompt_offline_mode(&e.to_string()) {
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
println!("{}", "Exiting.".yellow());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let plan = if use_offline {
|
let plan = if use_offline {
|
||||||
@@ -84,9 +98,8 @@ pub async fn handle_organization(
|
|||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only save if we have a plan (online mode returns None after moving)
|
|
||||||
if plan.is_none()
|
if plan.is_none()
|
||||||
&& let Err(e) = cache.save(cache_path.as_path())
|
&& let Err(e) = cache.save(&cache_path)
|
||||||
{
|
{
|
||||||
eprintln!("Warning: Failed to save cache: {}", e);
|
eprintln!("Warning: Failed to save cache: {}", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,67 +42,5 @@ impl FileBatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "batch_test.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
use std::fs::{self, File};
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_file_batch_from_path() {
|
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
|
||||||
let dir_path = temp_dir.path();
|
|
||||||
|
|
||||||
File::create(dir_path.join("file1.txt")).unwrap();
|
|
||||||
File::create(dir_path.join("file2.rs")).unwrap();
|
|
||||||
fs::create_dir(dir_path.join("subdir")).unwrap();
|
|
||||||
|
|
||||||
let batch = FileBatch::from_path(dir_path, false);
|
|
||||||
assert_eq!(batch.count(), 2);
|
|
||||||
assert!(batch.filenames.contains(&"file1.txt".to_string()));
|
|
||||||
assert!(batch.filenames.contains(&"file2.rs".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_file_batch_from_path_nonexistent() {
|
|
||||||
let batch = FileBatch::from_path(Path::new("/nonexistent/path"), false);
|
|
||||||
assert_eq!(batch.count(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_file_batch_from_path_non_recursive() {
|
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
|
||||||
let dir_path = temp_dir.path();
|
|
||||||
File::create(dir_path.join("file1.txt")).unwrap();
|
|
||||||
File::create(dir_path.join("file2.rs")).unwrap();
|
|
||||||
fs::create_dir(dir_path.join("subdir")).unwrap();
|
|
||||||
File::create(dir_path.join("subdir").join("file3.txt")).unwrap();
|
|
||||||
let batch = FileBatch::from_path(dir_path, false);
|
|
||||||
assert_eq!(batch.count(), 2);
|
|
||||||
assert!(batch.filenames.contains(&"file1.txt".to_string()));
|
|
||||||
assert!(batch.filenames.contains(&"file2.rs".to_string()));
|
|
||||||
assert!(!batch.filenames.contains(&"subdir/file3.txt".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_file_batch_from_path_recursive() {
|
|
||||||
let temp_dir = tempfile::tempdir().unwrap();
|
|
||||||
let dir_path = temp_dir.path();
|
|
||||||
File::create(dir_path.join("file1.txt")).unwrap();
|
|
||||||
fs::create_dir(dir_path.join("subdir1")).unwrap();
|
|
||||||
File::create(dir_path.join("subdir1").join("file2.rs")).unwrap();
|
|
||||||
fs::create_dir(dir_path.join("subdir1").join("nested")).unwrap();
|
|
||||||
File::create(dir_path.join("subdir1").join("nested").join("file3.md")).unwrap();
|
|
||||||
fs::create_dir(dir_path.join("subdir2")).unwrap();
|
|
||||||
File::create(dir_path.join("subdir2").join("file4.py")).unwrap();
|
|
||||||
let batch = FileBatch::from_path(dir_path, true);
|
|
||||||
assert_eq!(batch.count(), 4);
|
|
||||||
assert!(batch.filenames.contains(&"file1.txt".to_string()));
|
|
||||||
assert!(batch.filenames.contains(&"subdir1/file2.rs".to_string()));
|
|
||||||
assert!(
|
|
||||||
batch
|
|
||||||
.filenames
|
|
||||||
.contains(&"subdir1/nested/file3.md".to_string())
|
|
||||||
);
|
|
||||||
assert!(batch.filenames.contains(&"subdir2/file4.py".to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
62
src/files/batch_test.rs
Normal file
62
src/files/batch_test.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
use super::*;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_batch_from_path() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let dir_path = temp_dir.path();
|
||||||
|
|
||||||
|
File::create(dir_path.join("file1.txt")).unwrap();
|
||||||
|
File::create(dir_path.join("file2.rs")).unwrap();
|
||||||
|
fs::create_dir(dir_path.join("subdir")).unwrap();
|
||||||
|
|
||||||
|
let batch = FileBatch::from_path(dir_path, false);
|
||||||
|
assert_eq!(batch.count(), 2);
|
||||||
|
assert!(batch.filenames.contains(&"file1.txt".to_string()));
|
||||||
|
assert!(batch.filenames.contains(&"file2.rs".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_batch_from_path_nonexistent() {
|
||||||
|
let batch = FileBatch::from_path(Path::new("/nonexistent/path"), false);
|
||||||
|
assert_eq!(batch.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_batch_from_path_non_recursive() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let dir_path = temp_dir.path();
|
||||||
|
File::create(dir_path.join("file1.txt")).unwrap();
|
||||||
|
File::create(dir_path.join("file2.rs")).unwrap();
|
||||||
|
fs::create_dir(dir_path.join("subdir")).unwrap();
|
||||||
|
File::create(dir_path.join("subdir").join("file3.txt")).unwrap();
|
||||||
|
let batch = FileBatch::from_path(dir_path, false);
|
||||||
|
assert_eq!(batch.count(), 2);
|
||||||
|
assert!(batch.filenames.contains(&"file1.txt".to_string()));
|
||||||
|
assert!(batch.filenames.contains(&"file2.rs".to_string()));
|
||||||
|
assert!(!batch.filenames.contains(&"subdir/file3.txt".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_batch_from_path_recursive() {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let dir_path = temp_dir.path();
|
||||||
|
File::create(dir_path.join("file1.txt")).unwrap();
|
||||||
|
fs::create_dir(dir_path.join("subdir1")).unwrap();
|
||||||
|
File::create(dir_path.join("subdir1").join("file2.rs")).unwrap();
|
||||||
|
fs::create_dir(dir_path.join("subdir1").join("nested")).unwrap();
|
||||||
|
File::create(dir_path.join("subdir1").join("nested").join("file3.md")).unwrap();
|
||||||
|
fs::create_dir(dir_path.join("subdir2")).unwrap();
|
||||||
|
File::create(dir_path.join("subdir2").join("file4.py")).unwrap();
|
||||||
|
let batch = FileBatch::from_path(dir_path, true);
|
||||||
|
assert_eq!(batch.count(), 4);
|
||||||
|
assert!(batch.filenames.contains(&"file1.txt".to_string()));
|
||||||
|
assert!(batch.filenames.contains(&"subdir1/file2.rs".to_string()));
|
||||||
|
assert!(
|
||||||
|
batch
|
||||||
|
.filenames
|
||||||
|
.contains(&"subdir1/nested/file3.md".to_string())
|
||||||
|
);
|
||||||
|
assert!(batch.filenames.contains(&"subdir2/file4.py".to_string()));
|
||||||
|
}
|
||||||
@@ -146,52 +146,5 @@ pub fn categorize_files_offline(filenames: Vec<String>) -> OfflineCategorization
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "categorizer_test.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_categorize_known_extensions() {
|
|
||||||
assert_eq!(categorize_by_extension("photo.jpg"), Some("Images"));
|
|
||||||
assert_eq!(categorize_by_extension("document.pdf"), Some("Documents"));
|
|
||||||
assert_eq!(categorize_by_extension("setup.exe"), Some("Installers"));
|
|
||||||
assert_eq!(categorize_by_extension("song.mp3"), Some("Music"));
|
|
||||||
assert_eq!(categorize_by_extension("movie.mp4"), Some("Video"));
|
|
||||||
assert_eq!(categorize_by_extension("archive.zip"), Some("Archives"));
|
|
||||||
assert_eq!(categorize_by_extension("main.rs"), Some("Code"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_categorize_case_insensitive() {
|
|
||||||
assert_eq!(categorize_by_extension("PHOTO.JPG"), Some("Images"));
|
|
||||||
assert_eq!(categorize_by_extension("Photo.Png"), Some("Images"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_categorize_unknown_extension() {
|
|
||||||
assert_eq!(categorize_by_extension("file.xyz"), None);
|
|
||||||
assert_eq!(categorize_by_extension("file.unknown"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_categorize_no_extension() {
|
|
||||||
assert_eq!(categorize_by_extension("README"), None);
|
|
||||||
assert_eq!(categorize_by_extension("Makefile"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_categorize_files_offline() {
|
|
||||||
let filenames = vec![
|
|
||||||
"photo.jpg".to_string(),
|
|
||||||
"doc.pdf".to_string(),
|
|
||||||
"unknown".to_string(),
|
|
||||||
"file.xyz".to_string(),
|
|
||||||
];
|
|
||||||
|
|
||||||
let result = categorize_files_offline(filenames);
|
|
||||||
|
|
||||||
assert_eq!(result.plan.files.len(), 2);
|
|
||||||
assert_eq!(result.skipped.len(), 2);
|
|
||||||
assert!(result.skipped.contains(&"unknown".to_string()));
|
|
||||||
assert!(result.skipped.contains(&"file.xyz".to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
47
src/files/categorizer_test.rs
Normal file
47
src/files/categorizer_test.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_categorize_known_extensions() {
|
||||||
|
assert_eq!(categorize_by_extension("photo.jpg"), Some("Images"));
|
||||||
|
assert_eq!(categorize_by_extension("document.pdf"), Some("Documents"));
|
||||||
|
assert_eq!(categorize_by_extension("setup.exe"), Some("Installers"));
|
||||||
|
assert_eq!(categorize_by_extension("song.mp3"), Some("Music"));
|
||||||
|
assert_eq!(categorize_by_extension("movie.mp4"), Some("Video"));
|
||||||
|
assert_eq!(categorize_by_extension("archive.zip"), Some("Archives"));
|
||||||
|
assert_eq!(categorize_by_extension("main.rs"), Some("Code"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_categorize_case_insensitive() {
|
||||||
|
assert_eq!(categorize_by_extension("PHOTO.JPG"), Some("Images"));
|
||||||
|
assert_eq!(categorize_by_extension("Photo.Png"), Some("Images"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_categorize_unknown_extension() {
|
||||||
|
assert_eq!(categorize_by_extension("file.xyz"), None);
|
||||||
|
assert_eq!(categorize_by_extension("file.unknown"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_categorize_no_extension() {
|
||||||
|
assert_eq!(categorize_by_extension("README"), None);
|
||||||
|
assert_eq!(categorize_by_extension("Makefile"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_categorize_files_offline() {
|
||||||
|
let filenames = vec![
|
||||||
|
"photo.jpg".to_string(),
|
||||||
|
"doc.pdf".to_string(),
|
||||||
|
"unknown".to_string(),
|
||||||
|
"file.xyz".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let result = categorize_files_offline(filenames);
|
||||||
|
|
||||||
|
assert_eq!(result.plan.files.len(), 2);
|
||||||
|
assert_eq!(result.skipped.len(), 2);
|
||||||
|
assert!(result.skipped.contains(&"unknown".to_string()));
|
||||||
|
assert!(result.skipped.contains(&"file.xyz".to_string()));
|
||||||
|
}
|
||||||
16
src/files/file_ops.rs
Normal file
16
src/files/file_ops.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use std::{fs, io, path::Path};
|
||||||
|
|
||||||
|
pub fn move_file_cross_platform(source: &Path, target: &Path) -> io::Result<()> {
|
||||||
|
match fs::rename(source, target) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) => {
|
||||||
|
if cfg!(windows) || e.kind() == io::ErrorKind::CrossesDevices {
|
||||||
|
fs::copy(source, target)?;
|
||||||
|
fs::remove_file(source)?;
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
pub mod batch;
|
pub mod batch;
|
||||||
pub mod categorizer;
|
pub mod categorizer;
|
||||||
pub mod detector;
|
pub mod detector;
|
||||||
|
mod file_ops;
|
||||||
pub mod mover;
|
pub mod mover;
|
||||||
pub mod undo;
|
pub mod undo;
|
||||||
|
|
||||||
pub use batch::FileBatch;
|
pub use batch::FileBatch;
|
||||||
pub use categorizer::{OfflineCategorizationResult, categorize_files_offline};
|
pub use categorizer::{OfflineCategorizationResult, categorize_files_offline};
|
||||||
pub use detector::{is_text_file, read_file_sample};
|
pub use detector::{is_text_file, read_file_sample};
|
||||||
pub use mover::execute_move;
|
pub use file_ops::move_file_cross_platform;
|
||||||
pub use undo::undo_moves;
|
pub use mover::{MoveError, MoveSummary, execute_move, execute_move_auto};
|
||||||
|
pub use undo::{UndoError, UndoSummary, undo_moves, undo_moves_auto};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
use crate::models::OrganizationPlan;
|
|
||||||
use crate::storage::UndoLog;
|
|
||||||
use colored::*;
|
|
||||||
use std::io;
|
|
||||||
use std::path::MAIN_SEPARATOR;
|
|
||||||
use std::{ffi::OsStr, fs, path::Path};
|
|
||||||
|
|
||||||
pub fn execute_move(base_path: &Path, plan: OrganizationPlan, mut undo_log: Option<&mut UndoLog>) {
|
|
||||||
println!("\n{}", "--- EXECUTION PLAN ---".bold().underline());
|
|
||||||
|
|
||||||
if plan.files.is_empty() {
|
|
||||||
println!("{}", "No files to organize.".yellow());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for item in &plan.files {
|
|
||||||
let mut target_display = format!("{}", item.category.green());
|
|
||||||
if !item.sub_category.is_empty() {
|
|
||||||
target_display = format!(
|
|
||||||
"{}{}{}",
|
|
||||||
target_display,
|
|
||||||
MAIN_SEPARATOR,
|
|
||||||
item.sub_category.blue()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"Plan: {} -> {}{}",
|
|
||||||
item.filename, target_display, MAIN_SEPARATOR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
eprint!("\nDo you want to apply these changes? [y/N]: ");
|
|
||||||
|
|
||||||
let mut input = String::new();
|
|
||||||
if io::stdin().read_line(&mut input).is_err() {
|
|
||||||
eprintln!("\n{}", "Failed to read input. Operation cancelled.".red());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let input = input.trim().to_lowercase();
|
|
||||||
|
|
||||||
if input != "y" && input != "yes" {
|
|
||||||
println!("\n{}", "Operation cancelled.".red());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("\n{}", "--- MOVING FILES ---".bold().underline());
|
|
||||||
|
|
||||||
let mut moved_count = 0;
|
|
||||||
let mut error_count = 0;
|
|
||||||
|
|
||||||
for item in plan.files {
|
|
||||||
let source = base_path.join(&item.filename);
|
|
||||||
|
|
||||||
let mut final_path = base_path.join(&item.category);
|
|
||||||
|
|
||||||
if !item.sub_category.is_empty() {
|
|
||||||
final_path = final_path.join(&item.sub_category);
|
|
||||||
}
|
|
||||||
|
|
||||||
let file_name = Path::new(&item.filename)
|
|
||||||
.file_name()
|
|
||||||
.unwrap_or_else(|| OsStr::new(&item.filename))
|
|
||||||
.to_string_lossy()
|
|
||||||
.into_owned();
|
|
||||||
|
|
||||||
let target = final_path.join(&file_name);
|
|
||||||
|
|
||||||
if let Err(e) = fs::create_dir_all(&final_path) {
|
|
||||||
eprintln!(
|
|
||||||
"{} Failed to create dir {:?}: {}",
|
|
||||||
"ERROR:".red(),
|
|
||||||
final_path,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
error_count += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(metadata) = fs::metadata(&source) {
|
|
||||||
if metadata.is_file() {
|
|
||||||
match move_file_cross_platform(&source, &target) {
|
|
||||||
Ok(_) => {
|
|
||||||
if item.sub_category.is_empty() {
|
|
||||||
println!(
|
|
||||||
"Moved: {} -> {}{}",
|
|
||||||
item.filename,
|
|
||||||
item.category.green(),
|
|
||||||
MAIN_SEPARATOR
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
println!(
|
|
||||||
"Moved: {} -> {}{}{}",
|
|
||||||
item.filename,
|
|
||||||
item.category.green(),
|
|
||||||
MAIN_SEPARATOR,
|
|
||||||
item.sub_category.blue()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
eprintln!(
|
|
||||||
"{} Skipping {}: Not a file",
|
|
||||||
"WARN:".yellow(),
|
|
||||||
item.filename
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eprintln!(
|
|
||||||
"{} Skipping {}: File not found",
|
|
||||||
"WARN:".yellow(),
|
|
||||||
item.filename
|
|
||||||
);
|
|
||||||
error_count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("\n{}", "Organization Complete!".bold().green());
|
|
||||||
println!(
|
|
||||||
"Files moved: {}, Errors: {}",
|
|
||||||
moved_count.to_string().green(),
|
|
||||||
error_count.to_string().red()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn move_file_cross_platform(source: &Path, target: &Path) -> io::Result<()> {
|
|
||||||
match fs::rename(source, target) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(e) => {
|
|
||||||
if cfg!(windows) || e.kind() == io::ErrorKind::CrossesDevices {
|
|
||||||
fs::copy(source, target)?;
|
|
||||||
fs::remove_file(source)?;
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
src/files/mover/confirmation.rs
Normal file
36
src/files/mover/confirmation.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
use super::types::MoveError;
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
pub trait ConfirmationStrategy {
|
||||||
|
fn confirm(&self) -> Result<bool, MoveError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StdinConfirmation;
|
||||||
|
|
||||||
|
impl ConfirmationStrategy for StdinConfirmation {
|
||||||
|
fn confirm(&self) -> Result<bool, MoveError> {
|
||||||
|
eprint!("\nDo you want to apply these changes? [y/N]: ");
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
if io::stdin().read_line(&mut input).is_err() {
|
||||||
|
return Err(MoveError::InputReadFailed(
|
||||||
|
"Failed to read input. Operation cancelled.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let input = input.trim().to_lowercase();
|
||||||
|
if input != "y" && input != "yes" {
|
||||||
|
return Err(MoveError::UserCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AutoConfirm;
|
||||||
|
|
||||||
|
impl ConfirmationStrategy for AutoConfirm {
|
||||||
|
fn confirm(&self) -> Result<bool, MoveError> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/files/mover/display.rs
Normal file
43
src/files/mover/display.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use crate::models::FileCategory;
|
||||||
|
use colored::*;
|
||||||
|
use std::path::MAIN_SEPARATOR;
|
||||||
|
|
||||||
|
pub(super) fn display_plan(files: &[FileCategory]) {
|
||||||
|
println!("\n{}", "--- EXECUTION PLAN ---".bold().underline());
|
||||||
|
|
||||||
|
if files.is_empty() {
|
||||||
|
println!("{}", "No files to organize.".yellow());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in files {
|
||||||
|
let target_display = format_target_path(&item.category, &item.sub_category);
|
||||||
|
println!(
|
||||||
|
"Plan: {} -> {}{}",
|
||||||
|
item.filename, target_display, MAIN_SEPARATOR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn print_summary(summary: &super::types::MoveSummary) {
|
||||||
|
println!("\n{}", "Organization Complete!".bold().green());
|
||||||
|
println!(
|
||||||
|
"Files moved: {}, Errors: {}",
|
||||||
|
summary.moved_count().to_string().green(),
|
||||||
|
summary.error_count().to_string().red()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn format_target_path(category: &str, sub_category: &str) -> String {
|
||||||
|
let target_display = format!("{}", category.green());
|
||||||
|
if sub_category.is_empty() {
|
||||||
|
target_display
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}{}{}",
|
||||||
|
target_display,
|
||||||
|
MAIN_SEPARATOR,
|
||||||
|
sub_category.blue()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/files/mover/execution.rs
Normal file
91
src/files/mover/execution.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
use super::confirmation::ConfirmationStrategy;
|
||||||
|
use super::display::{display_plan, format_target_path};
|
||||||
|
use super::paths::{build_target_path, ensure_directory_exists};
|
||||||
|
use super::types::{MoveError, MoveSummary};
|
||||||
|
use crate::files::move_file_cross_platform;
|
||||||
|
use crate::models::OrganizationPlan;
|
||||||
|
use crate::storage::UndoLog;
|
||||||
|
use colored::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{MAIN_SEPARATOR, Path};
|
||||||
|
|
||||||
|
pub fn execute_move_with_strategy<C: ConfirmationStrategy>(
|
||||||
|
base_path: &Path,
|
||||||
|
plan: OrganizationPlan,
|
||||||
|
mut undo_log: Option<&mut UndoLog>,
|
||||||
|
confirmation: &C,
|
||||||
|
) -> Result<MoveSummary, MoveError> {
|
||||||
|
if plan.files.is_empty() {
|
||||||
|
println!("{}", "No files to organize.".yellow());
|
||||||
|
return Ok(MoveSummary::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
display_plan(&plan.files);
|
||||||
|
|
||||||
|
confirmation.confirm()?;
|
||||||
|
|
||||||
|
println!("\n{}", "--- MOVING FILES ---".bold().underline());
|
||||||
|
|
||||||
|
let mut summary = MoveSummary::new();
|
||||||
|
|
||||||
|
for item in plan.files {
|
||||||
|
let source = base_path.join(&item.filename);
|
||||||
|
let final_path = base_path.join(&item.category);
|
||||||
|
let target = build_target_path(
|
||||||
|
base_path,
|
||||||
|
&item.category,
|
||||||
|
&item.sub_category,
|
||||||
|
&item.filename,
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = ensure_directory_exists(&final_path) {
|
||||||
|
eprintln!("{} {}", "ERROR:".red(), e);
|
||||||
|
summary.errored();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match fs::metadata(&source) {
|
||||||
|
Ok(metadata) if metadata.is_file() => {
|
||||||
|
match move_file_cross_platform(&source, &target) {
|
||||||
|
Ok(_) => {
|
||||||
|
let target_display = format_target_path(&item.category, &item.sub_category);
|
||||||
|
println!(
|
||||||
|
"Moved: {} -> {}{}",
|
||||||
|
item.filename, target_display, MAIN_SEPARATOR
|
||||||
|
);
|
||||||
|
summary.moved();
|
||||||
|
|
||||||
|
if let Some(ref mut log) = undo_log {
|
||||||
|
log.record_move(source, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{} Failed to move {}: {}", "ERROR:".red(), item.filename, e);
|
||||||
|
summary.errored();
|
||||||
|
|
||||||
|
if let Some(ref mut log) = undo_log {
|
||||||
|
log.record_failed_move(source, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
eprintln!(
|
||||||
|
"{} Skipping {}: Not a file",
|
||||||
|
"WARN:".yellow(),
|
||||||
|
item.filename
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!(
|
||||||
|
"{} Skipping {}: File not found",
|
||||||
|
"WARN:".yellow(),
|
||||||
|
item.filename
|
||||||
|
);
|
||||||
|
summary.errored();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
38
src/files/mover/mod.rs
Normal file
38
src/files/mover/mod.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use crate::models::OrganizationPlan;
|
||||||
|
use crate::storage::UndoLog;
|
||||||
|
use colored::*;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
mod confirmation;
|
||||||
|
mod display;
|
||||||
|
mod execution;
|
||||||
|
mod paths;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
use confirmation::{AutoConfirm, StdinConfirmation};
|
||||||
|
use display::print_summary;
|
||||||
|
|
||||||
|
pub use types::{MoveError, MoveSummary};
|
||||||
|
|
||||||
|
pub fn execute_move(base_path: &Path, plan: OrganizationPlan, undo_log: Option<&mut UndoLog>) {
|
||||||
|
let confirmation = StdinConfirmation;
|
||||||
|
match execution::execute_move_with_strategy(base_path, plan, undo_log, &confirmation) {
|
||||||
|
Ok(summary) => print_summary(&summary),
|
||||||
|
Err(e) => {
|
||||||
|
if matches!(e, MoveError::UserCancelled) {
|
||||||
|
println!("\n{}", "Operation cancelled.".red());
|
||||||
|
} else {
|
||||||
|
eprintln!("\n{}", format!("{}", e).red());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_move_auto(
|
||||||
|
base_path: &Path,
|
||||||
|
plan: OrganizationPlan,
|
||||||
|
undo_log: Option<&mut UndoLog>,
|
||||||
|
) -> Result<MoveSummary, MoveError> {
|
||||||
|
let confirmation = AutoConfirm;
|
||||||
|
execution::execute_move_with_strategy(base_path, plan, undo_log, &confirmation)
|
||||||
|
}
|
||||||
30
src/files/mover/paths.rs
Normal file
30
src/files/mover/paths.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use super::types::MoveError;
|
||||||
|
use std::{
|
||||||
|
ffi::OsStr,
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn build_target_path(
|
||||||
|
base_path: &Path,
|
||||||
|
category: &str,
|
||||||
|
sub_category: &str,
|
||||||
|
filename: &str,
|
||||||
|
) -> PathBuf {
|
||||||
|
let mut final_path = base_path.join(category);
|
||||||
|
if !sub_category.is_empty() {
|
||||||
|
final_path = final_path.join(sub_category);
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_name = Path::new(filename)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_else(|| OsStr::new(filename))
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned();
|
||||||
|
|
||||||
|
final_path.join(&file_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_directory_exists(path: &Path) -> Result<(), MoveError> {
|
||||||
|
fs::create_dir_all(path).map_err(|e| MoveError::DirectoryCreationFailed(path.to_path_buf(), e))
|
||||||
|
}
|
||||||
62
src/files/mover/types.rs
Normal file
62
src/files/mover/types.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct MoveSummary {
|
||||||
|
moved_count: usize,
|
||||||
|
error_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MoveSummary {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn moved(&mut self) {
|
||||||
|
self.moved_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn errored(&mut self) {
|
||||||
|
self.error_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn moved_count(&self) -> usize {
|
||||||
|
self.moved_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error_count(&self) -> usize {
|
||||||
|
self.error_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_errors(&self) -> bool {
|
||||||
|
self.error_count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_processed(&self) -> usize {
|
||||||
|
self.moved_count + self.error_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum MoveError {
|
||||||
|
InputReadFailed(String),
|
||||||
|
UserCancelled,
|
||||||
|
DirectoryCreationFailed(PathBuf, std::io::Error),
|
||||||
|
FileMoveFailed(PathBuf, PathBuf, std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MoveError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
MoveError::InputReadFailed(msg) => write!(f, "Failed to read input: {}", msg),
|
||||||
|
MoveError::UserCancelled => write!(f, "Operation cancelled by user"),
|
||||||
|
MoveError::DirectoryCreationFailed(path, err) => {
|
||||||
|
write!(f, "Failed to create directory {:?}: {}", path, err)
|
||||||
|
}
|
||||||
|
MoveError::FileMoveFailed(source, target, err) => {
|
||||||
|
write!(f, "Failed to move {:?} to {:?}: {}", source, target, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for MoveError {}
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
use crate::storage::UndoLog;
|
|
||||||
use colored::*;
|
|
||||||
use std::fs;
|
|
||||||
use std::io;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
pub fn undo_moves(
|
|
||||||
base_path: &Path,
|
|
||||||
undo_log: &mut 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 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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn move_file_cross_platform(source: &Path, target: &Path) -> io::Result<()> {
|
|
||||||
match fs::rename(source, target) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(e) => {
|
|
||||||
if cfg!(windows) || e.kind() == io::ErrorKind::CrossesDevices {
|
|
||||||
fs::copy(source, target)?;
|
|
||||||
fs::remove_file(source)?;
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
24
src/files/undo/cleanup.rs
Normal file
24
src/files/undo/cleanup.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use crate::storage::UndoLog;
|
||||||
|
use colored::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub(super) fn cleanup_empty_directories(
|
||||||
|
base_path: &Path,
|
||||||
|
undo_log: &mut 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(())
|
||||||
|
}
|
||||||
36
src/files/undo/confirmation.rs
Normal file
36
src/files/undo/confirmation.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
use super::types::UndoError;
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
pub trait ConfirmationStrategy {
|
||||||
|
fn confirm(&self) -> Result<bool, UndoError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct StdinConfirmation;
|
||||||
|
|
||||||
|
impl ConfirmationStrategy for StdinConfirmation {
|
||||||
|
fn confirm(&self) -> Result<bool, UndoError> {
|
||||||
|
eprint!("\nDo you want to undo these changes? [y/N]: ");
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
if io::stdin().read_line(&mut input).is_err() {
|
||||||
|
return Err(UndoError::InputReadFailed(
|
||||||
|
"Failed to read input. Undo cancelled.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let input = input.trim().to_lowercase();
|
||||||
|
if input != "y" && input != "yes" {
|
||||||
|
return Err(UndoError::UserCancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AutoConfirm;
|
||||||
|
|
||||||
|
impl ConfirmationStrategy for AutoConfirm {
|
||||||
|
fn confirm(&self) -> Result<bool, UndoError> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/files/undo/display.rs
Normal file
36
src/files/undo/display.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
use crate::models::FileMoveRecord;
|
||||||
|
use colored::*;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub(super) fn display_undo_preview(records: &[FileMoveRecord], base_path: &Path) {
|
||||||
|
println!("\n{}", "--- UNDO PREVIEW ---".bold().underline());
|
||||||
|
println!("{} will restore {} files:", "INFO:".cyan(), records.len());
|
||||||
|
|
||||||
|
for record in records {
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn print_undo_summary(summary: &super::types::UndoSummary) {
|
||||||
|
println!("\n{}", "UNDO COMPLETE!".bold().green());
|
||||||
|
println!(
|
||||||
|
"Files restored: {}, Skipped: {}, Failed: {}",
|
||||||
|
summary.restored_count().to_string().green(),
|
||||||
|
summary.skipped_count().to_string().yellow(),
|
||||||
|
summary.failed_count().to_string().red()
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/files/undo/execution.rs
Normal file
91
src/files/undo/execution.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
use super::cleanup::cleanup_empty_directories;
|
||||||
|
use super::confirmation::ConfirmationStrategy;
|
||||||
|
use super::display::display_undo_preview;
|
||||||
|
use super::types::{UndoError, UndoSummary};
|
||||||
|
use crate::files::move_file_cross_platform;
|
||||||
|
use crate::storage::UndoLog;
|
||||||
|
use colored::*;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn undo_with_strategy<C: ConfirmationStrategy>(
|
||||||
|
base_path: &Path,
|
||||||
|
undo_log: &mut UndoLog,
|
||||||
|
confirmation: &C,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Result<UndoSummary, UndoError> {
|
||||||
|
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(UndoSummary::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
display_undo_preview(&completed_moves, base_path);
|
||||||
|
|
||||||
|
if dry_run {
|
||||||
|
println!("\n{}", "Dry run mode - skipping undo operation.".cyan());
|
||||||
|
return Ok(UndoSummary::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmation.confirm()?;
|
||||||
|
|
||||||
|
println!("\n{}", "--- UNDOING MOVES ---".bold().underline());
|
||||||
|
|
||||||
|
let mut summary = UndoSummary::new();
|
||||||
|
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
summary.failed();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if source.exists() {
|
||||||
|
eprintln!(
|
||||||
|
"{} Skipping {} - source already exists",
|
||||||
|
"WARN:".yellow(),
|
||||||
|
source.display()
|
||||||
|
);
|
||||||
|
summary.skipped();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match move_file_cross_platform(destination, source) {
|
||||||
|
Ok(_) => {
|
||||||
|
println!(
|
||||||
|
"Restored: {} -> {}",
|
||||||
|
destination.display().to_string().red(),
|
||||||
|
source.display().to_string().green()
|
||||||
|
);
|
||||||
|
summary.restored();
|
||||||
|
undo_log.mark_as_undone(destination);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"{} Failed to restore {}: {}",
|
||||||
|
"ERROR:".red(),
|
||||||
|
source.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
summary.failed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = cleanup_empty_directories(base_path, undo_log) {
|
||||||
|
eprintln!("{} Failed to cleanup directories: {}", "WARN:".yellow(), e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
51
src/files/undo/mod.rs
Normal file
51
src/files/undo/mod.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use crate::storage::UndoLog;
|
||||||
|
use colored::*;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
mod cleanup;
|
||||||
|
mod confirmation;
|
||||||
|
mod display;
|
||||||
|
mod execution;
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
use confirmation::{AutoConfirm, StdinConfirmation};
|
||||||
|
use display::print_undo_summary;
|
||||||
|
|
||||||
|
pub use types::{UndoError, UndoSummary};
|
||||||
|
|
||||||
|
pub fn undo_moves(
|
||||||
|
base_path: &Path,
|
||||||
|
undo_log: &mut UndoLog,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Result<(usize, usize, usize), Box<dyn std::error::Error>> {
|
||||||
|
let confirmation = StdinConfirmation;
|
||||||
|
match execution::undo_with_strategy(base_path, undo_log, &confirmation, dry_run) {
|
||||||
|
Ok(summary) => {
|
||||||
|
if !dry_run {
|
||||||
|
print_undo_summary(&summary);
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
summary.restored_count(),
|
||||||
|
summary.skipped_count(),
|
||||||
|
summary.failed_count(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if matches!(e, UndoError::UserCancelled) {
|
||||||
|
println!("\n{}", "Undo cancelled.".red());
|
||||||
|
} else {
|
||||||
|
eprintln!("\n{}", format!("{}", e).red());
|
||||||
|
}
|
||||||
|
Ok((0, 0, 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn undo_moves_auto(
|
||||||
|
base_path: &Path,
|
||||||
|
undo_log: &mut UndoLog,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> Result<UndoSummary, UndoError> {
|
||||||
|
let confirmation = AutoConfirm;
|
||||||
|
execution::undo_with_strategy(base_path, undo_log, &confirmation, dry_run)
|
||||||
|
}
|
||||||
67
src/files/undo/types.rs
Normal file
67
src/files/undo/types.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct UndoSummary {
|
||||||
|
restored_count: usize,
|
||||||
|
skipped_count: usize,
|
||||||
|
failed_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UndoSummary {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restored(&mut self) {
|
||||||
|
self.restored_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn skipped(&mut self) {
|
||||||
|
self.skipped_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn failed(&mut self) {
|
||||||
|
self.failed_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restored_count(&self) -> usize {
|
||||||
|
self.restored_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn skipped_count(&self) -> usize {
|
||||||
|
self.skipped_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn failed_count(&self) -> usize {
|
||||||
|
self.failed_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_processed(&self) -> usize {
|
||||||
|
self.restored_count + self.skipped_count + self.failed_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_failures(&self) -> bool {
|
||||||
|
self.failed_count > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum UndoError {
|
||||||
|
InputReadFailed(String),
|
||||||
|
UserCancelled,
|
||||||
|
FileRestoreFailed(String, String, std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for UndoError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
UndoError::InputReadFailed(msg) => write!(f, "Failed to read input: {}", msg),
|
||||||
|
UndoError::UserCancelled => write!(f, "Undo cancelled by user"),
|
||||||
|
UndoError::FileRestoreFailed(dest, src, err) => {
|
||||||
|
write!(f, "Failed to restore from {} to {}: {}", dest, src, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for UndoError {}
|
||||||
@@ -6,7 +6,10 @@ pub mod settings;
|
|||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
|
||||||
pub use cli::Args;
|
pub use cli::Args;
|
||||||
pub use files::{FileBatch, execute_move, is_text_file, read_file_sample, undo_moves};
|
pub use files::{
|
||||||
|
FileBatch, MoveError, MoveSummary, execute_move, execute_move_auto, is_text_file,
|
||||||
|
read_file_sample, undo_moves,
|
||||||
|
};
|
||||||
pub use gemini::GeminiClient;
|
pub use gemini::GeminiClient;
|
||||||
pub use gemini::GeminiError;
|
pub use gemini::GeminiError;
|
||||||
pub use models::{FileCategory, FileMoveRecord, MoveStatus, OrganizationPlan};
|
pub use models::{FileCategory, FileMoveRecord, MoveStatus, OrganizationPlan};
|
||||||
|
|||||||
Reference in New Issue
Block a user