freneng 0.1.2

A useful, async-first file renaming library
Documentation
//! Validation of rename operations before execution.
//! 
//! This module provides comprehensive validation including filename validity,
//! file system checks, permission verification, and circular rename detection.
//! All file system operations are async and non-blocking.

use std::path::{Path, PathBuf};
use tokio::fs;
use crate::FileRename;

/// Types of validation issues that can be found.
#[derive(Debug, Clone, PartialEq)]
pub enum ValidationIssue {
    /// Invalid filename characters for the OS
    InvalidCharacters(String),
    /// Reserved filename (e.g., CON, PRN on Windows)
    ReservedFilename(String),
    /// Path too long for the filesystem
    PathTooLong { path: String, max_length: usize },
    /// Source file doesn't exist
    SourceNotFound(String),
    /// Source file not readable
    SourceNotReadable(String),
    /// Parent directory not writable
    ParentNotWritable(String),
    /// Target file already exists (only if overwrite=false)
    TargetExists(String),
    /// Circular rename detected (file1->file2, file2->file1)
    CircularRename { file1: String, file2: String },
    /// Invalid filename format (leading/trailing spaces/dots on Windows)
    InvalidFormat(String),
    /// Empty filename
    EmptyFilename,
}

/// Result of validating rename operations.
#[derive(Debug)]
pub struct ValidationResult {
    /// Renames that passed all validation checks
    pub valid: Vec<FileRename>,
    /// Renames with issues, paired with the specific issue found
    pub issues: Vec<(PathBuf, ValidationIssue)>,
}

/// Validates a set of renames before execution asynchronously.
/// 
/// Performs comprehensive validation including:
/// - Filename validity (characters, length, format, reserved names)
/// - File system checks (existence, permissions)
/// - Circular rename detection
/// 
/// All file system operations are non-blocking.
/// 
/// # Arguments
/// 
/// * `renames` - List of proposed rename operations
/// * `overwrite` - Whether to allow overwriting existing files
/// 
/// # Returns
/// 
/// A `ValidationResult` separating valid renames from those with issues.
/// 
/// # Examples
/// 
/// ```
/// # tokio_test::block_on(async {
/// use freneng::validation::validate_renames;
/// use freneng::FileRename;
/// use std::path::PathBuf;
/// use tempfile::TempDir;
/// use tokio::fs;
/// 
/// // Create a temporary directory and file for testing
/// let temp_dir = TempDir::new().unwrap();
/// let old_file = temp_dir.path().join("old.txt");
/// let new_file = temp_dir.path().join("new.txt");
/// 
/// // Create the source file
/// fs::write(&old_file, "test content").await.unwrap();
/// 
/// let renames = vec![FileRename {
///     old_path: old_file.clone(),
///     new_path: new_file.clone(),
///     new_name: "new.txt".to_string(),
/// }];
/// 
/// let result = validate_renames(&renames, false).await;
/// assert_eq!(result.valid.len(), 1);
/// assert_eq!(result.issues.len(), 0);
/// # })
/// ```
pub async fn validate_renames(renames: &[FileRename], overwrite: bool) -> ValidationResult {
    let mut valid = Vec::new();
    let mut issues = Vec::new();
    
    // Check for circular renames first (synchronous, no I/O)
    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));
    }
    
    // Validate each rename concurrently (skip those already marked as circular)
    let validation_futures = renames.iter()
        .filter(|rename| !circular_paths.contains(&rename.old_path))
        .map(|rename| {
            let rename = rename.clone();
            async move {
                // Validate each rename
                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 {
            // Find the rename to add to valid (only non-circular ones reach here)
            if let Some(rename) = renames.iter().find(|r| r.old_path == path) {
                valid.push(rename.clone());
            }
        }
    }
    
    ValidationResult { valid, issues }
}

/// Validates a single rename operation asynchronously.
/// 
/// Checks all validation rules for one file rename and returns
/// the first issue found, or None if valid.
async fn validate_single_rename(rename: &FileRename, overwrite: bool) -> Option<ValidationIssue> {
    // Check empty filename (synchronous check)
    if rename.new_name.trim().is_empty() {
        return Some(ValidationIssue::EmptyFilename);
    }
    
    // Validate filename format (synchronous, should be checked early)
    if let Some(issue) = validate_filename_format(&rename.new_name) {
        return Some(issue);
    }
    
    // Validate filename characters (synchronous, should be checked before I/O operations)
    if let Some(issue) = validate_filename_characters(&rename.new_name) {
        return Some(issue);
    }
    
    // Validate reserved filenames (synchronous)
    if let Some(issue) = validate_reserved_filename(&rename.new_name) {
        return Some(issue);
    }
    
    // Validate path length (synchronous)
    if let Some(issue) = validate_path_length(&rename.new_path) {
        return Some(issue);
    }
    
    // Check source file exists
    if fs::metadata(&rename.old_path).await.is_err() {
        return Some(ValidationIssue::SourceNotFound(
            rename.old_path.display().to_string()
        ));
    }
    
    // Check source is readable (try to open for reading)
    if fs::File::open(&rename.old_path).await.is_err() {
        return Some(ValidationIssue::SourceNotReadable(
            rename.old_path.display().to_string()
        ));
    }
    
    // Check parent directory of NEW path is writable (where we're writing to)
    // This is the directory that needs write permission for the rename operation
    if let Some(parent) = rename.new_path.parent() {
        if check_directory_writable(parent).await.is_err() {
            return Some(ValidationIssue::ParentNotWritable(
                parent.display().to_string()
            ));
        }
    }
    
    // Check target file exists (if not overwriting)
    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)]
    {
        // Windows doesn't allow leading/trailing spaces or dots
        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))]
    {
        // On Unix-like systems, these are generally allowed
        let _ = name; // Suppress unused variable warning
    }
    
    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 // MAX_PATH on Windows
    }
    #[cfg(not(windows))]
    {
        4096 // Typical Linux/Unix limit
    }
}

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!['/'] // Only forward slash is invalid on Unix-like systems
    }
}

fn validate_reserved_filename(name: &str) -> Option<ValidationIssue> {
    #[cfg(windows)]
    {
        // Windows reserved filenames (case-insensitive)
        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",
        ];
        
        // Check if name (without extension) matches reserved name
        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))]
    {
        // Unix-like systems don't have reserved filenames (except . and ..)
        let _ = name; // Suppress unused variable warning
    }
    
    None
}

/// Detects circular renames (file1->file2, file2->file1).
/// 
/// Returns validation issues for all files involved in circular renames.
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) {
            // Check if rename1's target is rename2's source
            // and rename2's target is rename1's source
            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
}

/// Checks if a directory is writable by attempting to create a test file asynchronously.
/// 
/// This is a practical test of write permissions rather than just checking
/// metadata, as permissions can be complex on some systems.
/// 
/// Uses a unique test filename to avoid race conditions when multiple validations
/// run concurrently or when leftover test files exist from previous runs.
async fn check_directory_writable(dir: &Path) -> Result<(), std::io::Error> {
    // Use a unique test filename to avoid conflicts with concurrent validations
    // or leftover files from previous runs
    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),
    }
}