refactor: reorganize file operations and extract test modules

- Extract mover and undo functionality into dedicated modules (mover/ and undo/ subdirectories)
- Move cross-platform file operations to separate file_ops.rs module for reusability
- Extract batch and categorizer tests into separate test files (batch_test.rs, categorizer_test.rs)
- Refactor orchestrator.rs with extracted helper functions for improved readability
  - Separate cache and undo log initialization
  - Extract path resolution and offline mode determination logic
  - Simplify main organization flow by delegating to helper functions
- Update module exports to expose new types and functions (MoveError, MoveSummary, UndoError, UndoSummary)
- Reduce code duplication of move_file_cross_platform implementation
This commit is contained in:
2026-01-10 21:08:00 +05:30
parent 263f938734
commit 10e508fa0e
22 changed files with 789 additions and 470 deletions

View File

@@ -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 {
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(()); 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);
} }

View File

@@ -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
View 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()));
}

View File

@@ -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()));
}
}

View 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
View 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)
}
}
}
}

View File

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

View File

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

View 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)
}
}

View 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()
)
}
}

View 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
View 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
View 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
View 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 {}

View File

@@ -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
View 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(())
}

View 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
View 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()
);
}

View 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
View 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
View 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 {}

View File

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