Public/Convert-ModuleNameAndReferences.ps1

<#
.SYNOPSIS
Copies a PowerShell module to a new location, optionally renaming the module and updating all references to the new name.

.DESCRIPTION
This function copies one or more PowerShell module directories to a specified output path. If the module name contains a dot (.), you can replace the first segment of the module name with a specified string. All references to the old module name in the copied text files are updated to the new name, and files named after the module (for example the .psd1 and .psm1) are renamed. Function names (Verb-OldSegmentNoun) and file names containing the segment are also updated. Binary files (files containing NUL characters) are copied unchanged.

.PARAMETER ModulePaths
An array of paths to the module directories to convert.

.PARAMETER OutputPath
The directory where the converted modules will be placed. Created if it does not exist.

.PARAMETER ReplaceFirstSegmentWith
If specified and the module name contains a dot, replaces the first segment of the module name (before the first dot) with this value. The segment is capitalized (PascalCase) when updating function and file names. Empty or whitespace is treated as "no replacement".

.PARAMETER Force
Overwrites the destination directory if it already exists.

.EXAMPLE
Convert-ModuleNameAndReferences -ModulePaths 'C:\git\tcs.confluence-1\ps\modules\tcs.confluence' -OutputPath 'C:\working\ModuleConversion' -ReplaceFirstSegmentWith 'X'

Copies the module and renames it so that the first segment is replaced with 'X', updating all references in the files.

.EXAMPLE
Convert-ModuleNameAndReferences -ModulePaths 'C:\modules\my.module' -OutputPath 'C:\out' -ReplaceFirstSegmentWith 'tcs' -Force

Overwrites the destination folder if C:\out\tcs.module already exists.

.INPUTS
System.String[]. Paths to module directories to convert (ModulePaths).

.OUTPUTS
None. Writes verbose output and creates files and folders under OutputPath.

.NOTES
Author: TheCodeSaiyan
Date: 2025-09-11
Compatible with PowerShell Constrained Language Mode (no [regex] or [string]:: static methods;
uses -replace operator and inline regex-literal escaping).
#>

function Convert-ModuleNameAndReferences {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '',
        Justification = 'Public command name kept for backward compatibility.')]
    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([void])]
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$ModulePaths,

        [Parameter(Mandatory = $true)]
        [string]$OutputPath,

        [string]$ReplaceFirstSegmentWith,

        [switch]$Force
    )

    $TelemetryArgs = @{
        ModuleName    = $MyInvocation.MyCommand.Module.Name
        ModuleVersion = [string]$MyInvocation.MyCommand.Module.Version
        CommandName   = $MyInvocation.MyCommand.Name
        ExecutionID   = [guid]::NewGuid().ToString()
    }
    Invoke-TelemetryCollection @TelemetryArgs -Stage Start -ClearTimer

    try {
        # Escape for use in -match/-replace (Constrained Language Mode: no [regex]::Escape)
        $escapePattern = {
            param([string]$Text)
            $Text -replace '\\', '\\' -replace '\[', '\[' -replace '\]', '\]' -replace '\(', '\(' -replace '\)', '\)' -replace '\*', '\*' -replace '\+', '\+' -replace '\?', '\?' -replace '\.', '\.' -replace '\^', '\^' -replace '\$', '\$' -replace '\|', '\|' -replace '\{', '\{' -replace '\}', '\}'
        }

        if (-not (Test-Path -Path $OutputPath -PathType Container)) {
            if ($PSCmdlet.ShouldProcess($OutputPath, 'Create output folder')) {
                $null = New-Item -Path $OutputPath -ItemType Directory -Force -ErrorAction Stop
            }
        }

        foreach ($modulePath in $ModulePaths) {
            if (-not (Test-Path -Path $modulePath -PathType Container)) {
                Write-Warning "Module path not found: $modulePath"
                continue
            }
            $moduleName = Split-Path -Path $modulePath -Leaf
            $hasReplacement = $ReplaceFirstSegmentWith -and ($ReplaceFirstSegmentWith.Trim().Length -gt 0) -and ($moduleName -match '\.')
            if ($hasReplacement) {
                $oldFirstSegment = $moduleName -replace '\..*', ''
                $seg = $ReplaceFirstSegmentWith.Trim()
                $newModuleName = $moduleName -replace '^[^.]+', $seg
                $newSegmentCap = $seg.Substring(0, 1).ToUpper() + $seg.Substring(1)
                $oldFirstSegmentEscaped = & $escapePattern $oldFirstSegment
            }
            else {
                $newModuleName = $moduleName
                $newSegmentCap = $null
                $oldFirstSegmentEscaped = $null
            }
            $moduleNameEscaped = & $escapePattern $moduleName
            $destModulePath = Join-Path -Path $OutputPath -ChildPath $newModuleName

            if (Test-Path -Path $destModulePath) {
                if ($Force) {
                    if ($PSCmdlet.ShouldProcess($destModulePath, 'Remove-Item')) {
                        Remove-Item -Path $destModulePath -Recurse -Force
                    }
                }
                else {
                    Write-Error "Destination module already exists: $destModulePath. Use -Force to overwrite. Skipping."
                    continue
                }
            }

            if ($PSCmdlet.ShouldProcess($destModulePath, 'Copy-Item')) {
                Copy-Item -Path $modulePath -Destination $destModulePath -Recurse -Force

                # Replace all references to the old module name in all text files
                $files = Get-ChildItem -Path $destModulePath -Recurse -File

                foreach ($file in $files) {
                    $fullName = $file.FullName
                    $content = Get-Content -Path $fullName -Raw -Encoding UTF8
                    if ($null -eq $content -or $content -match "`0") {
                        # Empty or binary file: nothing to update
                        continue
                    }
                    $updatedContent = $content -replace $moduleNameEscaped, $newModuleName

                    if ($hasReplacement) {
                        # Replace function names: Verb-OldSegmentNoun -> Verb-NewSegmentNoun
                        $updatedContent = $updatedContent -replace "(?<=function\s+\w+-)$oldFirstSegmentEscaped(?=\w*)", $newSegmentCap
                        # Only add segment when noun is absent (e.g. "function Get-" or "Get-.ps1" in text)
                        $updatedContent = $updatedContent -replace '(?<=function\s+\w+-)(?=\s|$|\.)', $newSegmentCap
                    }
                    if ($updatedContent -cne $content -and $PSCmdlet.ShouldProcess($fullName, 'Update references')) {
                        Set-Content -Path $fullName -Value $updatedContent -Encoding UTF8 -NoNewline
                    }
                }

                # Rename files named after the module, e.g. old.name.psd1 -> new.name.psd1
                if ($newModuleName -ne $moduleName) {
                    $filesToRename = Get-ChildItem -Path $destModulePath -Recurse -File | Where-Object { $_.Name -match $moduleNameEscaped }
                    foreach ($file in $filesToRename) {
                        $newName = $file.Name -replace $moduleNameEscaped, $newModuleName
                        if ($PSCmdlet.ShouldProcess($file.FullName, "Rename to $newName")) {
                            Rename-Item -Path $file.FullName -NewName $newName -Force
                        }
                    }
                }

                # Rename files that contain the old first segment after dash
                if ($hasReplacement) {
                    $filesToRename = Get-ChildItem -Path $destModulePath -Recurse -File | Where-Object { $_.Name -match "-$oldFirstSegmentEscaped" }
                    foreach ($file in $filesToRename) {
                        $newName = $file.Name -replace "-$oldFirstSegmentEscaped", "-$newSegmentCap"
                        if ($PSCmdlet.ShouldProcess($file.FullName, "Rename to $newName")) {
                            Rename-Item -Path $file.FullName -NewName $newName -Force
                        }
                    }
                    # If file name is just Verb-.ps1 (no segment), add new segment
                    $filesToAddSegment = Get-ChildItem -Path $destModulePath -Recurse -File | Where-Object { $_.Name -match '-\.ps1$' }
                    foreach ($file in $filesToAddSegment) {
                        $newName = $file.Name -replace '-\.ps1$', "-$newSegmentCap.ps1"
                        if ($PSCmdlet.ShouldProcess($file.FullName, "Rename to $newName")) {
                            Rename-Item -Path $file.FullName -NewName $newName -Force
                        }
                    }
                }

                Write-Verbose "Converted $moduleName to $newModuleName at $destModulePath"
            }
        }
        Invoke-TelemetryCollection @TelemetryArgs -Stage End
    }
    catch {
        Invoke-TelemetryCollection @TelemetryArgs -Stage End -Failed $true -Exception $_
        throw
    }
}