Viscalyx.Common.psm1

#Region '.\prefix.ps1' -1

$script:dscResourceCommonModulePath = Join-Path -Path $PSScriptRoot -ChildPath 'Modules/DscResource.Common'
Import-Module -Name $script:dscResourceCommonModulePath

$script:localizedData = Get-LocalizedData -DefaultUICulture 'en-US'
#EndRegion '.\prefix.ps1' 5
#Region '.\Private\Assert-PatchFile.ps1' -1

<#
    .SYNOPSIS
        Validates the structure and data of a patch file.
 
    .DESCRIPTION
        The Assert-PatchFile function validates the structure and data of a patch file.
        It ensures that the patch file contains the necessary properties and that the
        values are valid. The function checks for required properties, verifies the
        module version and hash against the existing module, and ensures the start and
        end offsets are within bounds.
 
    .PARAMETER PatchFileObject
        Specifies the patch file to validate.
 
    .EXAMPLE
        $patchFileContent = Get-Content -Path "C:\patches\MyModule_1.0.0_patch.json" -Raw | ConvertFrom-Json
        Assert-PatchFile -PatchFileObject $patchFileContent
 
        Validates the structure and data of the patch file object.
 
    .INPUTS
        None. You cannot pipe input to this function.
 
    .OUTPUTS
        None. The function does not generate any output.
#>

function Assert-PatchFile
{
    param
    (
        [Parameter(Mandatory = $true)]
        [PSCustomObject]
        $PatchFileObject
    )

    if (-not $PatchFileObject.ModuleName)
    {
        $writeErrorParameters = @{
            Message      = $script:localizedData.Assert_PatchFile_MissingModuleName
            Category     = 'InvalidData'
            ErrorId      = 'APF0001' # cSpell: disable-line
            TargetObject = $PatchFileObject
        }

        Write-Error @writeErrorParameters

        continue
    }

    if (-not $PatchFileObject.ModuleVersion)
    {
        $writeErrorParameters = @{
            Message      = $script:localizedData.Assert_PatchFile_MissingModuleVersion
            Category     = 'InvalidData'
            ErrorId      = 'APF0002' # cSpell: disable-line
            TargetObject = $PatchFileObject
        }

        Write-Error @writeErrorParameters

        continue
    }

    # Should have the property ModuleFiles
    if (-not $PatchFileObject.ModuleFiles)
    {
        $writeErrorParameters = @{
            Message      = $script:localizedData.Assert_PatchFile_MissingModuleFiles
            Category     = 'InvalidData'
            ErrorId      = 'APF0003' # cSpell: disable-line
            TargetObject = $PatchFileObject
        }

        Write-Error @writeErrorParameters

        continue
    }

    foreach ($scriptFile in $PatchFileObject.ModuleFiles)
    {
        if (-not $scriptFile.ScriptFileName)
        {
            $writeErrorParameters = @{
                Message      = $script:localizedData.Assert_PatchFile_MissingScriptFileName
                Category     = 'InvalidData'
                ErrorId      = 'APF0004' # cSpell: disable-line
                TargetObject = $scriptFile
            }

            Write-Error @writeErrorParameters

            continue
        }

        if (-not $scriptFile.OriginalHashSHA)
        {
            $writeErrorParameters = @{
                Message      = $script:localizedData.Assert_PatchFile_MissingOriginalHashSHA
                Category     = 'InvalidData'
                ErrorId      = 'APF0005' # cSpell: disable-line
                TargetObject = $scriptFile
            }

            Write-Error @writeErrorParameters

            continue
        }

        if (-not $scriptFile.ValidationHashSHA)
        {
            $writeErrorParameters = @{
                Message      = $script:localizedData.Assert_PatchFile_MissingValidationHashSHA
                Category     = 'InvalidData'
                ErrorId      = 'APF0006' # cSpell: disable-line
                TargetObject = $scriptFile
            }

            Write-Error @writeErrorParameters

            continue
        }

        # Should have the property FilePatches
        if (-not $scriptFile.FilePatches)
        {
            $writeErrorParameters = @{
                Message      = $script:localizedData.Assert_PatchFile_MissingFilePatches
                Category     = 'InvalidData'
                ErrorId      = 'APF0007' # cSpell: disable-line
                TargetObject = $scriptFile
            }

            Write-Error @writeErrorParameters

            continue
        }

        foreach ($patchEntry in $scriptFile.FilePatches)
        {
            if ($null -eq $patchEntry.StartOffset -or $null -eq $patchEntry.EndOffset)
            {
                $writeErrorParameters = @{
                    Message      = $script:localizedData.Assert_PatchFile_MissingOffset
                    Category     = 'InvalidData'
                    ErrorId      = 'APF0008' # cSpell: disable-line
                    TargetObject = $patchEntry
                }

                Write-Error @writeErrorParameters

                continue
            }

            if (-not $patchEntry.PatchContent)
            {
                $writeErrorParameters = @{
                    Message      = $script:localizedData.Assert_PatchFile_MissingPatchContent
                    Category     = 'InvalidData'
                    ErrorId      = 'APF0009' # cSpell: disable-line
                    TargetObject = $patchEntry
                }

                Write-Error @writeErrorParameters

                continue
            }
        }
    }
}
#EndRegion '.\Private\Assert-PatchFile.ps1' 170
#Region '.\Private\Assert-ScriptFileValidity.ps1' -1

<#
    .SYNOPSIS
        Validates the existence and SHA256 hash of a file.
 
    .DESCRIPTION
        The Assert-ScriptFileValidity function checks if a file exists at the
        specified path and validates that its SHA256 hash matches the expected
        value.
 
    .PARAMETER FilePath
        Specifies the path to the file to validate.
 
    .PARAMETER Hash
        Specifies the expected SHA256 hash of the file.
 
    .EXAMPLE
        Assert-ScriptFileValidity -FilePath "C:\path\to\myfile.txt" -Hash "A1B2C3D4E5F6..."
 
        Validates that the file "myfile.txt" exists at the specified path and that
        its SHA256 hash matches the provided hash.
 
    .INPUTS
        None. You cannot pipe input to this function.
 
    .OUTPUTS
        None. The function does not generate any output.
#>

function Assert-ScriptFileValidity
{
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $FilePath,

        [Parameter(Mandatory = $true)]
        [System.String]
        $Hash
    )

    if (-not (Test-Path -Path $FilePath))
    {
        $writeErrorParameters = @{
            Message      = $script:localizedData.Assert_ScriptFileValidity_ScriptFileNotFound -f $FilePath
            Category     = 'ObjectNotFound'
            ErrorId      = 'APV0003' # cSpell: disable-line
            TargetObject = $FilePath
        }

        Write-Error @writeErrorParameters

        return
    }

    $hasExpectedHash = Test-FileHash -Path $FilePath -Algorithm 'SHA256' -ExpectedHash $Hash

    if (-not $hasExpectedHash)
    {
        $writeErrorParameters = @{
            Message      = $script:localizedData.Assert_ScriptFileValidity_HashValidationFailed -f $FilePath, $Hash
            Category     = 'InvalidData'
            ErrorId      = 'APV0004' # cSpell: disable-line
            TargetObject = $FilePath
        }

        Write-Error @writeErrorParameters

        return
    }
}
#EndRegion '.\Private\Assert-ScriptFileValidity.ps1' 71
#Region '.\Private\Get-PatchFileContent.ps1' -1

<#
    .SYNOPSIS
        Converts JSON content to a PowerShell object.
 
    .DESCRIPTION
        The Get-PatchFileContent function converts JSON content to a PowerShell object.
        It takes the JSON content as input and returns the corresponding PowerShell object.
 
    .PARAMETER JsonContent
        Specifies the JSON content to convert.
 
    .EXAMPLE
        $jsonContent = Get-Content -Path "C:\patches\MyModule_1.0.0_patch.json" -Raw
        $patchFileContent = Get-PatchFileContent -JsonContent $jsonContent
 
        Converts the JSON content to a PowerShell object.
 
    .INPUTS
        None. You cannot pipe input to this function.
 
    .OUTPUTS
        System.Object. The function returns the JSON content as a PowerShell object.
#>

function Get-PatchFileContent
{
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $JsonContent
    )

    try
    {
        $patchFileContent = $JsonContent | ConvertFrom-Json -Depth 10 -ErrorAction 'Stop'

        return $patchFileContent
    }
    catch
    {
        $writeErrorParameters = @{
            Message      = $_.Exception.ToString()
            Category     = 'InvalidData'
            ErrorId      = 'GPFC0001' # cSpell: disable-line
            TargetObject = $JsonContent
        }

        Write-Error @writeErrorParameters
    }
}
#EndRegion '.\Private\Get-PatchFileContent.ps1' 51
#Region '.\Private\Get-PatchFileContentFromPath.ps1' -1

<#
    .SYNOPSIS
        Reads patch file content from a specified path.
 
    .DESCRIPTION
        The Get-PatchFileContentFromPath function reads the content of a patch file from a specified path.
        It attempts to fetch the patch file content using Get-Content and returns the content as a JSON object.
 
    .PARAMETER Path
        Specifies the path of the patch file.
 
    .EXAMPLE
        $patchFileContent = Get-PatchFileContentFromPath -Path "C:\patches\MyModule_1.0.0_patch.json"
 
        Reads the content of the patch file located at the specified path.
 
    .INPUTS
        None. You cannot pipe input to this function.
 
    .OUTPUTS
        System.Object. The function returns the content of the patch file as a JSON object.
#>

function Get-PatchFileContentFromPath
{
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Path
    )

    $normalizedPath = $Path -replace '[\\/]', [System.IO.Path]::DirectorySeparatorChar

    if (-not (Test-Path -Path $normalizedPath))
    {
        $writeErrorParameters = @{
            Message      = $script:localizedData.Install_ModulePatch_PatchFilePathNotFound -f $normalizedPath
            Category     = 'ObjectNotFound'
            ErrorId      = 'GPFCFP0001' # cSpell: disable-line
            TargetObject = $normalizedPath
        }

        Write-Error @writeErrorParameters

        return $null
    }

    $jsonContent = Get-Content -Path $normalizedPath -Raw

    $patchFileContent = Get-PatchFileContent -JsonContent $jsonContent -ErrorAction 'Stop'

    return $patchFileContent
}
#EndRegion '.\Private\Get-PatchFileContentFromPath.ps1' 54
#Region '.\Private\Get-PatchFileContentFromURI.ps1' -1

<#
    .SYNOPSIS
        Reads patch file content from a specified URI.
 
    .DESCRIPTION
        The Get-PatchFileContentFromURI function reads the content of a patch file from a specified URI.
        It attempts to fetch the patch file content using Invoke-RestMethod and returns the content as a JSON object.
 
    .PARAMETER URI
        Specifies the URI of the patch file.
 
    .EXAMPLE
        $patchFileContent = Get-PatchFileContentFromURI -URI "https://gist.githubusercontent.com/user/gistid/raw/MyModule_1.0.0_patch.json"
 
        Reads the content of the patch file located at the specified URI.
 
    .INPUTS
        None. You cannot pipe input to this function.
 
    .OUTPUTS
        System.Object. The function returns the content of the patch file as a JSON object.
#>

function Get-PatchFileContentFromUri
{
    param
    (
        [Parameter(Mandatory = $true)]
        [System.Uri]
        $Uri
    )

    try
    {
        $response = Invoke-WebRequest -Uri $Uri -UseBasicParsing -ErrorAction 'Stop'
    }
    catch
    {
        $writeErrorParameters = @{
            Message      = $_.Exception.ToString()
            Category     = 'ConnectionError'
            ErrorId      = 'GPFCFU0001' # cSpell: disable-line
            TargetObject = $Uri
        }

        Write-Error @writeErrorParameters

        return $null
    }

    $patchFileContent = Get-PatchFileContent -JsonContent $response.Content -ErrorAction 'Stop'

    return $patchFileContent
}
#EndRegion '.\Private\Get-PatchFileContentFromURI.ps1' 54
#Region '.\Private\Merge-Patch.ps1' -1

<#
    .SYNOPSIS
        Applies a patch to a file.
 
    .DESCRIPTION
        The `Merge-Patch` function applies a patch to a file based on the provided
        patch entry. It reads the content of the file, applies the patch, and writes
        the patched content back to the file.
 
    .PARAMETER FilePath
        Specifies the path to the file to be patched.
 
    .PARAMETER PatchEntry
        Specifies the patch entry to apply. The patch entry should contain the
        `StartOffset`, `EndOffset`, and `PatchContent` properties.
 
    .EXAMPLE
        Merge-Patch -FilePath "C:\path\to\myfile.txt" -PatchEntry $patchEntry
 
        Applies the patch specified in `$patchEntry` to the file "C:\path\to\myfile.txt".
 
    .INPUTS
        None. You cannot pipe input to this function.
 
    .OUTPUTS
        None. The function does not generate any output.
#>

function Merge-Patch
{
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $FilePath,

        [Parameter(Mandatory = $true)]
        [System.Object]
        $PatchEntry
    )

    # TODO: Get-Content and Set-Content should be moved out to Install-ModulePatch so that we read and write once, and also can rollback on error.
    $scriptContent = Get-Content -Path $FilePath -Raw -ErrorAction 'Stop'

    $startOffset = $PatchEntry.StartOffset
    $endOffset = $PatchEntry.EndOffset

    if ($startOffset -lt 0 -or $endOffset -gt $scriptContent.Length -or $startOffset -ge $endOffset)
    {
        $writeErrorParameters = @{
            Message      = $script:localizedData.Merge_Patch_InvalidStartOrEndOffset -f $startOffset, $endOffset, $FilePath
            Category     = 'InvalidArgument'
            ErrorId      = 'MP0001' # cSpell: disable-line
            TargetObject = $FilePath
        }

        Write-Error @writeErrorParameters

        return
    }

    $patchedContent = $scriptContent.Substring(0, $startOffset) + $PatchEntry.PatchContent + $scriptContent.Substring($endOffset)

    Set-Content -Path $FilePath -Value $patchedContent -ErrorAction 'Stop'

    Write-Debug -Message ($script:localizedData.Merge_Patch_SuccessfullyPatched -f $FilePath, $PatchEntry.StartOffset)
}
#EndRegion '.\Private\Merge-Patch.ps1' 67
#Region '.\Public\ConvertTo-AnsiSequence.ps1' -1

<#
    .SYNOPSIS
        Converts a string value to an ANSI escape sequence.
 
    .DESCRIPTION
        The ConvertTo-AnsiSequence command converts a string value to an ANSI escape
        sequence. It is used to format text with ANSI escape codes for color and
        formatting in console output.
 
    .PARAMETER Value
        The string value to be converted to an ANSI escape sequence.
 
    .INPUTS
        System.String
 
    .OUTPUTS
        System.String
 
    .EXAMPLE
        ConvertTo-AnsiSequence -Value "31"
 
        This example converts the string value "31" to its ANSI escape sequence.
 
    .NOTES
        This function supports ANSI escape codes for color and formatting. It checks
        if the input string matches the pattern of an ANSI escape sequence and
        converts it accordingly. If the input string does not match the pattern,
        it is returned as is.
#>

function ConvertTo-AnsiSequence
{
    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(Mandatory = $true, Position = 0)]
        [AllowEmptyString()]
        [AllowNull()]
        [System.String]
        $Value
    )

    if ($Value)
    {
        if ($Value -match "^(?:`e)?\[?([0-9;]+)m?$")
        {
            $Value = "`e[" + $Matches[1] + 'm'
        }
    }

    return $Value
}
#EndRegion '.\Public\ConvertTo-AnsiSequence.ps1' 53
#Region '.\Public\ConvertTo-DifferenceString.ps1' -1

<#
    .SYNOPSIS
        Converts two strings into a difference string, highlighting the differences
        between them.
 
    .DESCRIPTION
        The ConvertTo-DifferenceString command takes two strings, a reference string
        and a difference string, and converts them into a difference string that
        highlights the differences between the two strings. The function compares
        the byte values of each character in the strings and outputs the differences
        in a formatted manner. It supports customizing the indicators, labels, colors,
        and encoding type.
 
    .PARAMETER ReferenceString
        Specifies the reference string to compare against.
 
    .PARAMETER DifferenceString
        Specifies the difference string to compare.
 
    .PARAMETER EqualIndicator
        Specifies the indicator to use for equal bytes. Default is '=='.
 
    .PARAMETER NotEqualIndicator
        Specifies the indicator to use for not equal bytes. Default is '!='.
 
    .PARAMETER HighlightStart
        Specifies the ANSI escape sequence to start highlighting. Default is
        "[31m" (red color).
 
    .PARAMETER HighlightEnd
        Specifies the ANSI escape sequence to end highlighting. Default is
        "[0m" (reset color).
 
    .PARAMETER ReferenceLabel
        Specifies the label for the reference string. Default is 'Expected:'.
 
    .PARAMETER DifferenceLabel
        Specifies the label for the difference string. Default is 'But was:'.
 
    .PARAMETER NoColumnHeader
        Specifies whether to exclude the column header from the output.
 
    .PARAMETER NoLabels
        Specifies whether to exclude the labels from the output.
 
    .PARAMETER ReferenceLabelAnsi
        Specifies the ANSI escape sequence to apply to the reference label.
 
    .PARAMETER DifferenceLabelAnsi
        Specifies the ANSI escape sequence to apply to the difference label.
 
    .PARAMETER ColumnHeaderAnsi
        Specifies the ANSI escape sequence to apply to the column header.
 
    .PARAMETER ColumnHeaderResetAnsi
        Specifies the ANSI escape sequence to reset the column header.
 
    .PARAMETER EncodingType
        Specifies the encoding type to use for converting the strings to byte arrays.
        Default is 'UTF8'.
 
    .PARAMETER NoHexOutput
        Specifies whether to omit the hex columns and output only the character
        groups.
 
    .EXAMPLE
        PS> ConvertTo-DifferenceString -ReferenceString 'Hello' -DifferenceString 'Hallo'
 
        Expected: But was:
        ---------------------------------------------------------------- --------------------------------------------------------
        Bytes Ascii Bytes Ascii
        ----- ----- ----- -----
        48 65 6C 6C 6F Hello == 48 61 6C 6C 6F Hallo
 
        Converts the reference string 'Hello' and the difference string 'Hallo'
        into a difference string, highlighting the differences.
 
    .INPUTS
        None.
 
    .OUTPUTS
        System.String.
#>

function ConvertTo-DifferenceString
{
    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [System.String]
        $ReferenceString,

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [System.String]
        $DifferenceString,

        [Parameter()]
        [ValidateLength(0, 2)]
        [System.String]
        $EqualIndicator = '==',

        [Parameter()]
        [ValidateLength(0, 2)]
        [System.String]
        $NotEqualIndicator = '!=',

        [Parameter()]
        [System.String]
        $HighlightStart = '[31m', # Default to red color

        [Parameter()]
        [System.String]
        $HighlightEnd = '[0m', # Default to reset color

        [Parameter()]
        [System.String]
        $ReferenceLabel = 'Expected:',

        [Parameter()]
        [System.String]
        $DifferenceLabel = 'But was:',

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $NoColumnHeader,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $NoLabels,

        [Parameter()]
        [System.String]
        $ReferenceLabelAnsi = '',

        [Parameter()]
        [System.String]
        $DifferenceLabelAnsi = '',

        [Parameter()]
        [System.String]
        $ColumnHeaderAnsi = '',

        [Parameter()]
        [System.String]
        $ColumnHeaderResetAnsi = '',

        [Parameter()]
        [ValidateSet('ASCII', 'BigEndianUnicode', 'Default', 'Unicode', 'UTF32', 'UTF7', 'UTF8')]
        [System.String]
        $EncodingType = 'UTF8',

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $NoHexOutput
    )

    # Get actual ANSI escape sequences if they weren't.
    $HighlightStart = ConvertTo-AnsiSequence -Value $HighlightStart
    $HighlightEnd = ConvertTo-AnsiSequence -Value $HighlightEnd
    $ReferenceLabelAnsi = ConvertTo-AnsiSequence -Value $ReferenceLabelAnsi
    $DifferenceLabelAnsi = ConvertTo-AnsiSequence -Value $DifferenceLabelAnsi
    $ColumnHeaderAnsi = ConvertTo-AnsiSequence -Value $ColumnHeaderAnsi
    $ColumnHeaderResetAnsi = ConvertTo-AnsiSequence -Value $ColumnHeaderResetAnsi

    # Pre-pad indicators
    $NotEqualIndicator = $NotEqualIndicator.PadRight(2)
    $EqualIndicator = $EqualIndicator.PadRight(2)

    # Convert the strings to byte arrays using the specified encoding
    $encoding = [System.Text.Encoding]::$EncodingType
    $referenceBytes = $encoding.GetBytes($ReferenceString)
    $differenceBytes = $encoding.GetBytes($DifferenceString)

    # Precompute lengths to avoid repeated property lookups
    $refLength = $referenceBytes.Length
    $diffLength = $differenceBytes.Length
    $maxLength = [Math]::Max($refLength, $diffLength)

    # Use a larger group size when hex output is disabled
    $groupSize = if ($NoHexOutput)
    {
        64
    }
    else
    {
        16
    }

    # Output the labels if NoLabels is not specified
    if (-not $NoLabels)
    {
        "$($ReferenceLabelAnsi)$($ReferenceLabel)$($HighlightEnd) $($DifferenceLabelAnsi)$($DifferenceLabel)$($HighlightEnd)"
        ('-' * 64) + (' ' * 8) + ('-' * 64) # Output a line of dashes under the labels
    }

    # Output the column header once with dashes underline if NoColumnHeader is not specified
    if (-not $NoColumnHeader)
    {
        if ($NoHexOutput)
        {
            # New header when hex column is disabled
            "$($ColumnHeaderAnsi)Ascii$(' ' * 67)Ascii$($ColumnHeaderResetAnsi)"
            "$($ColumnHeaderAnsi)" + ('-' * 64) + (' ' * 8) + ('-' * 64) + "$($ColumnHeaderResetAnsi)"
        }
        else
        {
            "$($ColumnHeaderAnsi)Bytes Ascii Bytes Ascii$($ColumnHeaderResetAnsi)"
            "$($ColumnHeaderAnsi)----- ----- ----- -----$($ColumnHeaderResetAnsi)"
        }
    }

    if ($NoHexOutput)
    {
        # Initialize arrays for ascii only
        $refCharArray = @()
        $diffCharArray = @()
    }
    else
    {
        # Initialize arrays for hex and ascii
        $refHexArray = @()
        $refCharArray = @()
        $diffHexArray = @()
        $diffCharArray = @()
    }

    $currentGroupHighlighted = $false

    # Loop through each byte in the arrays up to the maximum length
    for ($i = 0; $i -lt $maxLength; $i++)
    {
        # At the beginning of a new group, reset the highlight flag
        if (($i % $groupSize) -eq 0)
        {
            $currentGroupHighlighted = $false

            if (-not $NoHexOutput)
            {
                $refHexArray = @()
                $refCharArray = @()
                $diffHexArray = @()
                $diffCharArray = @()
            }
            else
            {
                $refCharArray = @()
                $diffCharArray = @()
            }
        }

        # Get the byte and corresponding values for the reference string
        if ($i -lt $refLength)
        {
            $refByte = $referenceBytes[$i]
            $refHex = '{0:X2}' -f $refByte
            $refChar = if ($refByte -lt 32)
            {
                [System.Char] ($refByte + 0x2400)
            }
            elseif ($refByte -eq 127)
            {
                [System.Char] 0x2421
            }
            else
            {
                [System.Char] $refByte
            }
        }
        else
        {
            $refByte = -1
            $refHex = ' '
            $refChar = ' '
        }

        # Get the byte and corresponding values for the difference string
        if ($i -lt $diffLength)
        {
            $diffByte = $differenceBytes[$i]
            $diffHex = '{0:X2}' -f $diffByte
            $diffChar = if ($diffByte -lt 32)
            {
                [System.Char] ($diffByte + 0x2400)
            }
            elseif ($diffByte -eq 127)
            {
                [System.Char] 0x2421
            }
            else
            {
                [System.Char] $diffByte
            }
        }
        else
        {
            $diffByte = -1
            $diffHex = ' '
            $diffChar = ' '
        }

        # Highlight differences and set the group's flag if any difference is found
        if ($refByte -ne $diffByte)
        {
            $refHex = "$HighlightStart$refHex$HighlightEnd"
            $refChar = "$HighlightStart$refChar$HighlightEnd"
            $diffHex = "$HighlightStart$diffHex$HighlightEnd"
            $diffChar = "$HighlightStart$diffChar$HighlightEnd"

            $currentGroupHighlighted = $true
        }

        if (-not $NoHexOutput)
        {
            # Add to arrays
            $refHexArray += $refHex
            $refCharArray += $refChar
            $diffHexArray += $diffHex
            $diffCharArray += $diffChar
        }
        else
        {
            $refCharArray += $refChar
            $diffCharArray += $diffChar
        }

        # When a group is completed or at the end...
        if ((($i + 1) % $groupSize) -eq 0 -or $i -eq $maxLength - 1)
        {
            if (-not $NoHexOutput)
            {
                # Pad arrays to ensure they have the correct number of elements using for loops
                for ($j = $refHexArray.Count; $j -lt $groupSize; $j++)
                {
                    $refHexArray += ' '
                }

                for ($j = $refCharArray.Count; $j -lt $groupSize; $j++)
                {
                    $refCharArray += ' '
                }

                for ($j = $diffHexArray.Count; $j -lt $groupSize; $j++)
                {
                    $diffHexArray += ' '
                }

                for ($j = $diffCharArray.Count; $j -lt $groupSize; $j++)
                {
                    $diffCharArray += ' '
                }

                $refHexLine = $refHexArray -join ' '
                $refCharLine = $refCharArray -join ''
                $diffHexLine = $diffHexArray -join ' '
                $diffCharLine = $diffCharArray -join ''

                # Use the precomputed flag for this group
                $indicator = if ($currentGroupHighlighted)
                {
                    $NotEqualIndicator
                }
                else
                {
                    $EqualIndicator
                }

                # Output the results in the specified format
                '{0} {1} {2} {3} {4}' -f $refHexLine, $refCharLine, $indicator, $diffHexLine, $diffCharLine
            }
            else
            {
                for ($j = $refCharArray.Count; $j -lt $groupSize; $j++)
                {
                    $refCharArray += ' '
                }
                for ($j = $diffCharArray.Count; $j -lt $groupSize; $j++)
                {
                    $diffCharArray += ' '
                }
                $refChars = $refCharArray -join ''
                $diffChars = $diffCharArray -join ''
                $indicator = if ($currentGroupHighlighted)
                {
                    $NotEqualIndicator
                }
                else
                {
                    $EqualIndicator
                }
                '{0} {1} {2}' -f $refChars, $indicator, $diffChars
            }
        }
    }
}
#EndRegion '.\Public\ConvertTo-DifferenceString.ps1' 398
#Region '.\Public\ConvertTo-RelativePath.ps1' -1

<#
    .SYNOPSIS
        Converts an absolute path to a relative path.
 
    .DESCRIPTION
        The ConvertTo-RelativePath command takes an absolute path and converts it
        to a relative path based on the current location. If the absolute path
        starts with the current location, the function removes the current location
        from the beginning of the path and inserts a '.' to indicate the relative path.
 
    .PARAMETER AbsolutePath
        Specifies the absolute path that needs to be converted to a relative path.
 
    .PARAMETER CurrentLocation
        Specifies the current location used as a reference for converting the absolute
        path to a relative path. If not specified, the function uses the current
        location obtained from Get-Location.
 
    .EXAMPLE
        ConvertTo-RelativePath -AbsolutePath '/source/Viscalyx.Common/source/Public/ConvertTo-RelativePath.ps1' -CurrentLocation "/source/Viscalyx.Common"
 
        Returns "./source/Public/ConvertTo-RelativePath.ps1", which is the
        relative path of the given absolute path based on the current location.
 
    .INPUTS
        [System.String]
 
    .OUTPUTS
        [System.String]
#>

function ConvertTo-RelativePath
{
    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)]
        [System.String]
        $AbsolutePath,

        [Parameter(Position = 1)]
        [System.String]
        $CurrentLocation
    )

    begin
    {
        if (-not $PSBoundParameters.ContainsKey('CurrentLocation'))
        {
            $CurrentLocation = (Get-Location).Path
        }
    }

    process
    {
        $relativePath = $AbsolutePath

        if ($relativePath.StartsWith($CurrentLocation))
        {
            $relativePath = $relativePath.Substring($CurrentLocation.Length).Insert(0, '.')
        }

        return $relativePath
    }
}
#EndRegion '.\Public\ConvertTo-RelativePath.ps1' 66
#Region '.\Public\Get-ModuleByVersion.ps1' -1

<#
    .SYNOPSIS
        Gets a specific version of a PowerShell module from the list of available
        modules.
 
    .DESCRIPTION
        The Get-ModuleByVersion function retrieves a specific version of a PowerShell
        module from the list of available modules based on the module name and version
        provided. If the module with the specified version is found, it returns the
        module object; otherwise, it returns $null.
 
    .PARAMETER Name
        The name of the module to retrieve.
 
    .PARAMETER Version
        The version of the module to retrieve.
 
    .EXAMPLE
        Get-ModuleByVersion -Name 'MyModule' -Version '1.0.0'
 
        Retrieves the module 'MyModule' with version '1.0.0' and returns the module
        object.
 
    .INPUTS
        None. You cannot pipe input to this function.
 
    .OUTPUTS
        [PSModuleInfo] or $null
 
        The function returns a PSModuleInfo object if the module with the specified
        name and version is found; otherwise, it returns $null.
#>

function Get-ModuleByVersion
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Name,

        [Parameter(Mandatory = $true)]
        [System.String]
        $Version
    )

    # Get all the modules with the specified name
    $availableModules = Get-Module -Name $Name -ListAvailable

    $foundModule = $null

    # Loop through the available modules and check the version
    foreach ($module in $availableModules)
    {
        $availableModuleVersion = Get-ModuleVersion -Module $module

        if ($Version -eq $availableModuleVersion)
        {
            $foundModule = $module

            break
        }
    }

    return $foundModule
}
#EndRegion '.\Public\Get-ModuleByVersion.ps1' 67
#Region '.\Public\Get-ModuleFileSha.ps1' -1

<#
    .SYNOPSIS
        Retrieves the SHA256 hash of all ps1, psm1, and psd1 files in a PowerShell module.
 
    .DESCRIPTION
        The Get-ModuleFileSha function retrieves the SHA256 hash of all ps1, psm1, and
        psd1 files in a PowerShell module. It takes a module name, module version, and
        Path as parameters. The function loops through all the ps1, psm1, and psd1 files
        that the module consists of and outputs each file with its corresponding SHA
        using the SHA256 algorithm.
 
    .PARAMETER Name
        Specifies the name of the module.
 
    .PARAMETER Version
        Specifies the version of the module.
 
    .PARAMETER Path
        Specifies the path to the root of module for a specific version.
 
    .EXAMPLE
        Get-ModuleFileSha -Name 'MyModule' -Version '1.0.0'
 
        Retrieves the SHA256 hash of all ps1, psm1, and psd1 files in the 'MyModule'
        module with version '1.0.0'.
 
    .EXAMPLE
        Get-ModuleFileSha -Path 'C:\Modules\MyModule\1.0.0'
 
        Retrieves the SHA256 hash of all ps1, psm1, and psd1 files in the module located
        at 'C:\Modules\MyModule'.
 
    .INPUTS
        None. You cannot pipe input to this function.
 
    .OUTPUTS
        System.Object. The function returns a list of files with their corresponding SHA256 hash.
#>

function Get-ModuleFileSha
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true, ParameterSetName = 'ModuleName')]
        [System.String]
        $Name,

        [Parameter(ParameterSetName = 'ModuleName')]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $Version,

        [Parameter(Mandatory = $true, ParameterSetName = 'Path')]
        [ValidateScript({
            if (-not (Test-Path -Path $_ -PathType Container))
            {
                throw $script:localizedData.Get_ModuleFileSha_PathMustBeDirectory
            }

            return $true
        })]
        [System.String]
        $Path
    )

    $modulePath = if ($PSCmdlet.ParameterSetName -eq 'ModuleName')
    {
        $availableModule = Get-Module -Name $Name -ListAvailable

        if ($Version)
        {
            $filteredModule = $null

            foreach ($currentModule in $availableModule)
            {
                $moduleVersion = Get-ModuleVersion -Module $currentModule

                if ($moduleVersion -eq $Version)
                {
                    $filteredModule = $currentModule

                    break
                }
            }

            $availableModule = $filteredModule
        }

        if (-not $availableModule)
        {
            if ($Version)
            {
                $errorMessage = $script:localizedData.Get_ModuleFileSha_MissingModuleVersion -f $Name, $Version
            }
            else
            {
                $errorMessage = $script:localizedData.Get_ModuleFileSha_MissingModule -f $Name
            }

            $writeErrorParameters = @{
                Message      = $errorMessage
                Category     = 'ObjectNotFound'
                ErrorId      = 'GMFS0001' # cSpell: disable-line
                TargetObject = $Name
            }

            Write-Error @writeErrorParameters

            return
        }

        # Will return multiple paths if more than one module is found.
        $availableModule.ModuleBase
    }
    else
    {
        [System.IO.Path]::GetFullPath($Path)
    }

    $fileExtensions = @('*.ps1', '*.psm1', '*.psd1')

    foreach ($path in $modulePath)
    {
        $moduleFiles = Get-ChildItem -Path $path -Recurse -Include $fileExtensions

        foreach ($file in $moduleFiles)
        {
            $fileHash = Get-FileHash -Path $file.FullName -Algorithm SHA256

            [PSCustomObject]@{
                # Output the relative path
                ModuleBase = $path
                RelativePath = $file.FullName.Substring($path.Length + 1)
                FileName = $file.Name
                HashSHA  = $fileHash.Hash
            }
        }
    }
}
#EndRegion '.\Public\Get-ModuleFileSha.ps1' 140
#Region '.\Public\Get-ModuleVersion.ps1' -1

<#
    .SYNOPSIS
        Retrieves the version of a PowerShell module.
 
    .DESCRIPTION
        The Get-ModuleVersion command retrieves the version of a PowerShell module.
        It accepts a module name or a PSModuleInfo object as input and returns the
        module version as a string.
 
    .PARAMETER Module
        Specifies the module for which to retrieve the version. This can be either
        a module name or a PSModuleInfo object.
 
    .EXAMPLE
        Get-ModuleVersion -Module 'MyModule'
 
        Retrieves the version of the module named "MyModule".
 
    .EXAMPLE
        $moduleInfo = Get-Module -Name 'MyModule'
        Get-ModuleVersion -Module $moduleInfo
 
        Retrieves the version of the module specified by the PSModuleInfo object $moduleInfo.
 
    .INPUTS
        [System.Object]
 
        Accepts a module name or a PSModuleInfo object as input.
 
    .OUTPUTS
        [System.String]
 
        Returns the module version as a string.
#>

function Get-ModuleVersion
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0)]
        [System.Object]
        $Module
    )

    process
    {
        $moduleInfo = $null
        $moduleVersion = $null

        if ($Module -is [System.String])
        {
            $moduleInfo = Get-Module -Name $Module -ErrorAction 'Stop'

            if (-not $moduleInfo)
            {
                Write-Error -Message "Cannot find the module '$Module'. Make sure it is loaded into the session."
            }
        }
        elseif ($Module -is [System.Management.Automation.PSModuleInfo])
        {
            $moduleInfo = $Module
        }
        else
        {
            Write-Error -Message "Invalid parameter type. The parameter 'Module' must be either a string or a PSModuleInfo object."
        }

        if ($moduleInfo)
        {
            $moduleVersion = $moduleInfo.Version.ToString()

            $previewReleaseTag = $moduleInfo.PrivateData.PSData.Prerelease

            if ($previewReleaseTag)
            {
                $moduleVersion += '-{0}' -f $previewReleaseTag
            }
        }

        return $moduleVersion
    }
}
#EndRegion '.\Public\Get-ModuleVersion.ps1' 83
#Region '.\Public\Get-NumericalSequence.ps1' -1

<#
    .SYNOPSIS
        Retrieves numerical sequences from a given set of numbers.
 
    .DESCRIPTION
        The Get-NumericalSequence command retrieves numerical sequences from a given
        set of numbers. It identifies consecutive numbers and groups them into ranges.
 
    .PARAMETER Number
        Specifies the number to be processed. This parameter is mandatory and can be
        provided via the pipeline.
 
    .OUTPUTS
        System.Object[]
 
        An array of PSCustomObject objects representing the numerical sequences.
        Each object contains the Start and End properties, indicating the start
        and end numbers of a sequence.
 
    .EXAMPLE
        Get-NumericalSequence -Number 1, 2, 3, 5, 6, 7, 10
 
        Returns:
        Start End
        ----- ---
        1 3
        5 7
        10
#>

function Get-NumericalSequence
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [System.Int32]
        $Number
    )

    begin
    {
        $ranges = @()
        $start = $null
        $end = $null
    }

    process
    {
        if ($null -eq $start)
        {
            $start = $Number
            $end = $Number
        }
        elseif ($Number -eq $end + 1)
        {
            $end = $Number
        }
        else
        {
            if ($start -eq $end)
            {
                $end = $null
            }

            $ranges += [PSCustomObject] @{
                Start = $start
                End = $end
            }

            $start = $Number
            $end = $Number
        }
    }

    end
    {
        if ($null -ne $start)
        {
            if ($start -eq $end)
            {
                $end = $null
            }

            $ranges += [PSCustomObject] @{
                Start = $start
                End = $end
            }
        }

        $ranges
    }
}
#EndRegion '.\Public\Get-NumericalSequence.ps1' 93
#Region '.\Public\Get-PSReadLineHistory.ps1' -1

<#
    .SYNOPSIS
        Retrieves the PSReadLine history content.
 
    .DESCRIPTION
        The Get-PSReadLineHistory function retrieves the content of the PSReadLine
        history file. By default, it returns the entire history content, but you
        can specify a pattern to filter the results.
 
    .PARAMETER Pattern
        Specifies a pattern to filter the history content. Only lines matching the
        pattern will be returned.
 
    .EXAMPLE
        Get-PSReadLineHistory
 
        Returns the entire content of the PSReadLine history file.
 
    .EXAMPLE
        Get-PSReadLineHistory -Pattern "git"
 
        Returns only the lines from the PSReadLine history file that contain the word "git".
 
    .INPUTS
        None
 
    .OUTPUTS
        System.String
 
    .NOTES
        This function requires the PSReadLine module to be installed.
 
    .LINK
        https://docs.microsoft.com/en-us/powershell/module/psreadline/
#>

function Get-PSReadLineHistory
{
    [CmdletBinding()]
    param
    (
        [Parameter(Position = 0)]
        [System.String]
        $Pattern
    )

    $historyPath = (Get-PSReadLineOption).HistorySavePath

    $historyContent = Get-Content -Path $historyPath

    if ($Pattern)
    {
        $historyContent = $historyContent |
            Select-Object -SkipLast 1 |
            Select-String -Pattern $Pattern -Raw
    }

    $historyContent
}
#EndRegion '.\Public\Get-PSReadLineHistory.ps1' 59
#Region '.\Public\Get-TextOffset.ps1' -1

<#
    .SYNOPSIS
        Finds the start and end offsets of a given text within a file.
 
    .DESCRIPTION
        Reads the content of a file and searches for the specified text.
        Returns the start and end offsets of the text within the file.
 
    .PARAMETER FilePath
        Specifies the path to the file.
 
    .PARAMETER TextToFind
        Specifies the text to search for within the file.
 
    .EXAMPLE
        Get-TextOffset -FilePath 'C:\path\to\your\script.ps1' -TextToFind 'if ($condition) {'
#>

function Get-TextOffset
{
    [OutputType([PSCustomObject])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $FilePath,

        [Parameter(Mandatory = $true)]
        [System.String]
        $TextToFind
    )

    $scriptContent = (Get-Content -Path $FilePath -Raw -ErrorAction 'Stop') -replace '\r?\n', "`n"

    $TextToFind = $TextToFind -replace '\r?\n', "`n"

    $startIndex = $scriptContent.IndexOf($TextToFind)

    $result = $null

    if ($startIndex -ne -1)
    {
        $endIndex = $startIndex + $TextToFind.Length

        $result = [PSCustomObject] @{
            ScriptFile  = $FilePath
            StartOffset = $startIndex
            EndOffset   = $endIndex
        }
    }
    else
    {
        Write-Warning ($script:localizedData.TextNotFoundWarning -f $TextToFind, $FilePath)
    }

    return $result
}
#EndRegion '.\Public\Get-TextOffset.ps1' 58
#Region '.\Public\Install-ModulePatch.ps1' -1

<#
    .SYNOPSIS
        Applies patches to PowerShell modules based on a patch file.
 
    .DESCRIPTION
        The Install-ModulePatch command reads a patch file, validates its content, and applies patches to PowerShell modules.
        The patch file can be provided as a local file path or a URL. The command verifies the module version and hash,
        and replaces the content according to the patch file. It supports multiple patch entries in a single patch file,
        applying them in descending order of StartOffset.
 
    .PARAMETER Path
        Specifies the path to the patch file.
 
    .PARAMETER Uri
        Specifies the URL of the patch file.
 
    .PARAMETER Force
        Overrides the confirmation dialogs.
 
    .PARAMETER SkipHashValidation
        Skips the hash validation after patching are completed.
 
    .EXAMPLE
        Install-ModulePatch -Path "C:\patches\MyModule_1.0.0_patch.json"
 
        Applies the patches specified in the patch file located at "C:\patches\MyModule_1.0.0_patch.json".
 
    .EXAMPLE
        Install-ModulePatch -Uri "https://gist.githubusercontent.com/user/gistid/raw/MyModule_1.0.0_patch.json"
 
        Applies the patches specified in the patch file located at the specified URL.
 
    .INPUTS
        None. You cannot pipe input to this function.
 
    .OUTPUTS
        None. The function does not generate any output.
#>

function Install-ModulePatch
{
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param
    (
        [Parameter(Mandatory = $true, ParameterSetName = 'Path')]
        [System.String]
        $Path,

        [Parameter(Mandatory = $true, ParameterSetName = 'URI')]
        [System.Uri]
        $Uri,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $Force,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $SkipHashValidation
    )

    if ($Force.IsPresent -and -not $Confirm)
    {
        $ConfirmPreference = 'None'
    }

    $patchLocation = if ($PSCmdlet.ParameterSetName -eq 'Path')
    {
        $Path
    }
    else
    {
        $Uri
    }

    $verboseDescriptionMessage = $script:localizedData.Install_ModulePatch_ShouldProcessVerboseDescription -f $patchLocation
    $verboseWarningMessage = $script:localizedData.Install_ModulePatch_ShouldProcessVerboseWarning -f $patchLocation
    $captionMessage = $script:localizedData.Install_ModulePatch_ShouldProcessCaption

    if ($PSCmdlet.ShouldProcess($verboseDescriptionMessage, $verboseWarningMessage, $captionMessage))
    {
        $patchFileContent = if ($PSCmdlet.ParameterSetName -eq 'Path')
        {
            Write-Debug -Message ($script:localizedData.Install_ModulePatch_Debug_Path -f $Path)

            Get-PatchFileContentFromPath -Path $Path -ErrorAction 'Stop'
        }
        else
        {
            Write-Debug -Message ($script:localizedData.Install_ModulePatch_Debug_URI -f $Uri)

            Get-PatchFileContentFromURI -Uri $Uri -ErrorAction 'Stop'
        }

        Write-Debug -Message ($script:localizedData.Install_ModulePatch_Debug_PatchFileContent -f ($patchFileContent | ConvertTo-Json -Depth 10 -Compress))

        Assert-PatchFile -PatchFileObject $patchFileContent

        $module = Get-ModuleByVersion -Name $patchFileContent.ModuleName -Version $patchFileContent.ModuleVersion

        if (-not $module)
        {
            $writeErrorParameters = @{
                Message      = $script:localizedData.Install_ModulePatch_MissingModule -f $patchFileContent.ModuleName, $patchFileContent.ModuleVersion
                Category     = 'ObjectNotFound'
                ErrorId      = 'IMP0001' # cSpell: disable-line
                TargetObject = $patchFileContent.ModuleName
            }

            Write-Error @writeErrorParameters

            return
        }

        foreach ($moduleFile in $patchFileContent.ModuleFiles)
        {
            $scriptFilePath = Join-Path -Path $module.ModuleBase -ChildPath $moduleFile.ScriptFileName

            Assert-ScriptFileValidity -FilePath $scriptFilePath -Hash $moduleFile.OriginalHashSHA -ErrorAction 'Stop'

            # Initialize progress bar
            $progressId = 1
            $progressActivity = $script:localizedData.Install_ModulePatch_Progress_Activity
            $progressStatus = $script:localizedData.Install_ModulePatch_Progress_Status

            # Show progress bar
            Write-Progress -Id $progressId -Activity $progressActivity -Status $progressStatus -PercentComplete 0

            $patchFileEntries = $moduleFile.FilePatches |
                Sort-Object -Property 'StartOffset' -Descending

            $totalPatches = $patchFileEntries.Count
            $patchCounter = 0

            foreach ($patchEntry in $patchFileEntries)
            {
                $patchCounter++
                $progressPercentComplete = ($patchCounter / $totalPatches) * 100
                $progressCurrentOperation = $script:localizedData.Install_ModulePatch_Progress_CurrentOperation -f $patchFileContent.ModuleName, $patchFileContent.ModuleVersion, $moduleFile.ScriptFileName

                Write-Debug -Message ($script:localizedData.Install_ModulePatch_Debug_PatchEntry -f ($patchEntry | ConvertTo-Json -Depth 10 -Compress))

                # Update progress bar
                Write-Progress -Id $progressId -Activity $progressActivity -Status "$progressStatus - $progressCurrentOperation" -PercentComplete $progressPercentComplete

                Merge-Patch -FilePath $scriptFilePath -PatchEntry $patchEntry -ErrorAction 'Stop'
            }

            # Clear progress bar
            Write-Progress -Id $progressId -Activity $progressActivity -Completed

            # Should we skip hash check?
            if (-not $SkipHashValidation.IsPresent)
            {
                # Verify the SHA256 hash of the patched file
                $hasNewFileHash = Test-FileHash -Path $scriptFilePath -Algorithm 'SHA256' -ExpectedHash $moduleFile.ValidationHashSHA

                if (-not $hasNewFileHash)
                {
                    $writeErrorParameters = @{
                        Message      = $script:localizedData.Install_ModulePatch_Error_HashMismatch -f $scriptFilePath
                        Category     = 'InvalidData'
                        ErrorId      = 'IMP0002' # cSpell: disable-line
                        TargetObject = $patchFileContent.ModuleName
                    }

                    Write-Error @writeErrorParameters

                    return $null
                }
                else
                {
                    Write-Debug -Message ($script:localizedData.Install_ModulePatch_Patch_Success -f $scriptFilePath)
                }
            }
            else
            {
                Write-Debug -Message ($script:localizedData.Install_ModulePatch_Patch_SuccessHashValidationSkipped -f $scriptFilePath)
            }
        }
    }
}
#EndRegion '.\Public\Install-ModulePatch.ps1' 182
#Region '.\Public\Invoke-PesterJob.ps1' -1

<#
    .SYNOPSIS
        Runs Pester tests using a job-based approach.
 
    .DESCRIPTION
        The `Invoke-PesterJob` command runs Pester tests using a job-based approach.
        It allows you to specify various parameters such as the test path, root path,
        module name, output verbosity, code coverage path, and more.
 
        Its primary purpose is to run Pester tests in a separate job to avoid polluting
        the current session with PowerShell classes and project specific assemblies
        which can cause issues when building the project.
 
        It is helpful for projects based on the Sampler project template, but it can
        also be used for other projects.
 
    .PARAMETER Path
        Specifies one or more paths to the Pester test files. If not specified, the
        current location is used. This also has tab completion support. Just write
        part of the test script file name and press tab to get a list of available
        test files matching the input, or if only one file matches, it will be
        auto-completed.
 
    .PARAMETER RootPath
        Specifies the root path for the Pester tests. If not specified, the current
        location is used.
 
    .PARAMETER Tag
        Specifies the tags to filter the Pester tests.
 
    .PARAMETER ModuleName
        Specifies the name of the module to test. If not specified, it will be
        inferred based on the project type.
 
    .PARAMETER Output
        Specifies the output verbosity level. Valid values are 'Normal', 'Detailed',
        'None', 'Diagnostic', and 'Minimal'. Default is 'Detailed'.
 
    .PARAMETER CodeCoveragePath
        Specifies the paths to one or more the code coverage files (script or module
        script files). If not provided the default path for code coverage is the
        content of the built module. This parameter also has tab completion support.
        Just write part of the script file name and press tab to get a list of
        available script files matching the input, or if only one file matches,
        it will be auto-completed.
 
    .PARAMETER SkipCodeCoverage
        Indicates whether to skip code coverage.
 
    .PARAMETER PassThru
        Indicates whether to pass the Pester result object through.
 
    .PARAMETER ShowError
        Indicates whether to display detailed error information. When using this
        to debug a test it is recommended to run as few tests as possible, or just
        the test having issues, to limit the amount of error information displayed.
 
    .PARAMETER SkipRun
        Indicates whether to skip running the tests, this just runs the discovery
        phase. This is useful when you want to see what tests would be run without
        actually running them. To actually make use of this, the PassThru parameter
        should also be specified. Suggest to also use the parameter SkipCodeCoverage.
 
    .PARAMETER BuildScriptPath
        Specifies the path to the build script. If not specified, it defaults to
        'build.ps1' in the root path. This is used to ensure that the test environment
        is configured correctly, for example required modules are available in the
        session. It is also used to ensure to find the specific Pester module used
        by the project.
 
    .PARAMETER BuildScriptParameter
        Specifies a hashtable with the parameters to pass to the build script.
        Defaults to parameter 'Task' with a value of 'noop'.
 
    .EXAMPLE
        $invokePesterJobParameters = @{
            Path = './tests/Unit/DSC_SqlAlias.Tests.ps1'
            CodeCoveragePath = './output/builtModule/SqlServerDsc/0.0.1/DSCResources/DSC_SqlAlias/DSC_SqlAlias.psm1'
        }
        Invoke-PesterJob @invokePesterJobParameters
 
        Runs the Pester test DSC_SqlAlias.Tests.ps1 located in the 'tests/Unit'
        folder. The code coverage is based on the code in the DSC_SqlAlias.psm1
        file.
 
    .EXAMPLE
        $invokePesterJobParameters = @{
            Path = './tests'
            RootPath = 'C:\Projects\MyModule'
            Tag = 'Unit'
            Output = 'Detailed'
            CodeCoveragePath = 'C:\Projects\MyModule\coverage'
        }
        Invoke-PesterJob @invokePesterJobParameters
 
        Runs Pester tests located in the 'tests' directory of the 'C:\Projects\MyModule'
        root path. Only tests with the 'Unit' tag will be executed. Detailed output
        will be displayed, and code coverage will be collected from the
        'C:\Projects\MyModule\coverage' directory.
 
    .EXAMPLE
        $invokePesterJobParameters = @{
            Path = './tests/Unit'
            SkipRun = $true
            SkipCodeCoverage = $true
            PassThru = $true
        }
        Invoke-PesterJob @invokePesterJobParameters
 
        Runs the discovery phase on all the Pester tests files located in the
        'tests/Unit' folder and outputs the Pester result object.
 
    .NOTES
        This function requires the Pester module to be imported. If the module is
        not available, it will attempt to run the build script to ensure the
        required modules are available in the session.
#>

function Invoke-PesterJob
{
    # cSpell: ignore Runspaces
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '', Justification = 'This is a false positive. The script block is used in a job and does not use variables from the parent scope, they are passed in ArgumentList.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('AvoidWriteErrorStop', '', Justification = 'If $PSCmdlet.ThrowTerminatingError were used, the error would not stop any command that would call Invoke-PesterJob.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Argument completers always need the same parameters even if they are not used in the argument completer script.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('DscResource.AnalyzerRules\Measure-Hashtable', '', Justification = 'The hashtable must be format as is to work when documentation is being generated by PlatyPS.')]
    [Alias('ipj')]
    [CmdletBinding()]
    param
    (
        [Parameter(Position = 0)]
        [ArgumentCompleter(
            {
                <#
                    This scriptblock is used to provide tab completion for the Path
                    parameter. The scriptblock could be a command, but then it would
                    need to be a public command. Also, if anything goes wrong in the
                    completer scriptblock, it will just fail silently and not provide
                    any completion results.
                #>

                param
                (
                    [Parameter()]
                    $CommandName,

                    [Parameter()]
                    $ParameterName,

                    [Parameter()]
                    $WordToComplete,

                    [Parameter()]
                    $CommandAst,

                    [Parameter()]
                    $FakeBoundParameters
                )

                # This parameter is from Invoke-PesterJob.
                if (-not $FakeBoundParameters.ContainsKey('RootPath'))
                {
                    $RootPath = (Get-Location).Path
                }

                $testRoot = Join-Path -Path $RootPath -ChildPath 'tests/unit'

                $values = (Get-ChildItem -Path $testRoot -Recurse -Filter '*.tests.ps1' -File).FullName

                foreach ($val in $values)
                {
                    if ($val -like "*$WordToComplete*")
                    {
                        New-Object -Type System.Management.Automation.CompletionResult -ArgumentList @(
                            (ConvertTo-RelativePath -AbsolutePath $val -CurrentLocation $RootPath) # completionText
                            (Split-Path -Path $val -Leaf) -replace '\.[Tt]ests.ps1' # listItemText
                            'ParameterValue' # resultType
                            $val # toolTip
                        )
                    }
                }
            })]
        [ValidateNotNullOrEmpty()]
        [System.String[]]
        $Path = (Get-Location).Path,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $RootPath = (Get-Location).Path,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [System.String[]]
        $Tag,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [System.String]
        $ModuleName,

        [Parameter()]
        [System.String]
        [ValidateSet('Normal', 'Detailed', 'None', 'Diagnostic', 'Minimal')]
        $Output,

        [Parameter(Position = 1)]
        [ArgumentCompleter(
            {
                <#
                    This scriptblock is used to provide tab completion for the
                    CodeCoveragePath parameter. The scriptblock could be a command,
                    but then it would need to be a public command. Also, if anything
                    goes wrong in the completer scriptblock, it will just fail
                    silently and not provide any completion results.
                #>

                param
                (
                    [Parameter()]
                    $CommandName,

                    [Parameter()]
                    $ParameterName,

                    [Parameter()]
                    $WordToComplete,

                    [Parameter()]
                    $CommandAst,

                    [Parameter()]
                    $FakeBoundParameters
                )

                # This parameter is from Invoke-PesterJob.
                if (-not $FakeBoundParameters.ContainsKey('RootPath'))
                {
                    $RootPath = (Get-Location).Path
                }

                # TODO: builtModule should be dynamic.
                $builtModuleCodePath = @(
                    Join-Path -Path $RootPath -ChildPath 'output/builtModule'
                )

                $paths = Get-ChildItem -Path $builtModuleCodePath -Recurse -Include @('*.psm1', '*.ps1') -File -ErrorAction 'SilentlyContinue'

                # Filter out the external Modules directory.
                $values = $paths.FullName -notmatch 'Modules'

                $leafRegex = [regex]::new('([^\\/]+)$')

                foreach ($val in $values)
                {
                    $leaf = $leafRegex.Match($val).Groups[1].Value

                    if ($leaf -like "*$WordToComplete*")
                    {
                        New-Object -Type System.Management.Automation.CompletionResult -ArgumentList @(
                            (ConvertTo-RelativePath -AbsolutePath $val -CurrentLocation $RootPath) # completionText
                            $leaf -replace '\.(ps1|psm1)' # listItemText
                            'ParameterValue' # resultType
                            $val # toolTip
                        )
                    }
                }
            })]
        [ValidateNotNullOrEmpty()]
        [System.String[]]
        $CodeCoveragePath,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $SkipCodeCoverage,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $PassThru,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $ShowError,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $SkipRun,

        [Parameter()]
        [ValidateScript({
                if (-not (Test-Path $_ -PathType 'Leaf'))
                {
                    throw "The file path '$_' does not exist or is a container."
                }

                $true
            })]
        [System.String]
        $BuildScriptPath,

        [Parameter()]
        [System.Collections.Hashtable]
        $BuildScriptParameter = @{ Task = 'noop' }
    )

    if (-not $PSBoundParameters.ContainsKey('BuildScriptPath'))
    {
        $BuildScriptPath = Join-Path -Path $RootPath -ChildPath 'build.ps1'
    }

    $pesterModuleVersion = $null

    do
    {
        $triesCount = 0

        try
        {
            $importedPesterModule = Import-Module -Name 'Pester' -MinimumVersion '4.10.1' -ErrorAction 'Stop' -PassThru

            $pesterModuleVersion = $importedPesterModule | Get-ModuleVersion

            <#
                Assuming that the project is a Sampler project if the Sampler
                module is available in the session. Also assuming that a Sampler
                build task has been run prior to running the command.
            #>

            $isSamplerProject = $null -ne (Get-Module -Name 'Sampler')
        }
        catch
        {
            $triesCount++

            if ($triesCount -eq 1 -and (Test-Path -Path $BuildScriptPath))
            {
                Write-Information -MessageData 'Could not import Pester. Running build script to make sure required modules is available in session. This can take a few seconds.' -InformationAction 'Continue'

                # Redirect all streams to $null, except the error stream (stream 2)
                & $BuildScriptPath @buildScriptParameter 2>&1 4>&1 5>&1 6>&1 > $null
            }
            else
            {
                Write-Error -ErrorRecord $_ -ErrorAction 'Stop'
            }
        }
    } until ($importedPesterModule)

    Write-Information -MessageData ('Using imported Pester v{0}.' -f $pesterModuleVersion) -InformationAction 'Continue'

    if (-not $PSBoundParameters.ContainsKey('ModuleName'))
    {
        if ($isSamplerProject)
        {
            $ModuleName = Get-SamplerProjectName -BuildRoot $RootPath
        }
        else
        {
            $ModuleName = (Get-Item -Path $RootPath).BaseName
        }
    }

    $testResultsPath = Join-Path -Path $RootPath -ChildPath 'output/testResults'

    if (-not $PSBoundParameters.ContainsKey('CodeCoveragePath'))
    {
        # TODO: Should be possible to use default coverage paths for a module that is not based on Sampler.
        if ($isSamplerProject)
        {
            $BuiltModuleBase = Get-SamplerBuiltModuleBase -OutputDirectory "$RootPath/output" -BuiltModuleSubdirectory 'builtModule' -ModuleName $ModuleName

            # TODO: This does not take into account any .ps1 files in the module.
            # TODO: This does not take into account any other .psm1 files in the module, e.g. MOF-based DSC resources.
            $CodeCoveragePath = '{0}/*/{1}.psm1' -f $BuiltModuleBase, $ModuleName
        }
    }

    if ($importedPesterModule.Version.Major -eq 4)
    {
        $pesterConfig = @{
            Script = $Path
        }
    }
    else
    {
        $pesterConfig = New-PesterConfiguration -Hashtable @{
            CodeCoverage = @{
                Enabled        = $true
                Path           = $CodeCoveragePath
                OutputPath     = (Join-Path -Path $testResultsPath -ChildPath 'PesterJob_coverage.xml')
                UseBreakpoints = $false
            }
            Run          = @{
                Path = $Path
            }
        }
    }

    if ($PSBoundParameters.ContainsKey('Output'))
    {
        if ($importedPesterModule.Version.Major -eq 4)
        {
            $pesterConfig.Show = $Output
        }
        else
        {
            $pesterConfig.Output.Verbosity = $Output
        }
    }
    else
    {
        if ($importedPesterModule.Version.Major -eq 4)
        {
            $pesterConfig.Show = 'All'
        }
        else
        {
            $pesterConfig.Output.Verbosity = 'Detailed'
        }
    }

    # Turn off code coverage if the user has specified that they don't want it
    if ($SkipCodeCoverage.IsPresent)
    {
        # Pester v4: By not passing code paths the code coverage is disabled.

        # Pester v5: By setting the Enabled property to false the code coverage is disabled.
        if ($importedPesterModule.Version.Major -ge 5)
        {
            $pesterConfig.CodeCoverage.Enabled = $false
        }
    }
    else
    {
        # Pester 4: By passing code paths the code coverage is enabled.
        if ($importedPesterModule.Version.Major -eq 4)
        {
            $pesterConfig.CodeCoverage = $CodeCoveragePath
        }
    }

    if ($PassThru.IsPresent)
    {
        if ($importedPesterModule.Version.Major -eq 4)
        {
            $pesterConfig.PassThru = $true
        }
        else
        {
            $pesterConfig.Run.PassThru = $true
        }
    }

    if ($SkipRun.IsPresent)
    {
        # This is only supported in Pester v5 or higher.
        if ($importedPesterModule.Version.Major -ge 5)
        {
            $pesterConfig.Run.SkipRun = $true
        }
    }

    if ($PSBoundParameters.ContainsKey('Tag'))
    {
        if ($importedPesterModule.Version.Major -eq 4)
        {
            $pesterConfig.Tag = $Tag
        }
        else
        {
            $pesterConfig.Filter.Tag = $Tag
        }
    }

    Start-Job -ScriptBlock {
        [CmdletBinding()]
        param
        (
            [Parameter(Mandatory = $true, Position = 0)]
            [System.Object]
            $PesterConfiguration,

            [Parameter(Mandatory = $true, Position = 1)]
            [System.Management.Automation.SwitchParameter]
            $ShowError,

            [Parameter(Mandatory = $true, Position = 2)]
            [System.Version]
            $PesterVersion,

            [Parameter(Mandatory = $true, Position = 3)]
            [System.String]
            $BuildScriptPath,

            [Parameter(Mandatory = $true, Position = 4)]
            [System.Collections.Hashtable]
            $BuildScriptParameter
        )

        Write-Information -MessageData 'Running build task ''noop'' inside the job to setup the test pipeline.' -InformationAction 'Continue'

        & $BuildScriptPath @buildScriptParameter

        if ($ShowError.IsPresent)
        {
            $Error.Clear()
            $ErrorView = 'DetailedView'
        }

        if ($PesterVersion.Major -eq 4)
        {
            Invoke-Pester @PesterConfiguration
        }
        else
        {
            Invoke-Pester -Configuration $PesterConfiguration
        }

        if ($ShowError.IsPresent)
        {
            'Error count: {0}' -f $Error.Count
            $Error | Out-String
        }
    } -ArgumentList @(
        $pesterConfig
        $ShowError.IsPresent
        $importedPesterModule.Version
        $BuildScriptPath
        $BuildScriptParameter
    ) |
        Receive-Job -AutoRemoveJob -Wait
}
#EndRegion '.\Public\Invoke-PesterJob.ps1' 528
#Region '.\Public\New-SamplerGitHubReleaseTag.ps1' -1

<#
    .SYNOPSIS
        Creates a new GitHub release tag for the Sampler project.
 
    .DESCRIPTION
        The New-SamplerGitHubReleaseTag function creates a new release tag for the
        Sampler project on GitHub. It performs the following steps:
 
        1. Checks if the remote specified in $UpstreamRemoteName exists locally and throws an error if it doesn't.
        2. Fetches the $DefaultBranchName branch from the $UpstreamRemoteName remote and throws an error if it doesn't exist.
        3. Checks out the $DefaultBranchName branch.
        4. Fetches the $DefaultBranchName branch from the $UpstreamRemoteName remote.
        5. Rebases the local $DefaultBranchName branch with the $UpstreamRemoteName/$DefaultBranchName branch.
        6. Gets the last commit ID of the $DefaultBranchName branch.
        7. Fetches tags from the $UpstreamRemoteName remote.
        8. If no release tag is specified, it checks if there are any tags in the local repository and selects the latest preview tag.
        9. Creates a new tag with the specified release tag or based on the latest preview tag.
        10. Optionally pushes the tag to the $UpstreamRemoteName remote.
        11. Switches back to the previous branch if requested.
 
    .PARAMETER DefaultBranchName
        Specifies the name of the default branch. Default value is 'main'.
 
    .PARAMETER UpstreamRemoteName
        Specifies the name of the upstream remote. Default value is 'origin'.
 
    .PARAMETER ReleaseTag
        Specifies the release tag to create. Must be in the format 'vX.X.X'. If
        not specified, the latest preview tag will be used.
 
    .PARAMETER SwitchBackToPreviousBranch
        Specifies that the command should switches back to the previous branch after
        creating the release tag.
 
    .PARAMETER Force
        Specifies that the command should run without prompting for confirmation.
 
    .PARAMETER PushTag
        Specifies that the tag should also be pushed to the upstream remote after
        creating it. This will always ask for confirmation before pushing the tag,
        unless Force is also specified.
 
    .EXAMPLE
        New-SamplerGitHubReleaseTag -ReleaseTag 'v1.0.0' -PushTag
 
        Creates a new release tag with the specified tag 'v1.0.0' and pushes it
        to the 'origin' remote.
 
    .EXAMPLE
        New-SamplerGitHubReleaseTag -SwitchBackToPreviousBranch
 
        Creates a new release tag and switches back to the previous branch.
 
    .NOTES
        This function requires Git to be installed and accessible from the command
        line.
#>

function New-SamplerGitHubReleaseTag
{
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param
    (
        [Parameter()]
        [System.String]
        $DefaultBranchName = 'main',

        [Parameter()]
        [System.String]
        $UpstreamRemoteName = 'origin',

        [Parameter()]
        [System.String]
        [ValidatePattern('^v\d+\.\d+\.\d+$')]
        $ReleaseTag,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $SwitchBackToPreviousBranch,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $Force,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $PushTag
    )

    if ($Force.IsPresent -and -not $Confirm)
    {
        $ConfirmPreference = 'None'
    }

    # Check if the remote specified in $UpstreamRemoteName exists locally and throw an error if it doesn't.
    $remoteExists = git remote | Where-Object -FilterScript { $_ -eq $UpstreamRemoteName }

    if (-not $remoteExists)
    {
        $PSCmdlet.ThrowTerminatingError(
            [System.Management.Automation.ErrorRecord]::new(
                ($script:localizedData.New_SamplerGitHubReleaseTag_RemoteMissing -f $UpstreamRemoteName),
                'NSGRT0001', # cspell: disable-line
                [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                $DatabaseName
            )
        )
    }

    $verboseDescriptionMessage = $script:localizedData.New_SamplerGitHubReleaseTag_FetchUpstream_ShouldProcessVerboseDescription -f $DefaultBranchName, $UpstreamRemoteName
    $verboseWarningMessage = $script:localizedData.New_SamplerGitHubReleaseTag_FetchUpstream_ShouldProcessVerboseWarning -f $DefaultBranchName, $UpstreamRemoteName
    $captionMessage = $script:localizedData.New_SamplerGitHubReleaseTag_FetchUpstream_ShouldProcessCaption

    if ($PSCmdlet.ShouldProcess($verboseDescriptionMessage, $verboseWarningMessage, $captionMessage))
    {
        # Fetch $DefaultBranchName from upstream and throw an error if it doesn't exist.
        git fetch $UpstreamRemoteName $DefaultBranchName

        if ($LASTEXITCODE -ne 0) # cSpell: ignore LASTEXITCODE
        {
            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    ($script:localizedData.New_SamplerGitHubReleaseTag_FailedFetchBranchFromRemote -f $DefaultBranchName, $UpstreamRemoteName),
                    'NSGRT0002', # cspell: disable-line
                    [System.Management.Automation.ErrorCategory]::InvalidOperation,
                    $DatabaseName
                )
            )
        }
    }

    if ($SwitchBackToPreviousBranch.IsPresent)
    {
        $currentLocalBranchName = git rev-parse --abbrev-ref HEAD

        if ($LASTEXITCODE -ne 0)
        {
            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    $script:localizedData.New_SamplerGitHubReleaseTag_FailedGetLocalBranchName,
                    'NSGRT0003', # cspell: disable-line
                    [System.Management.Automation.ErrorCategory]::InvalidOperation,
                    $DatabaseName
                )
            )
        }
    }

    $continueProcessing = $true
    $errorMessage = $null

    $verboseDescriptionMessage = $script:localizedData.New_SamplerGitHubReleaseTag_Rebase_ShouldProcessVerboseDescription -f $DefaultBranchName, $UpstreamRemoteName
    $verboseWarningMessage = $script:localizedData.New_SamplerGitHubReleaseTag_Rebase_ShouldProcessVerboseWarning -f $DefaultBranchName
    $captionMessage = $script:localizedData.New_SamplerGitHubReleaseTag_Rebase_ShouldProcessCaption

    if ($PSCmdlet.ShouldProcess($verboseDescriptionMessage, $verboseWarningMessage, $captionMessage))
    {
        git checkout $DefaultBranchName

        if ($LASTEXITCODE -ne 0)
        {
            $continueProcessing = $false
            $errorMessage = $script:localizedData.New_SamplerGitHubReleaseTag_FailedCheckoutLocalBranch -f $DefaultBranchName
            $errorCode = 'NSGRT0004' # cspell: disable-line
        }

        $switchedToDefaultBranch = $true

        if ($continueProcessing)
        {
            git rebase $UpstreamRemoteName/$DefaultBranchName

            if ($LASTEXITCODE -ne 0)
            {
                $continueProcessing = $false
                $errorMessage = $script:localizedData.New_SamplerGitHubReleaseTag_FailedRebaseLocalDefaultBranch -f $DefaultBranchName, $UpstreamRemoteName
                $errorCode = 'NSGRT0005' # cspell: disable-line
            }

            if ($continueProcessing)
            {
                $headCommitId = git rev-parse HEAD

                if ($LASTEXITCODE -ne 0)
                {
                    $continueProcessing = $false
                    $errorMessage = $script:localizedData.New_SamplerGitHubReleaseTag_FailedGetLastCommitId -f $DefaultBranchName
                    $errorCode = 'NSGRT0006' # cspell: disable-line
                }
            }
        }

        if (-not $continueProcessing)
        {
            # If something failed, revert back to the previous branch if requested.
            if ($SwitchBackToPreviousBranch.IsPresent -and $switchedToDefaultBranch)
            {
                git checkout $currentLocalBranchName
            }

            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    $errorMessage,
                    $errorCode, # cspell: disable-line
                    [System.Management.Automation.ErrorCategory]::InvalidOperation,
                    $DatabaseName
                )
            )
        }
    }

    $verboseDescriptionMessage = $script:localizedData.New_SamplerGitHubReleaseTag_UpstreamTags_ShouldProcessVerboseDescription -f $UpstreamRemoteName
    $verboseWarningMessage = $script:localizedData.New_SamplerGitHubReleaseTag_UpstreamTags_ShouldProcessVerboseWarning -f $UpstreamRemoteName
    $captionMessage = $script:localizedData.New_SamplerGitHubReleaseTag_UpstreamTags_ShouldProcessCaption

    if ($PSCmdlet.ShouldProcess($verboseDescriptionMessage, $verboseWarningMessage, $captionMessage))
    {
        git fetch $UpstreamRemoteName --tags

        if ($LASTEXITCODE -ne 0)
        {
            if ($SwitchBackToPreviousBranch.IsPresent -and $switchedToDefaultBranch)
            {
                git checkout $currentLocalBranchName
            }

            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    ($script:localizedData.New_SamplerGitHubReleaseTag_FailedFetchTagsFromUpstreamRemote -f $UpstreamRemoteName),
                    'NSGRT0007', # cspell: disable-line
                    [System.Management.Automation.ErrorCategory]::InvalidOperation,
                    $DatabaseName
                )
            )
        }
    }

    if (-not $ReleaseTag)
    {
        $tagExist = git tag | Select-Object -First 1

        if ($LASTEXITCODE -ne 0 -or -not $tagExist)
        {
            $continueProcessing = $false
            $errorMessage = $script:localizedData.New_SamplerGitHubReleaseTag_FailedGetTagsOrMissingTagsInLocalRepository
            $errorCode = 'NSGRT0008' # cspell: disable-line
        }

        if ($continueProcessing)
        {
            $latestPreviewTag = git describe --tags --abbrev=0

            if ($LASTEXITCODE -ne 0)
            {
                $continueProcessing = $false
                $errorMessage = $script:localizedData.New_SamplerGitHubReleaseTag_FailedDescribeTags
                $errorCode = 'NSGRT0009' # cspell: disable-line
            }

            if ($continueProcessing)
            {
                $isCorrectlyFormattedPreviewTag = $latestPreviewTag -match '^(v\d+\.\d+\.\d+)-.*'

                if ($isCorrectlyFormattedPreviewTag)
                {
                    $ReleaseTag = $matches[1]
                }
                else
                {
                    $continueProcessing = $false
                    $errorMessage = $script:localizedData.New_SamplerGitHubReleaseTag_LatestTagIsNotPreview -f $latestPreviewTag
                    $errorCode = 'NSGRT0010' # cspell: disable-line
                }
            }
        }

        if (-not $continueProcessing)
        {
            if ($SwitchBackToPreviousBranch.IsPresent -and $switchedToDefaultBranch)
            {
                git checkout $currentLocalBranchName
            }

            $PSCmdlet.ThrowTerminatingError(
                [System.Management.Automation.ErrorRecord]::new(
                    $errorMessage,
                    $errorCode, # cspell: disable-line
                    [System.Management.Automation.ErrorCategory]::InvalidOperation,
                    $DatabaseName
                )
            )
        }
    }

    if ($WhatIfPreference)
    {
        $messageShouldProcess = $script:localizedData.New_SamplerGitHubReleaseTag_NewTagWhatIf_ShouldProcessVerboseDescription
    }
    else
    {
        $messageShouldProcess = $script:localizedData.New_SamplerGitHubReleaseTag_NewTag_ShouldProcessVerboseDescription
    }

    $verboseDescriptionMessage = $messageShouldProcess -f $ReleaseTag, $DefaultBranchName, $headCommitId
    $verboseWarningMessage = $script:localizedData.New_SamplerGitHubReleaseTag_NewTag_ShouldProcessVerboseWarning -f $ReleaseTag
    $captionMessage = $script:localizedData.New_SamplerGitHubReleaseTag_NewTag_ShouldProcessCaption

    if ($PSCmdlet.ShouldProcess($verboseDescriptionMessage, $verboseWarningMessage, $captionMessage))
    {
        git tag $ReleaseTag

        if ($PushTag -and ($Force -or $PSCmdlet.ShouldContinue(('Do you want to push the tags to the upstream ''{0}''?' -f $UpstreamRemoteName), 'Confirm')))
        {
            git push origin --tags

            Write-Information -MessageData ("`e[32mTag `e[1;37;44m{0}`e[0m`e[32m was created and pushed to upstream '{1}'`e[0m" -f $ReleaseTag, $UpstreamRemoteName) -InformationAction Continue
        }
        else
        {
            # cSpell: disable-next-line
            Write-Information -MessageData ("`e[32mTag `e[1;37;44m{0}`e[0m`e[32m was created. To push the tag to upstream, run `e[1;37;44mgit push {1} --tags`e[0m`e[32m.`e[0m" -f $ReleaseTag, $UpstreamRemoteName) -InformationAction Continue
        }
    }

    if ($SwitchBackToPreviousBranch.IsPresent)
    {
        $verboseDescriptionMessage = $script:localizedData.New_SamplerGitHubReleaseTag_SwitchBack_ShouldProcessVerboseDescription -f $currentLocalBranchName
        $verboseWarningMessage = $script:localizedData.New_SamplerGitHubReleaseTag_SwitchBack_ShouldProcessVerboseWarning -f $currentLocalBranchName
        $captionMessage = $script:localizedData.New_SamplerGitHubReleaseTag_SwitchBack_ShouldProcessCaption

        if ($PSCmdlet.ShouldProcess($verboseDescriptionMessage, $verboseWarningMessage, $captionMessage))
        {
            git checkout $currentLocalBranchName

            if ($LASTEXITCODE -ne 0)
            {
                $PSCmdlet.ThrowTerminatingError(
                    [System.Management.Automation.ErrorRecord]::new(
                        ($script:localizedData.New_SamplerGitHubReleaseTag_FailedCheckoutPreviousBranch -f $currentLocalBranchName),
                        'NSGRT0011', # cspell: disable-line
                        [System.Management.Automation.ErrorCategory]::InvalidOperation,
                        $DatabaseName
                    )
                )
            }
        }
    }
}
#EndRegion '.\Public\New-SamplerGitHubReleaseTag.ps1' 348
#Region '.\Public\Out-Difference.ps1' -1

<#
    .SYNOPSIS
        Compares two sets of strings and converts them into a difference string.
 
    .DESCRIPTION
        The Out-Difference function compares two sets of strings, Reference and
        Difference, and converts them into a difference string. It provides options
        to customize the indicators, labels, and formatting of the output.
 
    .PARAMETER Reference
        Specifies the reference set of strings to compare.
 
    .PARAMETER Difference
        Specifies the difference set of strings to compare.
 
    .PARAMETER EqualIndicator
        Specifies the indicator to use for equal strings.
 
    .PARAMETER NotEqualIndicator
        Specifies the indicator to use for unequal strings.
 
    .PARAMETER HighlightStart
        Specifies the starting indicator for highlighting differences.
 
    .PARAMETER HighlightEnd
        Specifies the ending indicator for highlighting differences.
 
    .PARAMETER ReferenceLabel
        Specifies the label for the reference set.
 
    .PARAMETER DifferenceLabel
        Specifies the label for the difference set.
 
    .PARAMETER NoColumnHeader
        Indicates whether to exclude the column header from the output.
 
    .PARAMETER NoLabels
        Indicates whether to exclude the labels from the output.
 
    .PARAMETER ReferenceLabelAnsi
        Specifies the ANSI escape sequence for the reference label.
 
    .PARAMETER DifferenceLabelAnsi
        Specifies the ANSI escape sequence for the difference label.
 
    .PARAMETER ColumnHeaderAnsi
        Specifies the ANSI escape sequence for the column header.
 
    .PARAMETER ColumnHeaderResetAnsi
        Specifies the ANSI escape sequence to reset the column header formatting.
 
    .PARAMETER EncodingType
        Specifies the encoding type to use for converting the strings to byte arrays.
 
    .PARAMETER ConcatenateArray
        Indicates whether to concatenate the arrays of strings into a single string.
 
    .PARAMETER ConcatenateChar
        Specifies the character used to concatenate the strings. Default is a new line character.
 
    .PARAMETER NoHexOutput
        Specifies whether to omit the hex columns and output only the character groups.
 
    .EXAMPLE
        $reference = "apple", "banana", "cherry"
        $difference = "apple", "orange", "cherry"
        Out-Difference -Reference $reference -Difference $difference -EqualIndicator '' -ReferenceLabel 'Reference:' -DifferenceLabel 'Difference:' -ConcatenateArray -ConcatenateChar '' -NoHexOutput
 
    .INPUTS
        None. You cannot pipe input to this function.
 
    .OUTPUTS
        System.String. The difference string representing the comparison between
        the reference and difference sets.
 
    .NOTES
        This command is using the default parameters values from the ConvertTo-DifferenceString
        command.
#>

function Out-Difference
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [AllowNull()]
        [AllowEmptyCollection()]
        [System.String[]]
        $Reference,

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [AllowNull()]
        [AllowEmptyCollection()]
        [System.String[]]
        $Difference,

        [Parameter()]
        [ValidateLength(0, 2)]
        [System.String]
        $EqualIndicator,

        [Parameter()]
        [ValidateLength(0, 2)]
        [System.String]
        $NotEqualIndicator,

        [Parameter()]
        [System.String]
        $HighlightStart,

        [Parameter()]
        [System.String]
        $HighlightEnd,

        [Parameter()]
        [System.String]
        $ReferenceLabel,

        [Parameter()]
        [System.String]
        $DifferenceLabel,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $NoColumnHeader,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $NoLabels,

        [Parameter()]
        [System.String]
        $ReferenceLabelAnsi,

        [Parameter()]
        [System.String]
        $DifferenceLabelAnsi,

        [Parameter()]
        [System.String]
        $ColumnHeaderAnsi,

        [Parameter()]
        [System.String]
        $ColumnHeaderResetAnsi,

        [Parameter()]
        [ValidateSet('ASCII', 'BigEndianUnicode', 'Default', 'Unicode', 'UTF32', 'UTF7', 'UTF8')]
        [System.String]
        $EncodingType,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $ConcatenateArray,

        [Parameter()]
        [System.String]
        $ConcatenateChar = [System.Environment]::NewLine,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $NoHexOutput
    )

    if ($null -eq $ConcatenateChar)
    {
        $ConcatenateChar = ''
    }

    $behaviorParameters = @{} + $PSBoundParameters
    $behaviorParameters.Remove('Reference')
    $behaviorParameters.Remove('Difference')
    $behaviorParameters.Remove('ConcatenateArray')
    $behaviorParameters.Remove('ConcatenateChar')

    if ($ConcatenateArray.IsPresent)
    {
        # Handle null values by converting them to empty strings
        if ($null -eq $Reference)
        {
            $refString = ''
        }
        else
        {
            $refString = $Reference -join $ConcatenateChar
        }

        if ($null -eq $Difference)
        {
            $diffString = ''
        }
        else
        {
            $diffString = $Difference -join $ConcatenateChar
        }

        ConvertTo-DifferenceString -ReferenceString $refString -DifferenceString $diffString @behaviorParameters
    }
    else
    {
        for ($i = 0; $i -lt [Math]::Max($Reference.Length, $Difference.Length); $i++)
        {
            $refString = if ($i -lt $Reference.Length)
            {
                $Reference[$i]
            }
            else
            {
                ''
            }

            $diffString = if ($i -lt $Difference.Length)
            {
                $Difference[$i]
            }
            else
            {
                ''
            }

            ConvertTo-DifferenceString -ReferenceString $refString -DifferenceString $diffString @behaviorParameters
        }
    }
}
#EndRegion '.\Public\Out-Difference.ps1' 227
#Region '.\Public\Pop-VMLatestSnapshot.ps1' -1

<#
    .SYNOPSIS
        Sets the latest snapshot of a virtual machine and starts it.
 
    .DESCRIPTION
        The Pop-VMLatestSnapshot command sets the latest snapshot of a virtual
        machine specified by the $ServerName parameter and starts it.
 
    .PARAMETER ServerName
        Specifies the name of the server for which to set the latest snapshot.
 
    .EXAMPLE
        Pop-VMLatestSnapshot -ServerName 'VM1'
 
        Sets the latest snapshot of the virtual machine named "VM1" and starts it.
#>

function Pop-VMLatestSnapshot
{
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $ServerName
    )

    Get-VM -Name $ServerName |
        Get-Snapshot | # TODO: Should this not be Get-VMSnapshot?
        Where-Object -FilterScript {
            $_.IsCurrent -eq $true
        } |
        Set-VM -VM $ServerName | # TODO: Is -VM necessary?
        Start-VM
}
#EndRegion '.\Public\Pop-VMLatestSnapshot.ps1' 34
#Region '.\Public\Remove-History.ps1' -1

<#
    .SYNOPSIS
        Removes command history entries that match a specified pattern.
 
    .DESCRIPTION
        The Remove-History function removes command history entries that match a
        specified pattern. It removes both the history entries stored by the
        PSReadLine module and the history entries stored by the PowerShell session.
 
    .PARAMETER Pattern
        Specifies the pattern to match against the command history entries. Only
        the entries that match the pattern will be removed.
 
    .PARAMETER EscapeRegularExpression
        Indicates that the pattern should be treated as a literal string. If this
        switch parameter is specified, the pattern will not be treated as a regular
        expression.
 
    .INPUTS
        None. You cannot pipe input to this function.
 
    .OUTPUTS
        None. The function does not generate any output.
 
    .EXAMPLE
        Remove-History -Pattern ".*\.txt"
 
        This example removes all command history entries that end with the ".txt"
        extension, using a regular expression pattern.
 
    .EXAMPLE
        Remove-History -Pattern './build.ps1' -EscapeRegularExpression
 
        This example removes all command history entries that contain the string
        "./build.ps1".
#>

function Remove-History
{
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSShouldProcess', '', Justification = 'Because ShouldProcess is handled in the commands it calls')]
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param
    (
        [Parameter(Mandatory = $true, Position = 0)]
        [System.String]
        $Pattern,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $EscapeRegularExpression
    )

    Remove-PSReadLineHistory @PSBoundParameters
    Remove-PSHistory @PSBoundParameters
}
#EndRegion '.\Public\Remove-History.ps1' 55
#Region '.\Public\Remove-PSHistory.ps1' -1

<#
    .SYNOPSIS
        Removes PowerShell history content matching a specified pattern.
 
    .DESCRIPTION
        The Remove-PSHistory function removes PowerShell history content that matches
        a specified pattern.
 
    .PARAMETER Pattern
        Specifies the pattern to match against the command history entries. Only
        the entries that match the pattern will be removed.
 
    .PARAMETER EscapeRegularExpression
        Indicates that the pattern should be treated as a literal string. If this
        switch parameter is specified, the pattern will not be treated as a regular
        expression.
 
    .EXAMPLE
        Remove-PSHistory -Pattern ".*\.txt"
 
        This example removes all command history entries that end with the ".txt"
        extension, using a regular expression pattern.
 
    .EXAMPLE
        Remove-PSHistory -Pattern './build.ps1' -EscapeRegularExpression
 
        This example removes all command history entries that contain the string
        "./build.ps1".
 
    .INPUTS
        None. You cannot pipe input to this function.
 
    .OUTPUTS
        None. The function does not generate any output.
#>

function Remove-PSHistory
{
    [CmdletBinding(SupportsShouldProcess = $true , ConfirmImpact = 'High')]
    param
    (
        [Parameter(Mandatory = $true, Position = 0)]
        [System.String]
        $Pattern,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $EscapeRegularExpression
    )

    if ($EscapeRegularExpression.IsPresent)
    {
        $Pattern = [System.Text.RegularExpressions.Regex]::Escape($Pattern)
    }

    $historyContent = Get-History

    $matchingLines = $historyContent |
        Where-Object -FilterScript {
            $_.CommandLine -match $Pattern
        }

    if ($matchingLines)
    {
        $matchingLines | Write-Verbose -Verbose

        $shouldProcessVerboseDescription = 'Removing content matching the pattern ''{0}''.' -f $Pattern
        $shouldProcessVerboseWarning = 'Are you sure you want to remove the content matching the pattern ''{0}'' from PowerShell history?' -f $Pattern
        # This string shall not end with full stop (.) since it is used as a title of ShouldProcess messages.
        $shouldProcessCaption = 'Remove content matching the pattern from PowerShell history'

        if ($PSCmdlet.ShouldProcess($shouldProcessVerboseDescription, $shouldProcessVerboseWarning, $shouldProcessCaption))
        {
            $matchingLines |
                ForEach-Object -Process {
                    Clear-History -Id $_.Id
                }

            Write-Information -MessageData 'Removed PowerShell history content matching the pattern.' -InformationAction Continue
        }
    }
    else
    {
        Write-Information -MessageData 'No PowerShell history content matching the pattern.' -InformationAction Continue
    }
}
#EndRegion '.\Public\Remove-PSHistory.ps1' 86
#Region '.\Public\Remove-PSReadLineHistory.ps1' -1

<#
    .SYNOPSIS
        Removes content from the PSReadLine history that matches a specified pattern.
 
    .DESCRIPTION
        The Remove-PSReadLineHistory function removes content from the PSReadLine
        history that matches a specified pattern.
 
    .PARAMETER Pattern
        Specifies the pattern to match against the command history entries. Only
        the entries that match the pattern will be removed.
 
    .PARAMETER EscapeRegularExpression
        Indicates that the pattern should be treated as a literal string. If this
        switch parameter is specified, the pattern will not be treated as a regular
        expression.
 
    .NOTES
        - This command requires the PSReadLine module to be installed.
        - The PSReadLine history is stored in a file specified by the HistorySavePath
          property of the PSReadLineOption object.
 
    .EXAMPLE
        Remove-PSReadLineHistory -Pattern ".*\.txt"
 
        This example removes all command history entries that end with the ".txt"
        extension, using a regular expression pattern.
 
    .EXAMPLE
        Remove-PSReadLineHistory -Pattern './build.ps1' -EscapeRegularExpression
 
        This example removes all command history entries that contain the string
        "./build.ps1".
 
    .INPUTS
        None. You cannot pipe input to this function.
 
    .OUTPUTS
        None. The function does not generate any output.
#>


function Remove-PSReadLineHistory
{
    [CmdletBinding(SupportsShouldProcess = $true , ConfirmImpact = 'High')]
    param
    (
        [Parameter(Mandatory = $true, Position = 0)]
        [System.String]
        $Pattern,

        [Parameter()]
        [System.Management.Automation.SwitchParameter]
        $EscapeRegularExpression
    )

    if ($EscapeRegularExpression.IsPresent)
    {
        $Pattern = [System.Text.RegularExpressions.Regex]::Escape($Pattern)
    }

    $historyPath = (Get-PSReadLineOption).HistorySavePath

    $historyContent = Get-Content -Path $historyPath

    # Do not match the last line as it is the line that called the function.
    $matchingContent = $historyContent |
        Select-Object -SkipLast 1 |
        Select-String -Pattern $Pattern

    if ($matchingContent)
    {
        $matchingContent | Write-Verbose -Verbose

        $shouldProcessVerboseDescription = 'Removing content matching the pattern ''{0}''.' -f $Pattern
        $shouldProcessVerboseWarning = 'Are you sure you want to remove the content matching the pattern ''{0}'' from PSReadLine history?' -f $Pattern
        # This string shall not end with full stop (.) since it is used as a title of ShouldProcess messages.
        $shouldProcessCaption = 'Remove content matching the pattern from PSReadLine history'

        if ($PSCmdlet.ShouldProcess($shouldProcessVerboseDescription, $shouldProcessVerboseWarning, $shouldProcessCaption))
        {
            Set-Content -Path $historyPath -Value (
                $historyContent |
                    Select-String -NotMatch $Pattern
            ).Line

            Write-Information -MessageData 'Removed PSReadLine history content matching the pattern.' -InformationAction Continue
        }
    }
    else
    {
        Write-Information -MessageData 'No PSReadLine history content matching the pattern.' -InformationAction Continue
    }
}
#EndRegion '.\Public\Remove-PSReadLineHistory.ps1' 94
#Region '.\Public\Split-StringAtIndex.ps1' -1

<#
    .SYNOPSIS
        Splits a string at a specified index or range of indices.
 
    .DESCRIPTION
        The Split-StringAtIndex function splits a given string at a specified index
        or range of indices. It can be used to extract substrings from a larger
        string based on the provided indices.
 
    .PARAMETER IndexObject
        Specifies the index object to split the string. This parameter is used
        when providing input via the pipeline.
 
    .PARAMETER InputString
        Specifies the input string to be split.
 
    .PARAMETER StartIndex
        Specifies the starting index of the substring to be extracted. The value
        must be less than the length of the input string.
 
    .PARAMETER EndIndex
        Specifies the ending index of the substring to be extracted. The value
        must be less than the length of the input string.
 
    .EXAMPLE
        PS> Split-StringAtIndex -InputString "Hello, World!" -StartIndex 0 -EndIndex 4
 
        This example splits the input string "Hello, World!" at the index specified
        by StartIndex and then at the index specified by EndIndex and returns the
        resulting array of substrings.
 
    .EXAMPLE
        PS> @(@{Start = 0; End = 2}, @{Start = 7; End = 11 }) | Split-StringAtIndex -InputString "Hello, world!"
 
        This example splits the input string "Hello, World!" at the indices provided
        by the pipeline. It will split the string at each StartIndex and EndIndex
        and returns the resulting array of substrings.
 
    .EXAMPLE
        PS> @(0, 1, 2, 7, 8, 9, 10, 11) | Get-NumericalSequence | Split-StringAtIndex -InputString "Hello, world!"
 
        This example splits the input string "Hello, World!" at the indices provided
        by the pipeline. It will split the string at each StartIndex and EndIndex
        and returns the resulting array of substrings.
 
    .OUTPUTS
        System.String[]
 
        An array of substrings extracted from the input string.
 
    .NOTES
        The Split-StringAtIndex function is designed to split strings based on indices
        and can be used in various scenarios where string manipulation is required.
        To get the indices the function Get-NumericalSequence can be used.
#>

function Split-StringAtIndex
{
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('UseSyntacticallyCorrectExamples', '', Justification = 'Because the examples are syntactically correct. The rule does not seem to understand that there is pipeline input.')]
    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(ParameterSetName = 'PipelineInput', Mandatory = $true, ValueFromPipeline = $true)]
        [PSCustomObject]
        $IndexObject,

        [Parameter(ParameterSetName = 'StartEndInput', Mandatory = $true)]
        [Parameter(ParameterSetName = 'PipelineInput', Mandatory = $true)]
        [System.String]
        $InputString,

        [Parameter(ParameterSetName = 'StartEndInput', Mandatory = $true)]
        [ValidateScript({ $_ -lt $InputString.Length })]
        [System.UInt32]
        $StartIndex,

        [Parameter(ParameterSetName = 'StartEndInput', Mandatory = $true)]
        [ValidateScript({ $_ -lt $InputString.Length })]
        [System.UInt32]
        $EndIndex
    )

    begin
    {
        $result = @()
        $previousIndex = 0
    }

    process
    {
        switch ($PSCmdlet.ParameterSetName)
        {
            'PipelineInput'
            {
                $start = $IndexObject.Start
                $end = $IndexObject.End
            }

            'StartEndInput'
            {
                $start = $StartIndex
                $end = $EndIndex
            }
        }

        if ($null -eq $end)
        {
            $end = $start
        }

        if ($start -gt $previousIndex)
        {
            $result += $InputString.Substring($previousIndex, $start - $previousIndex)
        }

        $result += $InputString.Substring($start, $end - $start + 1)

        $previousIndex = $end + 1
    }

    end
    {
        if ($previousIndex -lt $InputString.Length)
        {
            $result += $InputString.Substring($previousIndex)
        }

        $result
    }
}
#EndRegion '.\Public\Split-StringAtIndex.ps1' 131
#Region '.\Public\Test-FileHash.ps1' -1

<#
    .SYNOPSIS
        Tests the hash of a file against an expected hash value.
 
    .DESCRIPTION
        This function calculates the hash of a file using a specified algorithm and
        compares it to an expected hash value. It returns $true if the calculated
        hash matches the expected hash, and $false otherwise.
 
    .PARAMETER Path
        The path to the file to be hashed.
 
    .PARAMETER Algorithm
        The hashing algorithm to use (SHA1, SHA256, SHA384, SHA512, or MD5).
 
    .PARAMETER ExpectedHash
        The expected hash value to compare against.
 
    .EXAMPLE
        Test-FileHash -Path "C:\example.txt" -Algorithm "SHA256" -ExpectedHash "e5b7e987e069f57439dfe8341f942f142e924a3a344b8941466011c5049a0855"
 
        Returns $true if the SHA256 hash of C:\example.txt matches the expected hash.
#>

function Test-FileHash
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.String]
        $Path,

        [Parameter(Mandatory = $true)]
        [ValidateSet('SHA1', 'SHA256', 'SHA384', 'SHA512', 'MD5')]
        [System.String]
        $Algorithm,

        [Parameter(Mandatory = $true)]
        [System.String]
        $ExpectedHash
    )

    Write-Debug -Message ($script:localizedData.TestFileHash_CalculatingHash -f $Path, $Algorithm)

    $fileHash = Get-FileHash -Path $Path -Algorithm $Algorithm -ErrorAction 'Stop' |
        Select-Object -ExpandProperty Hash

    Write-Debug -Message ($script:localizedData.TestFileHash_ComparingHash -f $fileHash, $ExpectedHash)

    return $fileHash -eq $ExpectedHash
}
#EndRegion '.\Public\Test-FileHash.ps1' 52