use crate::prelude::*;
use clap::{Parser, ValueEnum};
#[derive(Parser, Debug, Clone)]
#[command(name = "refactor")]
#[command(version = crate::get_version(), about = "A robust cross-platform tool for recursive string replacement in file/folder names and contents - part of the workspace tool suite")]
#[command(long_about = None)]
pub struct Args {
#[arg(value_name = "ROOT_DIR")]
pub root_dir: PathBuf,
#[arg(value_name = "PATTERN", required_unless_present = "patterns_file")]
pub pattern: Option<String>,
#[arg(value_name = "SUBSTITUTE", required_unless_present = "patterns_file")]
pub substitute: Option<String>,
#[arg(long = "patterns-file", value_name = "FILE", conflicts_with_all = ["pattern", "substitute"])]
pub patterns_file: Option<PathBuf>,
#[arg(short = 'y', long = "assume-yes")]
pub assume_yes: bool,
#[arg(short = 'v', long = "verbose")]
pub verbose: bool,
#[arg(long = "follow-symlinks")]
pub follow_symlinks: bool,
#[arg(short = 'b', long = "backup")]
pub backup: bool,
#[arg(long = "files-only")]
pub files_only: bool,
#[arg(long = "dirs-only")]
pub dirs_only: bool,
#[arg(long = "names-only")]
pub names_only: bool,
#[arg(long = "content-only")]
pub content_only: bool,
#[arg(long = "max-depth", default_value = "0")]
pub max_depth: usize,
#[arg(long = "exclude", value_name = "PATTERN")]
pub exclude_patterns: Vec<String>,
#[arg(long = "include", value_name = "PATTERN")]
pub include_patterns: Vec<String>,
#[arg(long = "format", default_value = "human")]
pub format: OutputFormat,
#[arg(short = 'j', long = "threads", default_value = "0")]
pub threads: usize,
#[arg(long = "progress", default_value = "auto")]
pub progress: ProgressMode,
#[arg(short = 'i', long = "ignore-case")]
pub ignore_case: bool,
#[arg(short = 'r', long = "regex")]
pub use_regex: bool,
#[arg(long = "include-hidden")]
pub include_hidden: bool,
#[arg(long = "binary-names")]
pub binary_names: bool,
}
#[derive(ValueEnum, Debug, Clone, PartialEq)]
pub enum OutputFormat {
Human,
Json,
Plain,
}
#[derive(ValueEnum, Debug, Clone)]
pub enum ProgressMode {
Auto,
Never,
Always,
}
#[derive(Debug, Clone)]
pub enum Mode {
Full,
FilesOnly,
DirsOnly,
NamesOnly,
ContentOnly,
}
impl Args {
pub fn get_mode(&self) -> Mode {
match (
self.files_only,
self.dirs_only,
self.names_only,
self.content_only,
) {
(true, false, false, false) => Mode::FilesOnly,
(false, true, false, false) => Mode::DirsOnly,
(false, false, true, false) => Mode::NamesOnly,
(false, false, false, true) => Mode::ContentOnly,
_ => Mode::Full,
}
}
pub fn validate(&self) -> Result<(), String> {
let mode_flags = [
self.files_only,
self.dirs_only,
self.names_only,
self.content_only,
];
let active_modes = mode_flags.iter().filter(|&&x| x).count();
if active_modes > 1 {
return Err("Cannot specify more than one mode flag (--files-only, --dirs-only, --names-only, --content-only)".to_string());
}
if !self.root_dir.exists() {
return Err(format!(
"Root directory does not exist: {}",
self.root_dir.display()
));
}
if !self.root_dir.is_dir() {
return Err(format!(
"Root path is not a directory: {}",
self.root_dir.display()
));
}
match (&self.pattern, &self.substitute, &self.patterns_file) {
(Some(p), Some(s), None) => {
if p.is_empty() {
return Err("Pattern cannot be empty".to_string());
}
if s.is_empty() {
return Err("Substitute cannot be empty".to_string());
}
if self.should_process_names() {
let invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0'];
if let Some(ch) = s.chars().find(|c| invalid_chars.contains(c)) {
return Err(format!(
"Substitute cannot contain path-unsafe characters ({}) when processing names. Use --content-only to replace in file contents only.",
ch
));
}
}
}
(None, None, Some(pf)) => {
if !pf.exists() {
return Err(format!("Patterns file does not exist: {}", pf.display()));
}
if !pf.is_file() {
return Err(format!("Patterns file is not a file: {}", pf.display()));
}
}
(Some(_), None, None) | (None, Some(_), None) => {
return Err(
"Both PATTERN and SUBSTITUTE are required when not using --patterns-file"
.to_string(),
);
}
(Some(_), _, Some(_)) | (_, Some(_), Some(_)) => {
return Err(
"Cannot use positional PATTERN/SUBSTITUTE together with --patterns-file"
.to_string(),
);
}
(None, None, None) => {
return Err("Either provide PATTERN SUBSTITUTE or use --patterns-file".to_string());
}
}
if self.threads > 1000 {
return Err("Thread count cannot exceed 1000".to_string());
}
if self.max_depth > 1000 {
return Err("Max depth cannot exceed 1000".to_string());
}
Ok(())
}
pub fn should_process_files(&self) -> bool {
!self.dirs_only
}
pub fn should_process_dirs(&self) -> bool {
!self.files_only
}
pub fn should_process_content(&self) -> bool {
!self.names_only
}
pub fn should_process_names(&self) -> bool {
!self.content_only
}
pub fn get_thread_count(&self) -> usize {
if self.threads == 0 {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
} else {
self.threads
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_args_validation() {
let temp_dir = TempDir::new().unwrap();
let mut args = Args {
root_dir: temp_dir.path().to_path_buf(),
pattern: Some("old".to_string()),
substitute: Some("new".to_string()),
patterns_file: None,
assume_yes: false,
verbose: false,
follow_symlinks: false,
backup: false,
files_only: false,
dirs_only: false,
names_only: false,
content_only: false,
max_depth: 0,
exclude_patterns: vec![],
include_patterns: vec![],
format: OutputFormat::Human,
threads: 0,
progress: ProgressMode::Auto,
ignore_case: false,
use_regex: false,
include_hidden: false,
binary_names: false,
};
assert!(args.validate().is_ok());
args.pattern = Some("".to_string());
assert!(args.validate().is_err());
args.pattern = Some("old".to_string());
args.substitute = Some("".to_string());
assert!(args.validate().is_err());
args.substitute = Some("new".to_string());
let invalid_chars = vec![
"new/path",
"new\\path",
"new:path",
"new*path",
"new?path",
"new\"path",
"new<path",
"new>path",
"new|path",
];
for invalid in &invalid_chars {
args.substitute = Some(invalid.to_string());
assert!(args.validate().is_err(), "Should reject: {}", invalid);
}
args.substitute = Some("new".to_string());
args.content_only = true;
for valid_in_content in &invalid_chars {
args.substitute = Some(valid_in_content.to_string());
assert!(
args.validate().is_ok(),
"Should allow in content-only mode: {}",
valid_in_content
);
}
args.substitute = Some("new".to_string());
args.content_only = false;
args.files_only = true;
args.dirs_only = true;
assert!(args.validate().is_err());
}
#[test]
fn test_args_validation_patterns_file() {
let temp_dir = TempDir::new().unwrap();
let patterns_file = temp_dir.path().join("patterns.txt");
std::fs::write(&patterns_file, "foo bar\n").unwrap();
let args = Args {
root_dir: temp_dir.path().to_path_buf(),
pattern: None,
substitute: None,
patterns_file: Some(patterns_file.clone()),
assume_yes: false,
verbose: false,
follow_symlinks: false,
backup: false,
files_only: false,
dirs_only: false,
names_only: false,
content_only: false,
max_depth: 0,
exclude_patterns: vec![],
include_patterns: vec![],
format: OutputFormat::Human,
threads: 0,
progress: ProgressMode::Auto,
ignore_case: false,
use_regex: false,
include_hidden: false,
binary_names: false,
};
assert!(args.validate().is_ok());
let args_both = Args {
pattern: Some("foo".to_string()),
substitute: Some("bar".to_string()),
patterns_file: Some(patterns_file.clone()),
..args.clone()
};
assert!(args_both.validate().is_err());
let args_neither = Args {
pattern: None,
substitute: None,
patterns_file: None,
..args.clone()
};
assert!(args_neither.validate().is_err());
let args_partial = Args {
pattern: Some("foo".to_string()),
substitute: None,
patterns_file: None,
..args.clone()
};
assert!(args_partial.validate().is_err());
let args_missing = Args {
pattern: None,
substitute: None,
patterns_file: Some(PathBuf::from("/nonexistent/file.txt")),
..args.clone()
};
assert!(args_missing.validate().is_err());
}
#[test]
fn test_mode_detection() {
let temp_dir = TempDir::new().unwrap();
let base_args = Args {
root_dir: temp_dir.path().to_path_buf(),
pattern: Some("old".to_string()),
substitute: Some("new".to_string()),
patterns_file: None,
assume_yes: false,
verbose: false,
follow_symlinks: false,
backup: false,
files_only: false,
dirs_only: false,
names_only: false,
content_only: false,
max_depth: 0,
exclude_patterns: vec![],
include_patterns: vec![],
format: OutputFormat::Human,
threads: 0,
progress: ProgressMode::Auto,
ignore_case: false,
use_regex: false,
include_hidden: false,
binary_names: false,
};
assert!(matches!(base_args.get_mode(), Mode::Full));
let mut args = base_args.clone();
args.files_only = true;
assert!(matches!(args.get_mode(), Mode::FilesOnly));
let mut args = base_args.clone();
args.dirs_only = true;
assert!(matches!(args.get_mode(), Mode::DirsOnly));
let mut args = base_args.clone();
args.names_only = true;
assert!(matches!(args.get_mode(), Mode::NamesOnly));
let mut args = base_args.clone();
args.content_only = true;
assert!(matches!(args.get_mode(), Mode::ContentOnly));
}
#[test]
fn test_processing_flags() {
let temp_dir = TempDir::new().unwrap();
let mut args = Args {
root_dir: temp_dir.path().to_path_buf(),
pattern: Some("old".to_string()),
substitute: Some("new".to_string()),
patterns_file: None,
assume_yes: false,
verbose: false,
follow_symlinks: false,
backup: false,
files_only: false,
dirs_only: false,
names_only: false,
content_only: false,
max_depth: 0,
exclude_patterns: vec![],
include_patterns: vec![],
format: OutputFormat::Human,
threads: 0,
progress: ProgressMode::Auto,
ignore_case: false,
use_regex: false,
include_hidden: false,
binary_names: false,
};
assert!(args.should_process_files());
assert!(args.should_process_dirs());
assert!(args.should_process_content());
assert!(args.should_process_names());
args.dirs_only = true;
assert!(!args.should_process_files());
assert!(args.should_process_dirs());
args.dirs_only = false;
args.content_only = true;
assert!(args.should_process_content());
assert!(!args.should_process_names());
}
}