mod transformers;
mod history;
mod repl;
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use std::error::Error;
use clap::{Parser, ArgAction};
use colored::*;
use regex::Regex;
use walkdir::WalkDir;
use dirs::home_dir;
use transformers::{TransformType, transform};
use history::HistoryManager;
use repl::InteractiveSession;
#[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, 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 = "clean", action = ArgAction::SetTrue)]
clean: bool,
#[arg(long = "snake", action = ArgAction::SetTrue)]
snake: bool,
#[arg(long = "kebab", action = ArgAction::SetTrue)]
kebab: bool,
#[arg(long = "title", action = ArgAction::SetTrue)]
title: bool,
#[arg(long = "camel", action = ArgAction::SetTrue)]
camel: bool,
#[arg(long = "pascal", action = ArgAction::SetTrue)]
pascal: bool,
#[arg(long = "lower", action = ArgAction::SetTrue)]
lower: bool,
#[arg(long = "upper", action = ArgAction::SetTrue)]
upper: bool,
#[arg(long, action = ArgAction::SetTrue)]
dry_run: bool,
#[arg(long, value_name = "PATTERNS")]
exclude: Option<String>,
#[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.interactive {
run_interactive_mode(args.max_history_size)?;
return Ok(());
}
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 or -i for interactive mode.");
process::exit(1);
}
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 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 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(())
}