diff --git a/src/cli/orchestrator.rs b/src/cli/orchestrator.rs index b3f80c2..b6e8dae 100644 --- a/src/cli/orchestrator.rs +++ b/src/cli/orchestrator.rs @@ -7,39 +7,68 @@ use crate::settings::{Config, Prompter}; use crate::storage::{Cache, UndoLog}; use colored::*; -/// Main entry point for file organization. -/// Coordinates cache, undo log, and delegates to online/offline handlers. -pub async fn handle_organization( - args: Args, - config: Config, -) -> Result<(), Box> { +fn initialize_cache() -> Result<(Cache, std::path::PathBuf), Box> { + const CACHE_RETENTION_SECONDS: u64 = 7 * 24 * 60 * 60; let data_dir = Config::get_data_dir()?; let cache_path = data_dir.join(".noentropy_cache.json"); 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); + Ok((cache, cache_path)) +} +fn initialize_undo_log() -> Result<(UndoLog, std::path::PathBuf), Box> { + const UNDO_LOG_RETENTION_SECONDS: u64 = 30 * 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(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 { let target_path = args .path .as_ref() .cloned() .unwrap_or_else(|| config.download_folder.clone()); - // Validate and normalize the target path early - let target_path = match validate_and_normalize_path(&target_path).await { - Ok(normalized) => normalized, + match validate_and_normalize_path(&target_path).await { + Ok(normalized) => Some(normalized), Err(e) => { println!("{}", format!("ERROR: {}", e).red()); - return Ok(()); + None } + } +} + +async fn determine_offline_mode(args: &Args, config: &Config) -> Option { + 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> { + 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); @@ -51,23 +80,8 @@ pub async fn handle_organization( println!("Found {} files to organize.", batch.count()); - // Determine if we should use offline mode - let use_offline = if args.offline { - 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 Some(use_offline) = determine_offline_mode(&args, &config).await else { + return Ok(()); }; let plan = if use_offline { @@ -84,9 +98,8 @@ pub async fn handle_organization( .await? }; - // Only save if we have a plan (online mode returns None after moving) 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); } diff --git a/src/files/batch.rs b/src/files/batch.rs index 8534272..a029199 100644 --- a/src/files/batch.rs +++ b/src/files/batch.rs @@ -42,67 +42,5 @@ impl FileBatch { } #[cfg(test)] -mod tests { - 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())); - } -} +#[path = "batch_test.rs"] +mod tests; diff --git a/src/files/batch_test.rs b/src/files/batch_test.rs new file mode 100644 index 0000000..08955f8 --- /dev/null +++ b/src/files/batch_test.rs @@ -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())); +} diff --git a/src/files/categorizer.rs b/src/files/categorizer.rs index c04541b..9ec6506 100644 --- a/src/files/categorizer.rs +++ b/src/files/categorizer.rs @@ -146,52 +146,5 @@ pub fn categorize_files_offline(filenames: Vec) -> OfflineCategorization } #[cfg(test)] -mod tests { - 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())); - } -} +#[path = "categorizer_test.rs"] +mod tests; diff --git a/src/files/categorizer_test.rs b/src/files/categorizer_test.rs new file mode 100644 index 0000000..6cda142 --- /dev/null +++ b/src/files/categorizer_test.rs @@ -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())); +} diff --git a/src/files/file_ops.rs b/src/files/file_ops.rs new file mode 100644 index 0000000..9e47bc7 --- /dev/null +++ b/src/files/file_ops.rs @@ -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) + } + } + } +} diff --git a/src/files/mod.rs b/src/files/mod.rs index e95afba..b303759 100644 --- a/src/files/mod.rs +++ b/src/files/mod.rs @@ -1,14 +1,16 @@ pub mod batch; pub mod categorizer; pub mod detector; +mod file_ops; pub mod mover; pub mod undo; pub use batch::FileBatch; pub use categorizer::{OfflineCategorizationResult, categorize_files_offline}; pub use detector::{is_text_file, read_file_sample}; -pub use mover::execute_move; -pub use undo::undo_moves; +pub use file_ops::move_file_cross_platform; +pub use mover::{MoveError, MoveSummary, execute_move, execute_move_auto}; +pub use undo::{UndoError, UndoSummary, undo_moves, undo_moves_auto}; #[cfg(test)] mod tests { diff --git a/src/files/mover.rs b/src/files/mover.rs deleted file mode 100644 index 9a7f5bf..0000000 --- a/src/files/mover.rs +++ /dev/null @@ -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) - } - } - } -} diff --git a/src/files/mover/confirmation.rs b/src/files/mover/confirmation.rs new file mode 100644 index 0000000..686ac28 --- /dev/null +++ b/src/files/mover/confirmation.rs @@ -0,0 +1,36 @@ +use super::types::MoveError; +use std::io; + +pub trait ConfirmationStrategy { + fn confirm(&self) -> Result; +} + +pub struct StdinConfirmation; + +impl ConfirmationStrategy for StdinConfirmation { + fn confirm(&self) -> Result { + 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 { + Ok(true) + } +} diff --git a/src/files/mover/display.rs b/src/files/mover/display.rs new file mode 100644 index 0000000..c6cbb5f --- /dev/null +++ b/src/files/mover/display.rs @@ -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() + ) + } +} diff --git a/src/files/mover/execution.rs b/src/files/mover/execution.rs new file mode 100644 index 0000000..932063a --- /dev/null +++ b/src/files/mover/execution.rs @@ -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( + base_path: &Path, + plan: OrganizationPlan, + mut undo_log: Option<&mut UndoLog>, + confirmation: &C, +) -> Result { + 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) +} diff --git a/src/files/mover/mod.rs b/src/files/mover/mod.rs new file mode 100644 index 0000000..4172674 --- /dev/null +++ b/src/files/mover/mod.rs @@ -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 { + let confirmation = AutoConfirm; + execution::execute_move_with_strategy(base_path, plan, undo_log, &confirmation) +} diff --git a/src/files/mover/paths.rs b/src/files/mover/paths.rs new file mode 100644 index 0000000..f59bc0a --- /dev/null +++ b/src/files/mover/paths.rs @@ -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)) +} diff --git a/src/files/mover/types.rs b/src/files/mover/types.rs new file mode 100644 index 0000000..19935c4 --- /dev/null +++ b/src/files/mover/types.rs @@ -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 {} diff --git a/src/files/undo.rs b/src/files/undo.rs deleted file mode 100644 index 4373556..0000000 --- a/src/files/undo.rs +++ /dev/null @@ -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> { - 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> { - 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) - } - } - } -} diff --git a/src/files/undo/cleanup.rs b/src/files/undo/cleanup.rs new file mode 100644 index 0000000..817f733 --- /dev/null +++ b/src/files/undo/cleanup.rs @@ -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> { + 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(()) +} diff --git a/src/files/undo/confirmation.rs b/src/files/undo/confirmation.rs new file mode 100644 index 0000000..256f559 --- /dev/null +++ b/src/files/undo/confirmation.rs @@ -0,0 +1,36 @@ +use super::types::UndoError; +use std::io; + +pub trait ConfirmationStrategy { + fn confirm(&self) -> Result; +} + +pub struct StdinConfirmation; + +impl ConfirmationStrategy for StdinConfirmation { + fn confirm(&self) -> Result { + 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 { + Ok(true) + } +} diff --git a/src/files/undo/display.rs b/src/files/undo/display.rs new file mode 100644 index 0000000..b41643c --- /dev/null +++ b/src/files/undo/display.rs @@ -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() + ); +} diff --git a/src/files/undo/execution.rs b/src/files/undo/execution.rs new file mode 100644 index 0000000..a5457e1 --- /dev/null +++ b/src/files/undo/execution.rs @@ -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( + base_path: &Path, + undo_log: &mut UndoLog, + confirmation: &C, + dry_run: bool, +) -> Result { + 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) +} diff --git a/src/files/undo/mod.rs b/src/files/undo/mod.rs new file mode 100644 index 0000000..d3d692d --- /dev/null +++ b/src/files/undo/mod.rs @@ -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> { + 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 { + let confirmation = AutoConfirm; + execution::undo_with_strategy(base_path, undo_log, &confirmation, dry_run) +} diff --git a/src/files/undo/types.rs b/src/files/undo/types.rs new file mode 100644 index 0000000..1bfa15f --- /dev/null +++ b/src/files/undo/types.rs @@ -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 {} diff --git a/src/lib.rs b/src/lib.rs index e3ca460..20e4a45 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,10 @@ pub mod settings; pub mod storage; 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::GeminiError; pub use models::{FileCategory, FileMoveRecord, MoveStatus, OrganizationPlan};