refactored every component thoroughly.
This commit is contained in:
108
src/files/batch.rs
Normal file
108
src/files/batch.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use std::path::PathBuf;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FileBatch {
|
||||
pub filenames: Vec<String>,
|
||||
pub paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl FileBatch {
|
||||
pub fn from_path(root_path: PathBuf, recursive: bool) -> Self {
|
||||
let mut filenames = Vec::new();
|
||||
let mut paths = Vec::new();
|
||||
let walker = if recursive {
|
||||
WalkDir::new(&root_path).min_depth(1).follow_links(false)
|
||||
} else {
|
||||
WalkDir::new(&root_path)
|
||||
.min_depth(1)
|
||||
.max_depth(1)
|
||||
.follow_links(false)
|
||||
};
|
||||
for entry in walker.into_iter().filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
match path.strip_prefix(&root_path) {
|
||||
Ok(relative_path) => {
|
||||
filenames.push(relative_path.to_string_lossy().into_owned());
|
||||
paths.push(path.to_path_buf());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error getting relative path for {:?}: {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FileBatch { filenames, paths }
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.filenames.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[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.to_path_buf(), 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(PathBuf::from("/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.to_path_buf(), 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.to_path_buf(), 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()));
|
||||
}
|
||||
}
|
||||
107
src/files/detector.rs
Normal file
107
src/files/detector.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use std::{fs, path::Path};
|
||||
|
||||
pub fn is_text_file(path: &Path) -> bool {
|
||||
let text_extensions = [
|
||||
"txt", "md", "rs", "py", "js", "ts", "jsx", "tsx", "html", "css", "json", "xml", "csv",
|
||||
"yaml", "yml", "toml", "ini", "cfg", "conf", "log", "sh", "bat", "ps1", "sql", "c", "cpp",
|
||||
"h", "hpp", "java", "go", "rb", "php", "swift", "kt", "scala", "lua", "r", "m",
|
||||
];
|
||||
|
||||
if let Some(ext) = path.extension()
|
||||
&& let Some(ext_str) = ext.to_str()
|
||||
{
|
||||
return text_extensions.contains(&ext_str.to_lowercase().as_str());
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn read_file_sample(path: &Path, max_chars: usize) -> Option<String> {
|
||||
use std::io::Read;
|
||||
let file = match fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
let mut handle = file.take(max_chars as u64);
|
||||
if handle.read_to_end(&mut buffer).is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
String::from_utf8(buffer).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_is_text_file_with_text_extensions() {
|
||||
assert!(is_text_file(Path::new("test.txt")));
|
||||
assert!(is_text_file(Path::new("test.rs")));
|
||||
assert!(is_text_file(Path::new("test.py")));
|
||||
assert!(is_text_file(Path::new("test.md")));
|
||||
assert!(is_text_file(Path::new("test.json")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_text_file_with_binary_extensions() {
|
||||
assert!(!is_text_file(Path::new("test.exe")));
|
||||
assert!(!is_text_file(Path::new("test.bin")));
|
||||
assert!(!is_text_file(Path::new("test.jpg")));
|
||||
assert!(!is_text_file(Path::new("test.pdf")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_text_file_case_insensitive() {
|
||||
assert!(is_text_file(Path::new("test.TXT")));
|
||||
assert!(is_text_file(Path::new("test.RS")));
|
||||
assert!(is_text_file(Path::new("test.Py")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_file_sample() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = temp_dir.path().join("test.txt");
|
||||
|
||||
let mut file = File::create(&file_path).unwrap();
|
||||
file.write_all(b"Hello, World!").unwrap();
|
||||
|
||||
let content = read_file_sample(&file_path, 1000);
|
||||
assert_eq!(content, Some("Hello, World!".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_file_sample_with_limit() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = temp_dir.path().join("test.txt");
|
||||
|
||||
let mut file = File::create(&file_path).unwrap();
|
||||
file.write_all(b"Hello, World! This is a long text.")
|
||||
.unwrap();
|
||||
|
||||
let content = read_file_sample(&file_path, 5);
|
||||
assert_eq!(content, Some("Hello".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_file_sample_binary_file() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = temp_dir.path().join("test.bin");
|
||||
|
||||
let mut file = File::create(&file_path).unwrap();
|
||||
file.write_all(&[0x00, 0xFF, 0x80, 0x90]).unwrap();
|
||||
|
||||
let content = read_file_sample(&file_path, 1000);
|
||||
assert_eq!(content, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_file_sample_nonexistent() {
|
||||
let content = read_file_sample(Path::new("/nonexistent/file.txt"), 1000);
|
||||
assert_eq!(content, None);
|
||||
}
|
||||
}
|
||||
49
src/files/mod.rs
Normal file
49
src/files/mod.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
pub mod batch;
|
||||
pub mod detector;
|
||||
pub mod mover;
|
||||
pub mod undo;
|
||||
|
||||
pub use batch::FileBatch;
|
||||
pub use detector::{is_text_file, read_file_sample};
|
||||
pub use mover::execute_move;
|
||||
pub use undo::undo_moves;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::models::{FileCategory, OrganizationPlan};
|
||||
use serde_json;
|
||||
|
||||
#[test]
|
||||
fn test_organization_plan_serialization() {
|
||||
let plan = OrganizationPlan {
|
||||
files: vec![FileCategory {
|
||||
filename: "test.txt".to_string(),
|
||||
category: "Documents".to_string(),
|
||||
sub_category: "Text".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&plan).unwrap();
|
||||
assert!(json.contains("test.txt"));
|
||||
assert!(json.contains("Documents"));
|
||||
|
||||
let deserialized: OrganizationPlan = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.files[0].filename, "test.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_category_serialization() {
|
||||
let fc = FileCategory {
|
||||
filename: "file.rs".to_string(),
|
||||
category: "Code".to_string(),
|
||||
sub_category: "Rust".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&fc).unwrap();
|
||||
let deserialized: FileCategory = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(fc.filename, deserialized.filename);
|
||||
assert_eq!(fc.category, deserialized.category);
|
||||
assert_eq!(fc.sub_category, deserialized.sub_category);
|
||||
}
|
||||
}
|
||||
143
src/files/mover.rs
Normal file
143
src/files/mover.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
use colored::*;
|
||||
use crate::models::OrganizationPlan;
|
||||
use crate::storage::UndoLog;
|
||||
use std::io;
|
||||
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, item.sub_category.blue());
|
||||
}
|
||||
|
||||
println!("Plan: {} -> {}/", item.filename, target_display);
|
||||
}
|
||||
|
||||
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());
|
||||
} else {
|
||||
println!(
|
||||
"Moved: {} -> {}/{}",
|
||||
item.filename,
|
||||
item.category.green(),
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
166
src/files/undo.rs
Normal file
166
src/files/undo.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use colored::*;
|
||||
use crate::storage::UndoLog;
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user