AtlassianPS.Standards.psm1

function Get-BuildEnvironmentMetadata {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
    param(
        [Parameter(Mandatory)]
        [String]$ProjectPath
    )

    $buildSystem = 'Unknown'
    $branchName = ''
    $commitHash = ''
    $buildNumber = '0'
    $commitMessage = ''

    if ($env:GITHUB_ACTIONS) {
        $buildSystem = 'GitHub Actions'
        $branchName = if ($env:GITHUB_HEAD_REF) { $env:GITHUB_HEAD_REF } else { $env:GITHUB_REF_NAME }
        $commitHash = $env:GITHUB_SHA
        $buildNumber = $env:GITHUB_RUN_NUMBER
        $commitMessage = $env:GITHUB_EVENT_HEAD_COMMIT_MESSAGE
    }
    else {
        $branchName = git -C $ProjectPath rev-parse --abbrev-ref HEAD 2>$null
        $commitHash = git -C $ProjectPath rev-parse HEAD 2>$null
        $commitMessage = (git -C $ProjectPath log -1 --pretty=%B 2>$null) -join "`n"
    }

    return [PSCustomObject]@{
        BuildSystem   = $buildSystem
        BranchName    = $branchName
        CommitHash    = $commitHash
        BuildNumber   = $buildNumber
        CommitMessage = $commitMessage
    }
}
function Get-HostPlatformInfo {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()

    $isWindows = (-not (Get-Variable -Name IsWindows -ErrorAction Ignore)) -or $IsWindows
    $isLinux = (Get-Variable -Name IsLinux -ErrorAction Ignore) -and $IsLinux
    $isMacOS = (Get-Variable -Name IsMacOS -ErrorAction Ignore) -and $IsMacOS
    $isCoreCLR = $PSVersionTable.ContainsKey('PSEdition') -and $PSVersionTable.PSEdition -eq 'Core'

    $os = 'Unknown'
    $osVersion = $PSVersionTable.OS

    switch ($true) {
        { $isWindows } {
            $os = 'Windows'
            if (-not $isCoreCLR) {
                $osVersion = $PSVersionTable.BuildVersion.ToString()
            }
            break
        }
        { $isLinux } {
            $os = 'Linux'
            break
        }
        { $isMacOS } {
            $os = 'OSX'
            break
        }
    }

    return [PSCustomObject]@{
        OS        = $os
        OSVersion = $osVersion
    }
}
function Get-ProjectRelativePath {
    [CmdletBinding()]
    [OutputType([String])]
    param(
        [Parameter(Mandatory)]
        [String]$BasePath,

        [Parameter(Mandatory)]
        [String]$TargetPath
    )

    $getRelativePathMethod = [System.IO.Path].GetMethod('GetRelativePath', [Type[]]@([String], [String]))
    if ($getRelativePathMethod) {
        return [System.IO.Path]::GetRelativePath($BasePath, $TargetPath)
    }

    if ($TargetPath.StartsWith($BasePath, [System.StringComparison]::OrdinalIgnoreCase)) {
        return $TargetPath.Substring($BasePath.Length).TrimStart('\', '/')
    }

    return $TargetPath
}
function Get-UsablePesterVersion {
    [CmdletBinding()]
    [OutputType([Version])]
    param(
        [Parameter(Mandatory)]
        [Version]$MinimumVersion,

        [Parameter()]
        [Version]$MaximumVersion
    )

    $availablePesterModules = @(
        Get-Module -Name 'Pester' -ListAvailable | Sort-Object -Property Version -Descending
    )

    if ($availablePesterModules.Count -eq 0) {
        throw "Pester version $MinimumVersion or newer is required, but no Pester module is installed."
    }

    $selectedModule = $availablePesterModules |
        Where-Object {
            $_.Version -ge $MinimumVersion -and (
                (-not $MaximumVersion) -or $_.Version -le $MaximumVersion
            )
        } |
        Select-Object -First 1

    if (-not $selectedModule) {
        if ($MaximumVersion) {
            throw "Pester version between $MinimumVersion and $MaximumVersion is required, but no installed version satisfies that range."
        }

        throw "Pester version $MinimumVersion or newer is required, but the highest available version is $($availablePesterModules[0].Version)."
    }

    return $selectedModule.Version
}
function Import-PesterVersion {
    [CmdletBinding()]
    [OutputType([Version])]
    param(
        [Parameter()]
        [Version]$MinimumVersion = [Version]'5.7.0',

        [Parameter()]
        [Version]$MaximumVersion
    )

    $pesterVersionToUse = Get-UsablePesterVersion -MinimumVersion $MinimumVersion -MaximumVersion $MaximumVersion
    $loadedPester = Get-Module -Name 'Pester' | Sort-Object -Property Version -Descending | Select-Object -First 1
    if ((-not $loadedPester) -or ($loadedPester.Version -ne $pesterVersionToUse)) {
        if ($loadedPester) {
            Get-Module -Name 'Pester' | Remove-Module -Force -ErrorAction SilentlyContinue
        }
        Import-Module -Name 'Pester' -RequiredVersion $pesterVersionToUse -ErrorAction Stop
    }

    return $pesterVersionToUse
}
function Invoke-ScriptAnalyzerLint {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String[]]$AnalyzerPaths,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$AnalyzerSettingsPath,

        [Parameter()]
        [ValidateSet('Error', 'Warning', 'Information', 'ParseError')]
        [String[]]$Severity = @('Error', 'Warning'),

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$ProjectPath,

        [Parameter()]
        [Boolean]$IsGitHubActions
    )

    $analyzerParams = @{
        Settings = $AnalyzerSettingsPath
        Severity = $Severity
        Recurse  = $true
    }

    $results = @(
        foreach ($path in $AnalyzerPaths) {
            Invoke-ScriptAnalyzer -Path $path @analyzerParams
        }
    )

    foreach ($result in $results) {
        $color = if ($result.Severity -eq 'Error') { 'Red' } else { 'Yellow' }
        $location = if ($result.ScriptName) { $result.ScriptName } else { '<unknown>' }
        Write-LintMessage -Color $color -Message "[$($result.Severity)] ${location}:$($result.Line) - $($result.RuleName): $($result.Message)"

        if ($IsGitHubActions -and $result.ScriptPath) {
            $level = if ($result.Severity -eq 'Error') { 'error' } else { 'warning' }
            $relativePath = Get-ProjectRelativePath -BasePath $ProjectPath -TargetPath $result.ScriptPath
            $message = ($result.Message -replace '%', '%25' -replace "`r", '%0D' -replace "`n", '%0A')
            Write-WorkflowCommand -Command "::${level} file=$relativePath,line=$($result.Line),col=$($result.Column),title=$($result.RuleName)::$message"
        }
    }

    return $results
}
function Invoke-StyleLintTests {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$StyleTestPath,

        [Parameter()]
        [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')]
        [String]$PesterVerbosity = 'Normal',

        [Parameter()]
        [Version]$MinimumPesterVersion = [Version]'5.7.0'
    )

    $pesterVersion = Import-PesterVersion -MinimumVersion $MinimumPesterVersion
    if (-not $pesterVersion) {
        $pesterVersion = [Version]'5.7.0'
    }

    if ($pesterVersion.Major -ge 5) {
        $pesterConfigHash = @{
            Run    = @{
                PassThru = $true
                Path     = $StyleTestPath
            }
            Output = @{
                Verbosity = $PesterVerbosity
            }
        }
        $pesterConfig = New-PesterConfiguration -Hashtable $pesterConfigHash
        return (Invoke-Pester -Configuration $pesterConfig)
    }

    return (Invoke-Pester -Script $StyleTestPath -PassThru)
}
function Write-LintMessage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [String]$Color,

        [Parameter(Mandatory)]
        [String]$Message
    )

    if (Get-Command -Name Write-Build -ErrorAction SilentlyContinue) {
        Write-Build $Color $Message
        return
    }

    Write-Output $Message
}
function Write-WorkflowCommand {
    <#
    .SYNOPSIS
        Emit a GitHub Actions workflow command on stdout.
    .DESCRIPTION
        GitHub Actions workflow commands must reach stdout for the runner to
        parse them as inline annotations.
    #>

    [System.Diagnostics.CodeAnalysis.SuppressMessage(
        'PSAvoidUsingWriteHost', '',
        Justification = 'GitHub Actions workflow commands must reach stdout; Write-Output is captured by Invoke-Build pipelines.'
    )]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [String]$Command
    )

    Write-Host $Command
}
function Copy-ModuleArtifacts {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$ProjectPath,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$ModuleName,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$BuildOutputPath,

        [Parameter()]
        [String[]]$AdditionalFiles = @(),

        [Parameter()]
        [Switch]$IncludeTests
    )

    $resolvedProjectPath = (Resolve-Path -LiteralPath $ProjectPath).ProviderPath
    $sourceModulePath = Join-Path -Path $resolvedProjectPath -ChildPath $ModuleName
    if (-not (Test-Path -LiteralPath $sourceModulePath -PathType Container)) {
        throw "Module source path '$sourceModulePath' was not found."
    }

    if (-not (Test-Path -LiteralPath $BuildOutputPath -PathType Container)) {
        $null = New-Item -Path $BuildOutputPath -ItemType Directory -Force
    }

    $releaseModulePath = Join-Path -Path $BuildOutputPath -ChildPath $ModuleName
    if (-not (Test-Path -LiteralPath $releaseModulePath -PathType Container)) {
        $null = New-Item -Path $releaseModulePath -ItemType Directory -Force
    }
    Copy-Item -Path "$sourceModulePath/*" -Destination $releaseModulePath -Recurse -Force

    foreach ($file in $AdditionalFiles) {
        $sourceFile = if ([System.IO.Path]::IsPathRooted($file)) {
            $file
        }
        else {
            Join-Path -Path $resolvedProjectPath -ChildPath $file
        }

        if (-not (Test-Path -LiteralPath $sourceFile -PathType Leaf)) {
            throw "Artifact source file '$sourceFile' was not found."
        }

        Copy-Item -Path $sourceFile -Destination $releaseModulePath -Force
    }

    if ($IncludeTests) {
        $testsPath = Join-Path -Path $resolvedProjectPath -ChildPath 'Tests'
        if (Test-Path -LiteralPath $testsPath -PathType Container) {
            Copy-Item -Path $testsPath -Destination $BuildOutputPath -Recurse -Force
        }
    }

    return [PSCustomObject]@{
        SourceModulePath  = $sourceModulePath
        ReleaseModulePath = $releaseModulePath
    }
}
function Get-BuildEnvironmentInfo {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter()]
        [String]$VersionToPublish
    )

    $platformInfo = Get-HostPlatformInfo
    $normalizedVersionToPublish = if ($VersionToPublish) { $VersionToPublish.TrimStart('v') } else { $null }
    $builtManifestPath = if ($env:BHBuildOutput -and $env:BHProjectName) {
        Join-Path -Path (Join-Path -Path $env:BHBuildOutput -ChildPath $env:BHProjectName) -ChildPath "$($env:BHProjectName).psd1"
    }
    else {
        $null
    }

    return [PSCustomObject]@{
        BuildSystem       = $env:BHBuildSystem
        ProjectName       = $env:BHProjectName
        ProjectPath       = $env:BHProjectPath
        ModulePath        = $env:BHModulePath
        ModuleManifest    = $env:BHPSModuleManifest
        BuildOutputPath   = $env:BHBuildOutput
        BuiltManifestPath = $builtManifestPath
        BranchName        = $env:BHBranchName
        CommitHash        = $env:BHCommitHash
        CommitMessage     = $env:BHCommitMessage
        BuildNumber       = $env:BHBuildNumber
        VersionToPublish  = $normalizedVersionToPublish
        OS                = $platformInfo.OS
        OSVersion         = $platformInfo.OSVersion
    }
}
function Get-ScriptAnalyzerSettingsPath {
    [CmdletBinding()]
    [OutputType([string])]
    param()

    $moduleBase = $ExecutionContext.SessionState.Module.ModuleBase
    if (-not $moduleBase) {
        throw 'Unable to resolve AtlassianPS.Standards module base path.'
    }

    $settingsPath = Join-Path -Path $moduleBase -ChildPath 'PSScriptAnalyzerSettings.psd1'

    if (-not (Test-Path -LiteralPath $settingsPath -PathType Leaf)) {
        throw "Unable to locate PSScriptAnalyzer settings file at '$settingsPath'. Ensure AtlassianPS.Standards is installed correctly."
    }

    return (Resolve-Path -LiteralPath $settingsPath).ProviderPath
}
function Initialize-BuildEnvironment {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$ProjectName,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$ProjectPath,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [String]$BuildOutputFolder = 'Release',

        [Parameter()]
        [String]$VersionToPublish,

        [Parameter()]
        [Switch]$ResetBuildEnvironmentVariables
    )

    $resolvedProjectPath = (Resolve-Path -LiteralPath $ProjectPath).ProviderPath

    if ($ResetBuildEnvironmentVariables) {
        Remove-Item -Path env:\BH* -ErrorAction SilentlyContinue
    }

    $env:BHProjectName = $ProjectName
    $env:BHProjectPath = $resolvedProjectPath
    $env:BHModulePath = Join-Path -Path $env:BHProjectPath -ChildPath $env:BHProjectName
    $env:BHPSModulePath = $env:BHModulePath
    $env:BHPSModuleManifest = Join-Path -Path $env:BHModulePath -ChildPath "$($env:BHProjectName).psd1"
    $env:BHBuildOutput = Join-Path -Path $env:BHProjectPath -ChildPath $BuildOutputFolder

    $metadata = Get-BuildEnvironmentMetadata -ProjectPath $env:BHProjectPath
    $env:BHBuildSystem = $metadata.BuildSystem
    $env:BHBranchName = $metadata.BranchName
    $env:BHCommitHash = $metadata.CommitHash
    $env:BHBuildNumber = $metadata.BuildNumber
    $env:BHCommitMessage = $metadata.CommitMessage

    return Get-BuildEnvironmentInfo -VersionToPublish $VersionToPublish
}
function Invoke-Lint {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [String]$ProjectPath = $env:BHProjectPath,

        [Parameter()]
        [String]$ModulePath = $env:BHModulePath,

        [Parameter()]
        [String]$BuildScriptPath,

        [Parameter()]
        [String]$StyleTestPath,

        [Parameter()]
        [String]$AnalyzerSettingsPath,

        [Parameter()]
        [String[]]$AnalyzerPaths,

        [Parameter()]
        [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')]
        [String]$PesterVerbosity = 'Normal',

        [Parameter()]
        [Version]$MinimumPesterVersion = [Version]'5.7.0',

        [Parameter()]
        [ValidateSet('Error', 'Warning', 'Information', 'ParseError')]
        [String[]]$Severity = @('Error', 'Warning'),

        [Parameter()]
        [Switch]$SkipStyleTests,

        [Parameter()]
        [Switch]$SkipScriptAnalyzer
    )

    if (-not $ProjectPath) {
        throw 'ProjectPath is required. Provide -ProjectPath or set $env:BHProjectPath.'
    }

    $projectPathResolved = (Resolve-Path -LiteralPath $ProjectPath).ProviderPath
    $failures = [System.Collections.Generic.List[String]]::new()
    $styleFailures = 0
    $analyzerIssueCount = 0
    $isGitHubActions = [bool]$env:GITHUB_ACTIONS

    if (-not $BuildScriptPath -and $env:BHProjectName) {
        $BuildScriptPath = Join-Path -Path $projectPathResolved -ChildPath "$($env:BHProjectName).build.ps1"
    }

    if (-not $StyleTestPath) {
        $StyleTestPath = Join-Path -Path $projectPathResolved -ChildPath 'Tests/Style.Tests.ps1'
    }

    if (-not $AnalyzerSettingsPath) {
        $AnalyzerSettingsPath = Get-ScriptAnalyzerSettingsPath
    }

    if (-not $AnalyzerPaths) {
        $AnalyzerPaths = @(
            $ModulePath
            (Join-Path -Path $projectPathResolved -ChildPath 'Tests')
            (Join-Path -Path $projectPathResolved -ChildPath 'Tools')
            $BuildScriptPath
        ) | Where-Object { $_ -and (Test-Path -LiteralPath $_) }
    }

    if (-not (Test-Path -LiteralPath $AnalyzerSettingsPath -PathType Leaf)) {
        throw "Analyzer settings file was not found at '$AnalyzerSettingsPath'."
    }

    if ($AnalyzerPaths.Count -eq 0 -and -not $SkipScriptAnalyzer) {
        throw 'No analyzer paths were discovered. Provide -AnalyzerPaths or set build environment paths.'
    }

    if (-not $SkipStyleTests) {
        if (Test-Path -LiteralPath $StyleTestPath -PathType Leaf) {
            Write-LintMessage -Color Gray -Message 'Running style tests...'
            $testResults = Invoke-StyleLintTests `
                -StyleTestPath $StyleTestPath `
                -PesterVerbosity $PesterVerbosity `
                -MinimumPesterVersion $MinimumPesterVersion

            $styleFailures = [int]$testResults.FailedCount
            if ($styleFailures -gt 0) {
                $failures.Add("$styleFailures style test(s) failed.")
            }
            else {
                Write-LintMessage -Color Green -Message 'Style tests: passed.'
            }
        }
        else {
            Write-LintMessage -Color Yellow -Message "Style tests skipped because '$StyleTestPath' was not found."
        }
    }

    if (-not $SkipScriptAnalyzer) {
        Write-LintMessage -Color Gray -Message 'Running PSScriptAnalyzer...'

        $results = Invoke-ScriptAnalyzerLint `
            -AnalyzerPaths $AnalyzerPaths `
            -AnalyzerSettingsPath $AnalyzerSettingsPath `
            -Severity $Severity `
            -ProjectPath $projectPathResolved `
            -IsGitHubActions $isGitHubActions

        $analyzerIssueCount = @($results).Count
        if ($analyzerIssueCount -gt 0) {
            $failures.Add("$analyzerIssueCount PSScriptAnalyzer issue(s) found.")
        }
        else {
            Write-LintMessage -Color Green -Message 'PSScriptAnalyzer: no issues found.'
        }
    }

    if ($failures.Count -gt 0) {
        throw ("Lint failed:`n - " + ($failures -join "`n - "))
    }

    return [PSCustomObject]@{
        StyleFailedCount   = $styleFailures
        AnalyzerIssueCount = $analyzerIssueCount
        AnalyzerPathCount  = $AnalyzerPaths.Count
    }
}
function Invoke-ModuleTests {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$TestPath,

        [Parameter()]
        [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')]
        [String]$PesterVerbosity = 'Normal',

        [Parameter()]
        [String[]]$Tag,

        [Parameter()]
        [String[]]$ExcludeTag,

        [Parameter()]
        [String[]]$DefaultExcludeTag = @('Integration'),

        [Parameter()]
        [String[]]$ExcludePath = @(),

        [Parameter()]
        [Version]$MinimumPesterVersion = [Version]'5.7.0',

        [Parameter()]
        [Version]$MaximumPesterVersion,

        [Parameter()]
        [String]$ResultOutputPath
    )

    $resolvedTestPath = (Resolve-Path -LiteralPath $TestPath).ProviderPath
    $pesterVersion = Import-PesterVersion -MinimumVersion $MinimumPesterVersion -MaximumVersion $MaximumPesterVersion
    if (-not $pesterVersion) {
        $pesterVersion = [Version]'5.7.0'
    }

    if (-not $ResultOutputPath) {
        $platformInfo = Get-HostPlatformInfo
        $resultRootPath = if ($env:BHProjectPath) {
            $env:BHProjectPath
        }
        else {
            Split-Path -Path $resolvedTestPath -Parent
        }
        $ResultOutputPath = Join-Path -Path $resultRootPath -ChildPath "Test-$($platformInfo.OS)-$($PSVersionTable.PSVersion.ToString()).xml"
    }

    $pesterConfigHash = @{
        Run        = @{
            PassThru = $true
            Path     = $resolvedTestPath
        }
        TestResult = @{
            Enabled      = $true
            OutputFormat = 'NUnitXml'
            OutputPath   = $ResultOutputPath
        }
        Output     = @{
            Verbosity = $PesterVerbosity
        }
        Filter     = @{
            ExcludeTag = @($DefaultExcludeTag)
        }
    }

    if ($ExcludePath.Count -gt 0) {
        $pesterConfigHash.Run.ExcludePath = @($ExcludePath)
    }

    if ($Tag) {
        $pesterConfigHash.Filter.Tag = $Tag
        $pesterConfigHash.Filter.ExcludeTag = @($pesterConfigHash.Filter.ExcludeTag | Where-Object { $_ -notin $Tag })
        if ($Tag -contains 'Integration') {
            $pesterConfigHash.Run.ExcludePath = @()
        }
    }

    if ($ExcludeTag) {
        $merged = @($pesterConfigHash.Filter.ExcludeTag) + @($ExcludeTag) | Select-Object -Unique
        if ($Tag) {
            $merged = @($merged | Where-Object { $_ -notin $Tag })
        }
        $pesterConfigHash.Filter.ExcludeTag = @($merged)
    }

    if ($pesterVersion.Major -ge 5) {
        $pesterConfig = New-PesterConfiguration -Hashtable $pesterConfigHash
        $testResults = Invoke-Pester -Configuration $pesterConfig
    }
    else {
        $invokePesterParams = @{
            Script       = $resolvedTestPath
            PassThru     = $true
            OutputFile   = $ResultOutputPath
            OutputFormat = 'NUnitXml'
        }

        if ($pesterConfigHash.Filter.Tag) {
            $invokePesterParams.Tag = $pesterConfigHash.Filter.Tag
        }

        if ($pesterConfigHash.Filter.ExcludeTag.Count -gt 0) {
            $invokePesterParams.ExcludeTag = $pesterConfigHash.Filter.ExcludeTag
        }

        $testResults = Invoke-Pester @invokePesterParams
    }

    $containerFailureCount = 0
    if ($testResults.PSObject.Properties.Name -contains 'ContainersFailedCount') {
        $containerFailureCount = [int]$testResults.ContainersFailedCount
    }

    if (($testResults.FailedCount -gt 0) -or ($containerFailureCount -gt 0)) {
        throw ("Pester reported failures. Failed tests: {0}; failed containers: {1}." -f $testResults.FailedCount, $containerFailureCount)
    }

    return $testResults
}
function Join-ModuleSource {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$ReleaseModulePath,

        [Parameter()]
        [String[]]$SourceFolders = @('Public', 'Private'),

        [Parameter()]
        [String[]]$RegionsToKeep = @('Dependencies', 'Configuration'),

        [Parameter()]
        [Boolean]$RemoveSourceFolders = $true
    )

    $resolvedReleaseModulePath = (Resolve-Path -LiteralPath $ReleaseModulePath).ProviderPath
    $releaseRootPath = [System.IO.Path]::GetFullPath($resolvedReleaseModulePath)
    if (-not $releaseRootPath.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
        $releaseRootPath += [System.IO.Path]::DirectorySeparatorChar
    }

    $validatedSourceFolders = @(
        foreach ($folder in $SourceFolders) {
            if ([string]::IsNullOrWhiteSpace($folder)) {
                throw 'SourceFolders cannot contain empty values.'
            }

            if ([System.IO.Path]::IsPathRooted($folder)) {
                throw "Source folder '$folder' must be relative to the release module path."
            }

            $sourceFolderPath = Join-Path -Path $resolvedReleaseModulePath -ChildPath $folder
            $resolvedSourceFolderPath = [System.IO.Path]::GetFullPath($sourceFolderPath)
            if (-not $resolvedSourceFolderPath.StartsWith($releaseRootPath, [System.StringComparison]::OrdinalIgnoreCase)) {
                throw "Source folder '$folder' resolves outside release module path '$resolvedReleaseModulePath'."
            }

            [PSCustomObject]@{
                Name = $folder
                Path = $resolvedSourceFolderPath
            }
        }
    )

    $moduleName = Split-Path -Path $resolvedReleaseModulePath -Leaf
    $targetFile = Join-Path -Path $resolvedReleaseModulePath -ChildPath "$moduleName.psm1"

    if (-not (Test-Path -LiteralPath $targetFile -PathType Leaf)) {
        throw "Module source file '$targetFile' was not found."
    }

    $content = Get-Content -Encoding UTF8 -LiteralPath $targetFile
    $capture = $false
    $regions = [System.Collections.Generic.HashSet[String]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($region in $RegionsToKeep) {
        $null = $regions.Add($region)
    }

    $compiled = [System.Text.StringBuilder]::new()

    foreach ($line in $content) {
        if ($line -match '^#region\s+(.+)$') {
            $capture = $regions.Contains($Matches[1].Trim())
        }

        if ($capture) {
            $null = $compiled.Append($line)
            $null = $compiled.Append("`r`n")
        }

        if ($capture -and $line -match '^#endregion\b') {
            $capture = $false
        }
    }

    $sourceFiles = foreach ($sourceFolder in $validatedSourceFolders) {
        if (Test-Path -LiteralPath $sourceFolder.Path -PathType Container) {
            Get-ChildItem -LiteralPath $sourceFolder.Path -Filter '*.ps1' -File -ErrorAction SilentlyContinue
        }
    }
    $sourceFiles = @(
        $sourceFiles | Sort-Object -Property FullName
    )

    foreach ($file in $sourceFiles) {
        $fileContent = Get-Content -LiteralPath $file.FullName -Raw
        $null = $compiled.Append($fileContent)
        if ($fileContent -and $fileContent[-1] -ne "`n") {
            $null = $compiled.Append("`r`n")
        }
    }

    $utf8Bom = [System.Text.UTF8Encoding]::new($true)
    [System.IO.File]::WriteAllText($targetFile, $compiled.ToString(), $utf8Bom)

    if ($RemoveSourceFolders) {
        foreach ($sourceFolder in $validatedSourceFolders) {
            if (Test-Path -LiteralPath $sourceFolder.Path -PathType Container) {
                Remove-Item -LiteralPath $sourceFolder.Path -Recurse -Force
            }
        }
    }

    return $targetFile
}
function New-ModulePackage {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$BuildOutputPath,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$ModuleName,

        [Parameter()]
        [String]$DestinationPath
    )

    $sourcePath = Join-Path -Path $BuildOutputPath -ChildPath $ModuleName
    if (-not (Test-Path -LiteralPath $sourcePath -PathType Container)) {
        throw "Missing files to package at '$sourcePath'."
    }

    if (-not $DestinationPath) {
        $DestinationPath = Join-Path -Path $BuildOutputPath -ChildPath "$ModuleName.zip"
    }

    if ($PSCmdlet.ShouldProcess($DestinationPath, "Create package from '$sourcePath'")) {
        Remove-Item -Path $DestinationPath -ErrorAction SilentlyContinue
        $null = Compress-Archive -Path $sourcePath -DestinationPath $DestinationPath
    }

    return $DestinationPath
}
function Publish-ModuleRelease {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$BuildOutputPath,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$ModuleName,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$ApiKey
    )

    $releasePath = Join-Path -Path $BuildOutputPath -ChildPath $ModuleName
    if (-not (Test-Path -LiteralPath $releasePath -PathType Container)) {
        throw "Expected release path '$releasePath' does not exist. Run the Build task before publishing."
    }

    Publish-Module -Path $releasePath -NuGetApiKey $ApiKey -ErrorAction Stop
}
function Set-ModuleManifestVersion {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$BuiltManifestPath,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$ModuleName,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$VersionToPublish
    )

    if (-not (Get-Command -Name 'Metadata\Update-Metadata' -ErrorAction SilentlyContinue)) {
        throw "Metadata\Update-Metadata is not available. Ensure the required metadata tooling is installed."
    }

    if (-not (Test-Path -LiteralPath $BuiltManifestPath -PathType Leaf)) {
        throw "Built module manifest '$BuiltManifestPath' was not found."
    }

    function ConvertTo-VersionDescriptor {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string]$VersionText
        )

        $trimmedVersion = $VersionText.Trim()
        if ($trimmedVersion -match '^v') {
            $trimmedVersion = $trimmedVersion.Substring(1)
        }

        if ($trimmedVersion -notmatch '^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<prerelease>[0-9A-Za-z][0-9A-Za-z\.-]*))?$') {
            throw "Invalid semantic version '$VersionText'. Expected format: <major>.<minor>.<patch>[-prerelease]."
        }

        return [PSCustomObject]@{
            Major           = [int]$matches.major
            Minor           = [int]$matches.minor
            Patch           = [int]$matches.patch
            PreReleaseLabel = $matches.prerelease
            CoreVersion     = [Version]::new([int]$matches.major, [int]$matches.minor, [int]$matches.patch)
        }
    }

    function Compare-VersionDescriptor {
        param(
            [Parameter(Mandatory)]
            [PSCustomObject]$Left,
            [Parameter(Mandatory)]
            [PSCustomObject]$Right
        )

        $coreComparison = $Left.CoreVersion.CompareTo($Right.CoreVersion)
        if ($coreComparison -ne 0) {
            return $coreComparison
        }

        if ([string]::IsNullOrEmpty($Left.PreReleaseLabel) -and [string]::IsNullOrEmpty($Right.PreReleaseLabel)) {
            return 0
        }

        if ([string]::IsNullOrEmpty($Left.PreReleaseLabel)) {
            return 1
        }

        if ([string]::IsNullOrEmpty($Right.PreReleaseLabel)) {
            return -1
        }

        return [string]::CompareOrdinal($Left.PreReleaseLabel, $Right.PreReleaseLabel)
    }

    $normalizedVersion = ConvertTo-VersionDescriptor -VersionText $VersionToPublish

    $published = Find-Module -Name $ModuleName -ErrorAction SilentlyContinue
    if ($published) {
        $latestPublished = ConvertTo-VersionDescriptor -VersionText $published.Version.ToString()
        if ((Compare-VersionDescriptor -Left $normalizedVersion -Right $latestPublished) -le 0) {
            throw "Version must be greater than latest published version: $($published.Version)"
        }
    }

    $versionString = "{0}.{1}.{2}" -f $normalizedVersion.Major, $normalizedVersion.Minor, $normalizedVersion.Patch
    if ($PSCmdlet.ShouldProcess($BuiltManifestPath, "Set module version to $versionString")) {
        Metadata\Update-Metadata -Path $BuiltManifestPath -PropertyName 'ModuleVersion' -Value $versionString

        if ($normalizedVersion.PreReleaseLabel) {
            Metadata\Update-Metadata -Path $BuiltManifestPath -PropertyName 'Prerelease' -Value $normalizedVersion.PreReleaseLabel
        }
        else {
            Metadata\Update-Metadata -Path $BuiltManifestPath -PropertyName 'Prerelease' -Value ''
        }
    }

    return $versionString
}
function Sync-ScriptAnalyzerSettings {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([String])]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$DestinationPath
    )

    $sourceSettingsPath = Get-ScriptAnalyzerSettingsPath
    if (-not (Test-Path -LiteralPath $sourceSettingsPath -PathType Leaf)) {
        throw "Shared PSScriptAnalyzer settings file was not found at '$sourceSettingsPath'."
    }

    $destinationDirectory = Split-Path -Path $DestinationPath -Parent
    if ($destinationDirectory -and -not (Test-Path -LiteralPath $destinationDirectory -PathType Container)) {
        $null = New-Item -Path $destinationDirectory -ItemType Directory -Force
    }

    if ($PSCmdlet.ShouldProcess($DestinationPath, "Copy analyzer settings from '$sourceSettingsPath'")) {
        Copy-Item -LiteralPath $sourceSettingsPath -Destination $DestinationPath -Force -ErrorAction Stop
    }

    return (Resolve-Path -LiteralPath $DestinationPath).ProviderPath
}
function Update-ModuleManifestExports {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([pscustomobject])]
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$SourceModulePath,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$BuiltManifestPath,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$ModuleName
    )

    if (-not (Get-Command -Name 'Metadata\Update-Metadata' -ErrorAction SilentlyContinue)) {
        throw "Metadata\Update-Metadata is not available. Ensure the required metadata tooling is installed."
    }

    if (-not (Test-Path -LiteralPath $BuiltManifestPath -PathType Leaf)) {
        throw "Built module manifest '$BuiltManifestPath' was not found."
    }

    $sourceManifestPath = Join-Path -Path $SourceModulePath -ChildPath "$ModuleName.psd1"
    if (-not (Test-Path -LiteralPath $sourceManifestPath -PathType Leaf)) {
        throw "Source module manifest '$sourceManifestPath' was not found."
    }

    $moduleFunctions = @(
        Get-ChildItem -Path (Join-Path -Path $SourceModulePath -ChildPath 'Public/*.ps1') -ErrorAction SilentlyContinue
    ).BaseName
    $sourceModuleInfo = Test-ModuleManifest -Path $sourceManifestPath -ErrorAction Stop
    $moduleAlias = @($sourceModuleInfo.ExportedAliases.Keys)

    if ($PSCmdlet.ShouldProcess($BuiltManifestPath, 'Update exported functions and aliases')) {
        Metadata\Update-Metadata -Path $BuiltManifestPath -PropertyName 'FunctionsToExport' -Value @($moduleFunctions)
        Metadata\Update-Metadata -Path $BuiltManifestPath -PropertyName 'AliasesToExport' -Value ''

        if ($moduleAlias.Count -gt 0) {
            Metadata\Update-Metadata -Path $BuiltManifestPath -PropertyName 'AliasesToExport' -Value @($moduleAlias)
        }
    }

    return [PSCustomObject]@{
        FunctionsToExport = @($moduleFunctions)
        AliasesToExport   = @($moduleAlias)
    }
}
function Write-BuildInfo {
    [CmdletBinding()]
    param(
        [Parameter()]
        [String]$VersionToPublish,

        [Parameter()]
        [PSObject]$BuildInfo
    )

    if (-not $BuildInfo) {
        $BuildInfo = Get-BuildEnvironmentInfo -VersionToPublish $VersionToPublish
    }

    $writer = if (Get-Command -Name Write-Build -ErrorAction SilentlyContinue) {
        {
            param([String]$Message)
            Write-Build Gray $Message
        }
    }
    else {
        {
            param([String]$Message)
            Write-Output $Message
        }
    }

    & $writer ''
    & $writer ('BHBuildSystem: {0}' -f $BuildInfo.BuildSystem)
    & $writer '-------------------------------------------------------'
    & $writer ('BHProjectName: {0}' -f $BuildInfo.ProjectName)
    & $writer ('BHProjectPath: {0}' -f $BuildInfo.ProjectPath)
    & $writer ('BHModulePath: {0}' -f $BuildInfo.ModulePath)
    & $writer ('BHPSModuleManifest: {0}' -f $BuildInfo.ModuleManifest)
    & $writer ('BHBuildOutput: {0}' -f $BuildInfo.BuildOutputPath)
    & $writer ('builtManifestPath: {0}' -f $BuildInfo.BuiltManifestPath)
    & $writer '-------------------------------------------------------'
    & $writer ('BHBranchName: {0}' -f $BuildInfo.BranchName)
    & $writer ('BHCommitHash: {0}' -f $BuildInfo.CommitHash)
    & $writer ('BHCommitMessage: {0}' -f $BuildInfo.CommitMessage)
    & $writer ('BHBuildNumber: {0}' -f $BuildInfo.BuildNumber)
    & $writer ('VersionToPublish: {0}' -f $BuildInfo.VersionToPublish)
    & $writer '-------------------------------------------------------'
    & $writer ('PowerShell version: {0}' -f $PSVersionTable.PSVersion.ToString())
    & $writer ('OS: {0}' -f $BuildInfo.OS)
    & $writer ('OS Version: {0}' -f $BuildInfo.OSVersion)
    & $writer ''
}