use std::path::{Path, PathBuf};
use tokio::fs;
use crate::FileRename;
#[derive(Debug, Clone, PartialEq)]
pub enum ValidationIssue {
InvalidCharacters(String),
ReservedFilename(String),
PathTooLong { path: String, max_length: usize },
SourceNotFound(String),
SourceNotReadable(String),
ParentNotWritable(String),
TargetExists(String),
CircularRename { file1: String, file2: String },
InvalidFormat(String),
EmptyFilename,
}
#[derive(Debug)]
pub struct ValidationResult {
pub valid: Vec<FileRename>,
pub issues: Vec<(PathBuf, ValidationIssue)>,
}
pub async fn validate_renames(renames: &[FileRename], overwrite: bool) -> ValidationResult {
let mut valid = Vec::new();
let mut issues = Vec::new();
let circular_issues = detect_circular_renames(renames);
let circular_paths: std::collections::HashSet<_> = circular_issues.iter()
.map(|(p, _)| p.clone())
.collect();
for (path, issue) in circular_issues {
issues.push((path, issue));
}
let validation_futures = renames.iter()
.filter(|rename| !circular_paths.contains(&rename.old_path))
.map(|rename| {
let rename = rename.clone();
async move {
let issue = validate_single_rename(&rename, overwrite).await;
(rename.old_path.clone(), issue)
}
});
let results: Vec<_> = futures::future::join_all(validation_futures).await;
for (path, issue) in results {
if let Some(issue) = issue {
issues.push((path, issue));
} else {
if let Some(rename) = renames.iter().find(|r| r.old_path == path) {
valid.push(rename.clone());
}
}
}
ValidationResult { valid, issues }
}
async fn validate_single_rename(rename: &FileRename, overwrite: bool) -> Option<ValidationIssue> {
if rename.new_name.trim().is_empty() {
return Some(ValidationIssue::EmptyFilename);
}
if let Some(issue) = validate_filename_format(&rename.new_name) {
return Some(issue);
}
if let Some(issue) = validate_filename_characters(&rename.new_name) {
return Some(issue);
}
if let Some(issue) = validate_reserved_filename(&rename.new_name) {
return Some(issue);
}
if let Some(issue) = validate_path_length(&rename.new_path) {
return Some(issue);
}
if fs::metadata(&rename.old_path).await.is_err() {
return Some(ValidationIssue::SourceNotFound(
rename.old_path.display().to_string()
));
}
if fs::File::open(&rename.old_path).await.is_err() {
return Some(ValidationIssue::SourceNotReadable(
rename.old_path.display().to_string()
));
}
if let Some(parent) = rename.new_path.parent() {
if check_directory_writable(parent).await.is_err() {
return Some(ValidationIssue::ParentNotWritable(
parent.display().to_string()
));
}
}
if fs::metadata(&rename.new_path).await.is_ok() && !overwrite && rename.old_path != rename.new_path {
return Some(ValidationIssue::TargetExists(
rename.new_path.display().to_string()
));
}
None
}
fn validate_filename_format(name: &str) -> Option<ValidationIssue> {
#[cfg(windows)]
{
if name.starts_with(' ') || name.ends_with(' ') {
return Some(ValidationIssue::InvalidFormat(
"Filename cannot start or end with spaces".to_string()
));
}
if name.starts_with('.') && name.len() > 1 && name.chars().skip(1).all(|c| c == '.') {
return Some(ValidationIssue::InvalidFormat(
"Filename cannot end with dots (except single dot for current dir)".to_string()
));
}
}
#[cfg(not(windows))]
{
let _ = name; }
None
}
fn validate_path_length(path: &Path) -> Option<ValidationIssue> {
let path_str = path.to_string_lossy();
let max_length = get_max_path_length();
if path_str.len() > max_length {
return Some(ValidationIssue::PathTooLong {
path: path_str.to_string(),
max_length,
});
}
None
}
fn get_max_path_length() -> usize {
#[cfg(windows)]
{
260 }
#[cfg(not(windows))]
{
4096 }
}
fn validate_filename_characters(name: &str) -> Option<ValidationIssue> {
let invalid_chars = get_invalid_filename_chars();
for ch in name.chars() {
if invalid_chars.contains(&ch) {
return Some(ValidationIssue::InvalidCharacters(
format!("Invalid character: '{}'", ch)
));
}
}
None
}
fn get_invalid_filename_chars() -> Vec<char> {
#[cfg(windows)]
{
vec!['<', '>', ':', '"', '/', '\\', '|', '?', '*']
}
#[cfg(not(windows))]
{
vec!['/'] }
}
fn validate_reserved_filename(name: &str) -> Option<ValidationIssue> {
#[cfg(windows)]
{
let name_upper = name.to_uppercase();
let reserved = vec![
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
let name_without_ext = if let Some(dot_pos) = name_upper.rfind('.') {
&name_upper[..dot_pos]
} else {
&name_upper
};
if reserved.contains(&name_without_ext) {
return Some(ValidationIssue::ReservedFilename(
format!("'{}' is a reserved filename on Windows", name)
));
}
}
#[cfg(not(windows))]
{
let _ = name; }
None
}
fn detect_circular_renames(renames: &[FileRename]) -> Vec<(PathBuf, ValidationIssue)> {
let mut issues = Vec::new();
for (i, rename1) in renames.iter().enumerate() {
for rename2 in renames.iter().skip(i + 1) {
if rename1.new_path == rename2.old_path && rename2.new_path == rename1.old_path {
issues.push((
rename1.old_path.clone(),
ValidationIssue::CircularRename {
file1: rename1.old_path.display().to_string(),
file2: rename2.old_path.display().to_string(),
}
));
issues.push((
rename2.old_path.clone(),
ValidationIssue::CircularRename {
file1: rename1.old_path.display().to_string(),
file2: rename2.old_path.display().to_string(),
}
));
}
}
}
issues
}
async fn check_directory_writable(dir: &Path) -> Result<(), std::io::Error> {
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let test_file = dir.join(format!(".fren_write_test_{}", timestamp));
match fs::File::create(&test_file).await {
Ok(_) => {
fs::remove_file(&test_file).await?;
Ok(())
}
Err(e) => Err(e),
}
}