Functions/GenXdev.FileSystem/Rename-InProject.ps1

################################################################################
<#
.SYNOPSIS
Performs case-sensitive text replacement throughout a project directory.
 
.DESCRIPTION
Recursively searches through files and directories in a project to perform text
replacements. Handles both file/directory names and file contents. Skips common
binary files and repository folders (.git, .svn) to avoid corruption. Uses UTF-8
encoding without BOM for file operations.
 
.PARAMETER Source
The directory, filepath, or directory+searchmask to process. Defaults to current
directory if not specified.
 
.PARAMETER FindText
The case-sensitive text pattern to search for in filenames and content.
 
.PARAMETER ReplacementText
The text to replace all instances of FindText with.
 
.PARAMETER WhatIf
Shows what changes would occur without actually making them.
 
.EXAMPLE
Rename-InProject -Source .\src\*.js -FindText "oldName" `
    -ReplacementText "newName"
 
.EXAMPLE
rip . "MyClass" "MyNewClass" -WhatIf
#>

function Rename-InProject {

    [CmdletBinding(SupportsShouldProcess = $true)]
    [Alias("rip")]
    param(
        ########################################################################
        [Parameter(
            Mandatory = $false,
            Position = 0,
            ValueFromPipeline = $false,
            HelpMessage = "The directory, filepath, or directory+searchmask"
        )]
        [Alias("src", "s")]
        [PSDefaultValue(Value = ".\")]
        [string] $Source,
        ########################################################################
        [Parameter(
            Mandatory = $true,
            Position = 1,
            ValueFromPipeline = $false,
            HelpMessage = "The text to find (case sensitive)"
        )]
        [Alias("find", "what", "from")]
        [ValidateNotNullOrEmpty()]
        [string] $FindText,
        ########################################################################
        [Parameter(
            Mandatory = $true,
            Position = 2,
            ValueFromPipeline = $false,
            HelpMessage = "The text to replace matches with"
        )]
        [Alias("into", "txt", "to")]
        [ValidateNotNull()]
        [string] $ReplacementText
        ########################################################################
    )

    begin {

        try {
            # normalize path and extract search pattern if specified
            $sourcePath = GenXdev.FileSystem\Expand-Path $Source
            $searchPattern = "*"

            # split source into path and pattern if not a directory
            if (![System.IO.Directory]::Exists($sourcePath)) {

                $searchPattern = [System.IO.Path]::GetFileName($sourcePath)
                $sourcePath = [System.IO.Path]::GetDirectoryName($sourcePath)

                if (![System.IO.Directory]::Exists($sourcePath)) {
                    throw "Source directory not found: $sourcePath"
                }
            }

            Write-Verbose "Processing source path: $sourcePath"
            Write-Verbose "Using search pattern: $searchPattern"

            # extensions to skip to avoid corrupting binary files
            $skipExtensions = @(
                ".jpg", ".jpeg", ".gif", ".bmp", ".png", ".tiff",
                ".exe", ".dll", ".pdb", ".so",
                ".wav", ".mp3", ".avi", ".mkv", ".wmv",
                ".tar", ".7z", ".zip", ".rar", ".apk", ".ipa",
                ".cer", ".crt", ".pkf", ".db", ".bin"
            )
        }
        catch {
            throw
        }
    }

    process {

        try {
            # recursive function to get all project files excluding repos
            function Get-ProjectFiles {

                [CmdletBinding()]
                [OutputType([System.Collections.Generic.List[string]])]
                [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute(
                    "PSUseSingularNouns",
                    "Get-ProjectFiles"
                )]
                param(
                    [string] $Dir,
                    [string] $Mask
                )

                $result = [System.Collections.Generic.List[string]]::new()

                # skip version control directories
                if ([IO.Path]::GetFileName($Dir) -in @(".svn", ".git")) {
                    return $result
                }

                # collect matching files in current directory
                [IO.Directory]::GetFiles($Dir, $Mask) | ForEach-Object {
                    $null = $result.Add($_)
                }

                # recursively process subdirectories
                [IO.Directory]::GetDirectories($Dir, "*") | ForEach-Object {
                    if ([IO.Path]::GetFileName($_) -notin @(".svn", ".git")) {
                        $null = Get-ProjectFiles $_ $Mask | ForEach-Object {
                            $null = $result.Add($_)
                        }
                    }
                }

                return $result
            }

            # process files in reverse order to handle renames safely
            Get-ProjectFiles -dir $sourcePath -mask $searchPattern |
            Sort-Object -Descending |
            ForEach-Object {

                $filePath = $_
                $extension = [IO.Path]::GetExtension($filePath).ToLower()

                # only process text files
                if ($extension -notin $skipExtensions) {

                    try {
                        Write-Verbose "Processing file: $filePath"

                        # replace text in file contents
                        $content = [IO.File]::ReadAllText($filePath,
                            [Text.Encoding]::UTF8)
                        $newContent = $content.Replace($FindText, $ReplacementText)

                        if ($content -ne $newContent) {
                            if ($PSCmdlet.ShouldProcess($filePath,
                                    "Replace content")) {

                                $utf8 = [Text.UTF8Encoding]::new($false)
                                [IO.File]::WriteAllText($filePath, $newContent,
                                    $utf8)

                                Write-Verbose "Updated content in: $filePath"
                            }
                        }
                    }
                    catch {
                        Write-Warning "Failed to update content in: $filePath`n$_"
                    }

                    # handle filename changes
                    $oldName = [IO.Path]::GetFileName($filePath)
                    $newName = $oldName.Replace($FindText, $ReplacementText)

                    if ($oldName -ne $newName) {
                        $newPath = [IO.Path]::Combine(
                            [IO.Path]::GetDirectoryName($filePath),
                            $newName)

                        if ($PSCmdlet.ShouldProcess($filePath, "Rename file")) {
                            try {
                                $null = Move-ItemWithTracking -Path $filePath `
                                    -Destination $newPath
                                Write-Verbose "Renamed file: $filePath -> $newPath"
                            }
                            catch {
                                Write-Warning "Failed to rename file: $filePath`n$_"
                            }
                        }
                    }
                }
            }

            # process directories in reverse order
            Get-ChildItem -Path $sourcePath -Directory -Recurse |
            Sort-Object -Descending |
            Where-Object {
                $_.FullName -notlike "*\.git\*" -and
                $_.FullName -notlike "*\.svn\*"
            } |
            ForEach-Object {

                $dir = $_
                $oldName = $dir.Name
                $newName = $oldName.Replace($FindText, $ReplacementText)

                if ($oldName -ne $newName) {
                    $newPath = GenXdev.FileSystem\Expand-Path (
                        [IO.Path]::Combine($dir.Parent.FullName, $newName))

                    if ($PSCmdlet.ShouldProcess($dir.FullName,
                            "Rename directory")) {

                        if ([IO.Directory]::Exists($newPath)) {
                            # merge directories if target exists
                            Start-RoboCopy -Source $dir.FullName `
                                -DestinationDirectory $newPath -Move
                            $null = Remove-AllItems ($dir.FullName) -DeleteFolder
                            Write-Verbose "Merged directory: $($dir.FullName) -> $newPath"
                        }
                        else {
                            try {
                                $null = Move-ItemWithTracking -Path $dir.FullName `
                                    -Destination $newPath
                                Write-Verbose "Renamed directory: $($dir.FullName) -> $newPath"
                            }
                            catch {
                                Write-Warning "Failed to rename directory: $($dir.FullName)`n$_"
                            }
                        }
                    }
                }
            }
        }
        catch {
            throw
        }
    }

    end {
    }
}
################################################################################