template/tools/Update-DependencyPins.ps1

[CmdletBinding(SupportsShouldProcess)]
param(
    [string]$ProjectRoot,

    [string]$ModuleManifestPath,

    [version]$ProposedVersion
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Get-ProjectManifestPath {
    param([string]$Root)

    $sourceRoot = Join-Path -Path $Root -ChildPath 'src'
    $moduleRoots = @(
        if (Test-Path -LiteralPath $sourceRoot -PathType Container) {
            Get-ChildItem -LiteralPath $sourceRoot -Directory
        }
        Get-ChildItem -LiteralPath $Root -Directory |
            Where-Object Name -ne 'src'
    )
    $candidates = @(
        $moduleRoots |
            ForEach-Object {
                $candidate = Join-Path -Path $_.FullName -ChildPath "$($_.Name).psd1"
                if (Test-Path -LiteralPath $candidate -PathType Leaf) {
                    $candidate
                }
            }
    )
    if ($candidates.Count -ne 1) {
        throw "Expected one project module manifest beneath $Root; found $($candidates.Count)."
    }

    $candidates[0]
}

function Get-DataFileHashtableAst {
    param(
        [string]$Content,
        [string]$Path
    )

    $tokens = $null
    $parseErrors = $null
    $ast = [System.Management.Automation.Language.Parser]::ParseInput(
        $Content,
        $Path,
        [ref]$tokens,
        [ref]$parseErrors
    )
    if ($parseErrors.Count -gt 0) {
        throw "Unable to parse $Path`: $($parseErrors[0].Message)"
    }

    $hashtable = $ast.Find(
        { param($node) $node -is [System.Management.Automation.Language.HashtableAst] },
        $false
    )
    if (-not $hashtable) {
        throw "No root hashtable was found in $Path."
    }

    $hashtable
}

function Get-HashtablePair {
    param(
        [System.Management.Automation.Language.HashtableAst]$Hashtable,
        [string]$Key
    )

    @(
        $Hashtable.KeyValuePairs |
            Where-Object { $_.Item1.Value -eq $Key }
    ) | Select-Object -First 1
}

function Get-PairStringAst {
    param($Pair)

    $Pair.Item2.Find(
        { param($node) $node -is [System.Management.Automation.Language.StringConstantExpressionAst] },
        $true
    )
}

function ConvertTo-ReplacedContent {
    param(
        [string]$Content,
        [object[]]$Replacement
    )

    foreach ($item in @($Replacement | Sort-Object StartOffset -Descending)) {
        $Content = $Content.Substring(0, $item.StartOffset) +
            $item.Text +
            $Content.Substring($item.EndOffset)
    }

    $Content
}

function Get-RequirementModuleName {
    param(
        [string]$Content,
        [string]$Path
    )

    $hashtable = Get-DataFileHashtableAst -Content $Content -Path $Path
    @(
        $hashtable.KeyValuePairs |
            ForEach-Object { $_.Item1.Value } |
            Where-Object { $_ -ne 'PSDependOptions' }
    )
}

function ConvertTo-RequirementVersionContent {
    param(
        [string]$Content,
        [string]$Path,
        [hashtable]$Version,
        [switch]$Latest
    )

    $hashtable = Get-DataFileHashtableAst -Content $Content -Path $Path
    $replacements = @()

    foreach ($pair in $hashtable.KeyValuePairs) {
        $moduleName = [string]$pair.Item1.Value
        if ($moduleName -eq 'PSDependOptions') {
            continue
        }
        if (-not $Latest -and -not $Version.ContainsKey($moduleName)) {
            throw "No initialized version was found for build dependency $moduleName."
        }

        $nestedHashtable = $pair.Item2.Find(
            { param($node) $node -is [System.Management.Automation.Language.HashtableAst] },
            $true
        )
        $versionAst = if ($nestedHashtable) {
            $versionPair = Get-HashtablePair -Hashtable $nestedHashtable -Key 'Version'
            if (-not $versionPair) {
                throw "The $moduleName entry in $Path does not contain a Version property."
            }
            Get-PairStringAst -Pair $versionPair
        } else {
            Get-PairStringAst -Pair $pair
        }
        if (-not $versionAst) {
            throw "The version value for $moduleName in $Path is not a string."
        }

        $newVersion = if ($Latest) { 'latest' } else { $Version[$moduleName] }
        $replacements += [pscustomobject]@{
            StartOffset = $versionAst.Extent.StartOffset
            EndOffset   = $versionAst.Extent.EndOffset
            Text        = "'$newVersion'"
        }
    }

    ConvertTo-ReplacedContent -Content $Content -Replacement $replacements
}

function Get-RequiredModuleSpecification {
    param(
        [string]$Content,
        [string]$Path
    )

    $rootHashtable = Get-DataFileHashtableAst -Content $Content -Path $Path
    $requiredModulesPair = Get-HashtablePair -Hashtable $rootHashtable -Key 'RequiredModules'
    if (-not $requiredModulesPair) {
        return @()
    }

    @(
        $requiredModulesPair.Item2.FindAll(
            { param($node) $node -is [System.Management.Automation.Language.HashtableAst] },
            $true
        ) |
            ForEach-Object {
                $namePair = Get-HashtablePair -Hashtable $_ -Key 'ModuleName'
                $versionPair = Get-HashtablePair -Hashtable $_ -Key 'RequiredVersion'
                if (-not $versionPair) {
                    $versionPair = Get-HashtablePair -Hashtable $_ -Key 'ModuleVersion'
                }
                if ($namePair -and $versionPair) {
                    [pscustomobject]@{
                        ModuleName  = [string](Get-PairStringAst -Pair $namePair).Value
                        VersionPair = $versionPair
                    }
                }
            }
    )
}

function ConvertTo-RequiredModuleContent {
    param(
        [string]$Content,
        [string]$Path,
        [hashtable]$Version
    )

    $replacements = @()
    foreach ($specification in Get-RequiredModuleSpecification -Content $Content -Path $Path) {
        if (-not $Version.ContainsKey($specification.ModuleName)) {
            throw "No initialized version was found for required module $($specification.ModuleName)."
        }

        $versionAst = Get-PairStringAst -Pair $specification.VersionPair
        if ($specification.VersionPair.Item1.Value -ne 'RequiredVersion') {
            $replacements += [pscustomobject]@{
                StartOffset = $specification.VersionPair.Item1.Extent.StartOffset
                EndOffset   = $specification.VersionPair.Item1.Extent.EndOffset
                Text        = 'RequiredVersion'
            }
        }
        $replacements += [pscustomobject]@{
            StartOffset = $versionAst.Extent.StartOffset
            EndOffset   = $versionAst.Extent.EndOffset
            Text        = "'$($Version[$specification.ModuleName])'"
        }
    }

    ConvertTo-ReplacedContent -Content $Content -Replacement $replacements
}

function ConvertTo-ManifestVersionContent {
    param(
        [string]$Content,
        [string]$Path,
        [version]$Version
    )

    $hashtable = Get-DataFileHashtableAst -Content $Content -Path $Path
    $versionPair = Get-HashtablePair -Hashtable $hashtable -Key 'ModuleVersion'
    if (-not $versionPair) {
        throw "ModuleVersion was not found in $Path."
    }

    $versionAst = Get-PairStringAst -Pair $versionPair
    ConvertTo-ReplacedContent -Content $Content -Replacement @(
        [pscustomobject]@{
            StartOffset = $versionAst.Extent.StartOffset
            EndOffset   = $versionAst.Extent.EndOffset
            Text        = "'$Version'"
        }
    )
}

function Get-NextPatchVersion {
    param([version]$Version)

    $patch = if ($Version.Build -lt 0) { 1 } else { $Version.Build + 1 }
    New-Object System.Version -ArgumentList $Version.Major, $Version.Minor, $patch
}

function ConvertTo-DependencyChangelog {
    param(
        [string]$Content,
        [version]$Version,
        [hashtable]$Dependency
    )

    $newline = if ($Content.Contains("`r`n")) { "`r`n" } else { "`n" }
    $dependencyEntries = @(
        $Dependency.Keys |
            Sort-Object |
            ForEach-Object {
                "- Validated and pinned ``$_`` at ``$($Dependency[$_])``."
            }
    )
    $section = @(
        "## [$Version] $(Get-Date -Format 'yyyy-MM-dd')"
        ''
        '### Changed'
        ''
        $dependencyEntries
        ''
        ''
    ) -join $newline

    $firstReleaseHeading = [regex]::Match($Content, '(?m)^##\s+\[')
    if ($firstReleaseHeading.Success) {
        $Content.Insert($firstReleaseHeading.Index, $section)
    } else {
        $Content.TrimEnd() + $newline + $newline + $section
    }
}

if ([string]::IsNullOrWhiteSpace($ProjectRoot)) {
    $ProjectRoot = Split-Path -Path $PSScriptRoot -Parent
}
$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot).Path
if (-not $ModuleManifestPath) {
    $ModuleManifestPath = Get-ProjectManifestPath -Root $ProjectRoot
} elseif (-not [System.IO.Path]::IsPathRooted($ModuleManifestPath)) {
    $ModuleManifestPath = Join-Path -Path $ProjectRoot -ChildPath $ModuleManifestPath
}
$ModuleManifestPath = (Resolve-Path -LiteralPath $ModuleManifestPath).Path

$rootRequirementsPath = Join-Path -Path $ProjectRoot -ChildPath 'requirements.psd1'
$templateRequirementsPath = Join-Path -Path (Split-Path -Path $ModuleManifestPath -Parent) `
    -ChildPath 'template/requirements.psd1'
$isTemplateProject = Test-Path -LiteralPath $templateRequirementsPath -PathType Leaf
$pinnedRequirementsPath = if ($isTemplateProject) {
    $templateRequirementsPath
} else {
    $rootRequirementsPath
}

$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$originalRootRequirements = [System.IO.File]::ReadAllText($rootRequirementsPath)
$originalPinnedRequirements = [System.IO.File]::ReadAllText($pinnedRequirementsPath)
$originalManifest = [System.IO.File]::ReadAllText($ModuleManifestPath)
$changelogPath = Join-Path -Path $ProjectRoot -ChildPath 'CHANGELOG.md'
$originalChangelog = [System.IO.File]::ReadAllText($changelogPath)
$manifestData = Import-PowerShellDataFile -LiteralPath $ModuleManifestPath
$moduleName = [System.IO.Path]::GetFileNameWithoutExtension($ModuleManifestPath)
$currentVersion = [version]$manifestData.ModuleVersion
$requiredSpecifications = @(
    Get-RequiredModuleSpecification -Content $originalManifest -Path $ModuleManifestPath
)

try {
    $rollingRequirements = ConvertTo-RequirementVersionContent `
        -Content $originalRootRequirements `
        -Path $rootRequirementsPath `
        -Latest
    [System.IO.File]::WriteAllText($rootRequirementsPath, $rollingRequirements, $utf8NoBom)

    $powerShellPath = (Get-Process -Id $PID).Path
    $buildPath = Join-Path -Path $ProjectRoot -ChildPath 'build.ps1'
    Push-Location -LiteralPath $ProjectRoot
    try {
        & $powerShellPath -NoLogo -NoProfile -ExecutionPolicy Bypass `
            -File $buildPath -Task Init -Bootstrap |
            Out-Host
        if ($LASTEXITCODE -ne 0) {
            throw "Dependency initialization failed with exit code $LASTEXITCODE."
        }
    } finally {
        Pop-Location
    }

    $rootDependencyNames = Get-RequirementModuleName `
        -Content $rollingRequirements `
        -Path $rootRequirementsPath
    $pinnedDependencyNames = Get-RequirementModuleName `
        -Content $originalPinnedRequirements `
        -Path $pinnedRequirementsPath
    $allDependencyNames = @(
        @('PSDepend') +
        @($rootDependencyNames) +
        @($pinnedDependencyNames) +
        @($requiredSpecifications.ModuleName) |
            Sort-Object -Unique
    )

    $initializedVersions = @{}
    foreach ($dependencyName in $allDependencyNames) {
        $installedModule = Get-Module -Name $dependencyName -ListAvailable |
            Sort-Object Version -Descending |
            Select-Object -First 1
        if (-not $installedModule) {
            Install-Module -Name $dependencyName `
                -Repository PSGallery `
                -Scope CurrentUser `
                -Force `
                -ErrorAction Stop
            $installedModule = Get-Module -Name $dependencyName -ListAvailable |
                Sort-Object Version -Descending |
                Select-Object -First 1
        }
        if (-not $installedModule) {
            throw "$dependencyName was not installed by the bootstrap/initialization step."
        }
        $initializedVersions[$dependencyName] = [string]$installedModule.Version
    }

    $requiredVersions = @{}
    foreach ($module in $requiredSpecifications.ModuleName) {
        $requiredVersions[$module] = $initializedVersions[$module]
    }

    $candidatePinnedRequirements = ConvertTo-RequirementVersionContent `
        -Content $originalPinnedRequirements `
        -Path $pinnedRequirementsPath `
        -Version $initializedVersions
    $candidateManifest = ConvertTo-RequiredModuleContent `
        -Content $originalManifest `
        -Path $ModuleManifestPath `
        -Version $requiredVersions

    if ($isTemplateProject) {
        [System.IO.File]::WriteAllText(
            $rootRequirementsPath,
            $originalRootRequirements,
            $utf8NoBom
        )
    }

    $dependenciesChanged =
        $candidatePinnedRequirements -cne $originalPinnedRequirements -or
        $candidateManifest -cne $originalManifest
    $candidateVersion = $currentVersion

    if ($dependenciesChanged) {
        if ($PSBoundParameters.ContainsKey('ProposedVersion')) {
            $candidateVersion = $ProposedVersion
            if ($candidateVersion -le $currentVersion) {
                throw "ProposedVersion $candidateVersion must be newer than $currentVersion."
            }
        } else {
            $candidateVersion = Get-NextPatchVersion -Version $currentVersion
        }

        $candidateManifest = ConvertTo-ManifestVersionContent `
            -Content $candidateManifest `
            -Path $ModuleManifestPath `
            -Version $candidateVersion
        $candidateChangelog = ConvertTo-DependencyChangelog `
            -Content $originalChangelog `
            -Version $candidateVersion `
            -Dependency $initializedVersions
    } else {
        $candidateChangelog = $originalChangelog
    }

    if ($PSCmdlet.ShouldProcess($pinnedRequirementsPath, 'Write validated dependency pins')) {
        [System.IO.File]::WriteAllText(
            $pinnedRequirementsPath,
            $candidatePinnedRequirements,
            $utf8NoBom
        )
        [System.IO.File]::WriteAllText($ModuleManifestPath, $candidateManifest, $utf8NoBom)
        [System.IO.File]::WriteAllText($changelogPath, $candidateChangelog, $utf8NoBom)
    } else {
        [System.IO.File]::WriteAllText(
            $rootRequirementsPath,
            $originalRootRequirements,
            $utf8NoBom
        )
        if ($pinnedRequirementsPath -ne $rootRequirementsPath) {
            [System.IO.File]::WriteAllText(
                $pinnedRequirementsPath,
                $originalPinnedRequirements,
                $utf8NoBom
            )
        }
        [System.IO.File]::WriteAllText($ModuleManifestPath, $originalManifest, $utf8NoBom)
        [System.IO.File]::WriteAllText($changelogPath, $originalChangelog, $utf8NoBom)
    }
} catch {
    [System.IO.File]::WriteAllText(
        $rootRequirementsPath,
        $originalRootRequirements,
        $utf8NoBom
    )
    if ($pinnedRequirementsPath -ne $rootRequirementsPath) {
        [System.IO.File]::WriteAllText(
            $pinnedRequirementsPath,
            $originalPinnedRequirements,
            $utf8NoBom
        )
    }
    [System.IO.File]::WriteAllText($ModuleManifestPath, $originalManifest, $utf8NoBom)
    [System.IO.File]::WriteAllText($changelogPath, $originalChangelog, $utf8NoBom)
    throw
}

[pscustomobject]@{
    Changed                = $dependenciesChanged
    ModuleName             = $moduleName
    CurrentVersion         = [string]$currentVersion
    ProposedVersion        = [string]$candidateVersion
    InitializedDependencies = [pscustomobject]$initializedVersions
    RequiredModules        = [pscustomobject]$requiredVersions
    PinnedRequirementsPath = $pinnedRequirementsPath.Substring($ProjectRoot.Length).TrimStart('\', '/')
    ModuleManifestPath     = $ModuleManifestPath.Substring($ProjectRoot.Length).TrimStart('\', '/')
    ChangelogPath          = $changelogPath.Substring($ProjectRoot.Length).TrimStart('\', '/')
}