mod history;
mod repl;
mod sort;
mod transformers;
mod ui;
mod unsort;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use clap::{ArgAction, Parser};
use colored::*;
use dirs::home_dir;
use regex::Regex;
use walkdir::WalkDir;
use history::HistoryManager;
use repl::InteractiveSession;
use transformers::{transform, TransformType};
use ui::UserInterface;
#[derive(Parser, Debug)]
#[command(
author,
version,
about = "Smart Move - An enhanced mv command with transformation capabilities",
long_about = None
)]
struct Args {
#[arg(value_name = "SOURCE")]
source: Vec<String>,
#[arg(value_name = "DESTINATION")]
destination: Option<String>,
#[arg(short, long, action = ArgAction::SetTrue)]
interactive: bool,
#[arg(short = 'T', long = "tui", action = ArgAction::SetTrue)]
tui: bool,
#[arg(short, long, action = ArgAction::SetTrue)]
preview: bool,
#[arg(short, long, action = ArgAction::SetTrue)]
recursive: bool,
#[arg(short, long, value_name = "EXTENSIONS")]
extensions: Option<String>,
#[arg(short = 'a', long, action = ArgAction::SetTrue)]
remove_accents: bool,
#[arg(long, action = ArgAction::SetTrue)]
clean: bool,
#[arg(long, action = ArgAction::SetTrue)]
snake: bool,
#[arg(long, action = ArgAction::SetTrue)]
kebab: bool,
#[arg(long, action = ArgAction::SetTrue)]
title: bool,
#[arg(long, action = ArgAction::SetTrue)]
camel: bool,
#[arg(long, action = ArgAction::SetTrue)]
pascal: bool,
#[arg(long, action = ArgAction::SetTrue)]
lower: bool,
#[arg(long, action = ArgAction::SetTrue)]
upper: bool,
#[arg(long, action = ArgAction::SetTrue)]
dry_run: bool,
#[arg(long, action = ArgAction::SetTrue)]
undo: bool,
#[arg(long, value_name = "PATTERNS")]
exclude: Option<String>,
#[arg(long, action = ArgAction::SetTrue)]
group: bool,
#[arg(long, action = ArgAction::SetTrue)]
flatten: bool,
#[arg(long, value_name = "SIZE", default_value = "50")]
max_history_size: usize,
}
#[derive(Debug, Default)]
struct Stats {
processed: u32,
renamed: u32,
errors: u32,
skipped: u32,
}
fn main() -> Result<(), Box<dyn Error>> {
let mut args = Args::parse();
if args.dry_run {
args.preview = true;
}
if args.tui {
run_tui_mode()?;
return Ok(());
}
if args.interactive {
run_interactive_mode(args.max_history_size)?;
return Ok(());
}
if args.undo {
run_undo_mode(args.max_history_size)?;
return Ok(());
}
if args.group {
run_group_mode(&args)?;
} else if args.flatten {
run_flatten_mode(&args)?;
} else if is_transformation_requested(&args) {
run_transformation_mode(&args)?;
} else if !args.source.is_empty() {
run_standard_mv_mode(&args)?;
} else {
eprintln!("Error: No files specified and no mode selected.");
eprintln!("Use --help for usage information, -i for interactive mode, or -T for TUI mode.");
process::exit(1);
}
Ok(())
}
fn run_tui_mode() -> Result<(), Box<dyn Error>> {
let backup_dir = home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".config")
.join("smv")
.join("backups");
fs::create_dir_all(&backup_dir)?;
let mut app = ui::terminal::App::new()?;
app.run()?;
Ok(())
}
fn run_interactive_mode(max_history_size: usize) -> Result<(), Box<dyn Error>> {
let backup_dir = home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".config")
.join("smv")
.join("backups");
fs::create_dir_all(&backup_dir)?;
let mut session = InteractiveSession::new(max_history_size, &backup_dir)?;
session.run()?;
Ok(())
}
fn run_undo_mode(max_history_size: usize) -> Result<(), Box<dyn Error>> {
let backup_dir = home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".config")
.join("smv")
.join("backups");
fs::create_dir_all(&backup_dir)?;
let mut history_manager = HistoryManager::new(max_history_size, &backup_dir);
match history_manager.undo() {
Ok(_) => {
println!("Operation undone successfully.");
Ok(())
}
Err(e) => {
eprintln!("{}: {}", "Error".red(), e);
Err(e)
}
}
}
fn is_transformation_requested(args: &Args) -> bool {
args.clean
|| args.snake
|| args.kebab
|| args.title
|| args.camel
|| args.pascal
|| args.lower
|| args.upper
}
fn run_transformation_mode(args: &Args) -> Result<(), Box<dyn Error>> {
if args.source.is_empty() {
return Err("No source files specified for transformation".into());
}
let transform_type = if args.clean {
TransformType::Clean
} else if args.snake {
TransformType::Snake
} else if args.kebab {
TransformType::Kebab
} else if args.title {
TransformType::Title
} else if args.camel {
TransformType::Camel
} else if args.pascal {
TransformType::Pascal
} else if args.lower {
TransformType::Lower
} else if args.upper {
TransformType::Upper
} else {
TransformType::Clean
};
let exclude_patterns: Vec<Regex> = process_exclude_patterns(args.exclude.as_deref())?;
let extensions: Option<Vec<String>> = args.extensions.as_ref().map(|exts| {
exts.split(',')
.map(|ext| ext.trim().to_lowercase())
.filter(|ext| !ext.is_empty())
.collect()
});
println!(
"\n{}\n",
format!(
"Smart Move - {} Mode",
if args.preview { "Preview" } else { "Rename" }
)
.bold()
);
println!("Transformation: {}", transform_type.as_str().green());
println!(
"Recursive: {}",
if args.recursive {
"Yes".green()
} else {
"No".yellow()
}
);
println!(
"Extensions filter: {}",
match &extensions {
Some(exts) if !exts.is_empty() => exts.join(", ").cyan(),
_ => "None (all files)".yellow(),
}
);
println!(
"Exclude patterns: {}\n",
if !exclude_patterns.is_empty() {
args.exclude.as_deref().unwrap_or_default().cyan()
} else {
"None".yellow()
}
);
let mut stats = Stats::default();
for source_pattern in &args.source {
process_pattern(
source_pattern,
transform_type,
args.preview,
args.recursive,
&exclude_patterns,
&extensions,
&mut stats,
)?;
}
println!("\n{}:", "Results".bold());
println!("Files processed: {}", stats.processed.to_string().cyan());
println!("Files to be renamed: {}", stats.renamed.to_string().green());
println!("Files skipped: {}", stats.skipped.to_string().yellow());
println!("Errors encountered: {}", stats.errors.to_string().red());
if args.preview && stats.renamed > 0 {
println!(
"\n{}",
"This was a preview only. No files were actually renamed."
.bold()
.blue()
);
println!(
"{}",
"To apply these changes, run the command without --preview or --dry-run option.".blue()
);
}
Ok(())
}
fn run_standard_mv_mode(args: &Args) -> Result<(), Box<dyn Error>> {
if args.source.is_empty() {
return Err("No source files specified".into());
}
if args.destination.is_none() {
return Err("No destination specified".into());
}
let destination = args.destination.as_ref().unwrap();
let dest_path = PathBuf::from(destination);
let dest_is_dir = dest_path.is_dir();
if args.source.len() > 1 && !dest_is_dir {
return Err("When specifying multiple sources, destination must be a directory".into());
}
for source in &args.source {
let source_path = PathBuf::from(source);
if !source_path.exists() {
eprintln!("{}: Source file not found: {}", "Error".red(), source);
continue;
}
let target_path = if dest_is_dir {
let source_filename = source_path.file_name().ok_or("Invalid source filename")?;
dest_path.join(source_filename)
} else {
dest_path.clone()
};
if target_path.exists() && source_path != target_path {
eprintln!(
"{}: Cannot move '{}' to '{}' - destination exists",
"Error".red(),
source,
target_path.display()
);
continue;
}
if args.preview {
println!(
"{} '{}' → '{}'",
"Preview:".blue(),
source,
target_path.display()
);
} else {
match fs::rename(&source_path, &target_path) {
Ok(_) => println!("Moved: '{}' → '{}'", source, target_path.display()),
Err(e) => eprintln!(
"{}: Failed to move '{}' to '{}' - {}",
"Error".red(),
source,
target_path.display(),
e
),
}
}
}
Ok(())
}
fn run_group_mode(args: &Args) -> Result<(), Box<dyn Error>> {
if args.source.is_empty() {
return Err("No directory specified for grouping".into());
}
println!("\n{}\n", "Smart Move - Group Files by Basename".bold());
for source_dir in &args.source {
println!("Processing directory: {}", source_dir.cyan());
sort::group_by_basename(source_dir, args.preview)?;
}
if args.preview {
println!(
"\n{}",
"This was a preview only. No files were actually moved."
.bold()
.blue()
);
println!(
"{}",
"To apply these changes, run the command without --preview or --dry-run option.".blue()
);
}
Ok(())
}
fn run_flatten_mode(args: &Args) -> Result<(), Box<dyn Error>> {
if args.source.is_empty() {
return Err("No directory specified for flattening".into());
}
println!("\n{}\n", "Smart Move - Flatten Directory Structure".bold());
for source_dir in &args.source {
println!("Processing directory: {}", source_dir.cyan());
unsort::flatten_directory(source_dir, args.preview)?;
println!("\nRemoving empty directories:");
unsort::remove_empty_dirs(source_dir, args.preview)?;
}
if args.preview {
println!(
"\n{}",
"This was a preview only. No files were actually moved."
.bold()
.blue()
);
println!(
"{}",
"To apply these changes, run the command without --preview or --dry-run option.".blue()
);
}
Ok(())
}
fn process_exclude_patterns(patterns: Option<&str>) -> Result<Vec<Regex>, Box<dyn Error>> {
match patterns {
Some(patterns) => {
let result: Vec<Regex> = patterns
.split(',')
.filter_map(|p| {
let p = p.trim();
if p.is_empty() {
None
} else {
match Regex::new(p) {
Ok(re) => Some(re),
Err(e) => {
eprintln!("{}: {}", "Invalid regex pattern".red(), e);
None
}
}
}
})
.collect();
Ok(result)
}
None => Ok(Vec::new()),
}
}
fn process_pattern(
pattern: &str,
transform_type: TransformType,
preview_only: bool,
recursive: bool,
exclude_patterns: &[Regex],
extensions: &Option<Vec<String>>,
stats: &mut Stats,
) -> Result<(), Box<dyn Error>> {
let path = Path::new(pattern);
let base_dir = if path.is_dir() {
path.to_path_buf()
} else {
path.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
};
let entries = if recursive {
WalkDir::new(&base_dir)
.into_iter()
.filter_map(Result::ok)
.collect::<Vec<_>>()
} else {
let paths = fs::read_dir(&base_dir)
.map_err(|e| format!("Failed to read directory {}: {}", base_dir.display(), e))?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.is_file())
.collect::<Vec<_>>();
paths
.into_iter()
.filter_map(|path| {
WalkDir::new(&path)
.max_depth(0)
.into_iter()
.next()
.and_then(|e| e.ok())
})
.collect::<Vec<_>>()
};
for entry in entries {
let path = entry.path();
if path.is_dir() {
continue;
}
if !path_matches_pattern(path, pattern) {
continue;
}
process_file(
path,
transform_type,
preview_only,
exclude_patterns,
extensions,
stats,
)?;
}
Ok(())
}
fn path_matches_pattern(path: &Path, pattern: &str) -> bool {
if Path::new(pattern).is_dir() {
return true;
}
let path_str = path.to_string_lossy();
if pattern.contains('*') || pattern.contains('?') {
let pattern_regex = pattern
.replace(".", "\\.")
.replace("*", ".*")
.replace("?", ".");
Regex::new(&format!("^{pattern_regex}$"))
.map(|re| re.is_match(&path_str))
.unwrap_or(false)
} else {
path_str == pattern
}
}
fn process_file(
file_path: &Path,
transform_type: TransformType,
preview_only: bool,
exclude_patterns: &[Regex],
extensions: &Option<Vec<String>>,
stats: &mut Stats,
) -> Result<(), Box<dyn Error>> {
let file_path_str = file_path.to_string_lossy();
if exclude_patterns
.iter()
.any(|pattern| pattern.is_match(&file_path_str))
{
stats.skipped += 1;
return Ok(());
}
if let Some(exts) = extensions {
if let Some(ext) = file_path.extension() {
let file_ext = ext.to_string_lossy().to_lowercase();
if !exts.contains(&file_ext) {
stats.skipped += 1;
return Ok(());
}
} else {
stats.skipped += 1;
return Ok(());
}
}
let Some(filename) = file_path
.file_name()
.map(|f| f.to_string_lossy().to_string())
else {
stats.errors += 1;
return Ok(());
};
let Some(directory) = file_path.parent() else {
stats.errors += 1;
return Ok(());
};
let new_name = transform(&filename, transform_type);
if new_name == filename {
stats.processed += 1;
return Ok(());
}
let new_path = directory.join(&new_name);
if new_path.exists() && file_path != new_path {
println!(
"{}: Cannot rename \"{}\" to \"{}\" - file already exists",
"Error".red(),
file_path_str,
new_path.to_string_lossy()
);
stats.errors += 1;
return Ok(());
}
println!(
"{}{}\"{}\" → \"{}\"",
if preview_only {
"[PREVIEW] ".blue()
} else {
"".into()
},
"Rename: ".green(),
filename,
new_name
);
if !preview_only {
let backup_dir = home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".config")
.join("smv")
.join("backups");
fs::create_dir_all(&backup_dir)?;
let mut history_manager = HistoryManager::new(50, &backup_dir);
history_manager.record(file_path.to_path_buf(), new_path.clone())?;
match fs::rename(file_path, &new_path) {
Ok(_) => stats.renamed += 1,
Err(e) => {
println!("{}: Renaming \"{}\": {}", "Error".red(), file_path_str, e);
stats.errors += 1;
}
}
} else {
stats.renamed += 1;
}
stats.processed += 1;
Ok(())
}