KeepAChangelog.psm1

Set-StrictMode -Version Latest

# Source: src/public/Convert-ChangelogReleaseNotesToTagMessage.ps1
function Add-ChangelogTagMessageBlankLine {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$LineList,
        [Parameter(Mandatory)]
        [bool]$AllowBlankLine
    )

    if (-not $AllowBlankLine -or $LineList.Count -eq 0) {
        return $false
    }

    $LineList.Add('')
    return $false
}

function Get-ChangelogTagMessageLine {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$TrimmedLine
    )

    if ($TrimmedLine -match '^###\s+(?<value>.+)$') {
        return $Matches.value
    }

    if ($TrimmedLine -match '^(?:-|\*)\s+(?<value>.+)$') {
        return $Matches.value
    }

    return $TrimmedLine
}

function Remove-ChangelogTagMessageTrailingBlanks {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$LineList
    )

    while ($LineList.Count -gt 0 -and [string]::IsNullOrWhiteSpace($LineList[$LineList.Count - 1])) {
        $LineList.RemoveAt($LineList.Count - 1)
    }
}

function Convert-ChangelogReleaseNotesToTagMessage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$ReleaseNotes
    )

    $lineList = [System.Collections.Generic.List[string]]::new()
    $allowBlankLine = $false

    foreach ($line in $ReleaseNotes -split '\r?\n') {
        $trimmedLine = $line.Trim()

        if ([string]::IsNullOrWhiteSpace($trimmedLine)) {
            $allowBlankLine = Add-ChangelogTagMessageBlankLine -LineList $lineList -AllowBlankLine $allowBlankLine
            continue
        }

        $lineList.Add((Get-ChangelogTagMessageLine -TrimmedLine $trimmedLine))
        $allowBlankLine = $true
    }

    Remove-ChangelogTagMessageTrailingBlanks -LineList $lineList
    return ($lineList -join "`n").Trim()
}


# Source: src/public/Get-KeepAChangelogVersion.ps1
function Get-KeepAChangelogVersion {
    [CmdletBinding()]
    [OutputType([string])]
    param()

    return Get-KeepAChangelogModuleVersion
}


# Source: src/public/Initialize-KeepAChangelogFile.ps1
function Initialize-KeepAChangelogFile {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Position = 0)]
        [string]$Path = 'CHANGELOG.md',

        [Parameter(Mandatory)]
        [string]$RepositoryUrl,

        [string]$PreviousReleaseReference,

        [string[]]$SectionHeading = @('Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security'),

        [switch]$Force
    )

    Assert-KeepAChangelogInitialization -Path $Path -RepositoryUrl $RepositoryUrl -Force $Force.IsPresent
    $normalizedRepositoryUrl = $RepositoryUrl.TrimEnd('/')
    $template = New-KeepAChangelogTemplate `
        -RepositoryUrl $normalizedRepositoryUrl `
        -PreviousReleaseReference $PreviousReleaseReference `
        -SectionHeading $SectionHeading

    if (-not $PSCmdlet.ShouldProcess($Path, 'Initialize Keep a Changelog template')) {
        return
    }

    Set-Content -LiteralPath $Path -Value $template -Encoding utf8

    return [pscustomobject]@{
        Path                     = $Path
        RepositoryUrl            = $normalizedRepositoryUrl
        PreviousReleaseReference = if ([string]::IsNullOrWhiteSpace($PreviousReleaseReference)) { $null } else { $PreviousReleaseReference }
    }
}


# Source: src/public/Move-UnreleasedChangelog.ps1
function Move-UnreleasedChangelog {
    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Position = 0)]
        [string]$Path = 'CHANGELOG.md',

        [Parameter(Mandatory)]
        [string]$Version,

        [string]$Date,

        [string]$RepositoryUrl
    )

    if ([string]::IsNullOrWhiteSpace($Version)) {
        throw 'Version is required.'
    }

    if (-not (Test-Path -LiteralPath $Path)) {
        throw "Could not find CHANGELOG file at '$Path'."
    }

    $releaseDate = Resolve-KeepAChangelogReleaseDate -Date $Date
    $release = @{
        Version = $Version
        Date    = $releaseDate
        Tag     = $Version
    }

    $text = Get-Content -LiteralPath $Path -Raw
    $result = Resolve-KeepAChangelogReleaseData -Text $text -Release $release -RepositoryUrl $RepositoryUrl
    $result | Add-Member -NotePropertyName KeepAChangelogVersion -NotePropertyValue (Get-KeepAChangelogModuleVersion) -Force
    $targetVersion = $result.Release.Version

    if (-not $PSCmdlet.ShouldProcess($Path, "Promote [Unreleased] to [$targetVersion]")) {
        return $result
    }

    Set-Content -LiteralPath $Path -Value $result.UpdatedText -Encoding utf8
    return $result
}


# Source: src/public/Test-KeepAChangelogFile.ps1
function Test-KeepAChangelogFile {
    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]$Path = 'CHANGELOG.md',

        [switch]$ThrowOnError
    )

    if (-not (Test-Path -LiteralPath $Path)) {
        throw "Could not find CHANGELOG file at '$Path'."
    }

    $text = Get-Content -LiteralPath $Path -Raw
    $result = Get-KeepAChangelogValidationResult -Text $text
    $result | Add-Member -NotePropertyName Path -NotePropertyValue $Path -Force

    if ($ThrowOnError -and -not $result.IsValid) {
        throw ($result.Errors -join ' ')
    }

    return $result
}


# Source: src/private/initialize/Assert-KeepAChangelogInitialization.ps1
function Assert-KeepAChangelogInitialization {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path,
        [Parameter(Mandatory)]
        [string]$RepositoryUrl,
        [Parameter(Mandatory)]
        [bool]$Force
    )

    if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) {
        throw 'RepositoryUrl is required.'
    }

    if ((Test-Path -LiteralPath $Path) -and -not $Force) {
        throw "File already exists at '$Path'. Use -Force to overwrite it."
    }
}


# Source: src/private/initialize/Get-KeepAChangelogFooterLine.ps1
function Get-KeepAChangelogFooterLine {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$RepositoryUrl,
        [string]$PreviousReleaseReference
    )

    if ([string]::IsNullOrWhiteSpace($PreviousReleaseReference)) {
        return $null
    }

    return "[Unreleased]: $RepositoryUrl/compare/$PreviousReleaseReference...HEAD"
}


# Source: src/private/initialize/Get-KeepAChangelogHeadingLines.ps1
function Get-KeepAChangelogHeadingLines {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string[]]$SectionHeading
    )

    return @(
        foreach ($heading in $SectionHeading) {
            "### $heading"
            ''
        }
    )
}


# Source: src/private/initialize/New-KeepAChangelogTemplate.ps1
function New-KeepAChangelogTemplate {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$RepositoryUrl,
        [string]$PreviousReleaseReference,
        [Parameter(Mandatory)]
        [string[]]$SectionHeading
    )

    $footerLine = Get-KeepAChangelogFooterLine -RepositoryUrl $RepositoryUrl -PreviousReleaseReference $PreviousReleaseReference
    $lineList = @(
        '# Changelog'
        ''
        'All notable changes to this project will be documented in this file.'
        ''
        'The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),'
        'and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).'
        ''
        '## [Unreleased]'
        ''
        (Get-KeepAChangelogHeadingLines -SectionHeading $SectionHeading)
        $footerLine
    ) | Where-Object { $null -ne $_ }

    return (($lineList -join "`n").TrimEnd() + "`n")
}


# Source: src/private/release/Assert-KeepAChangelogRelease.ps1
function Assert-KeepAChangelogRelease {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Release
    )

    $requiredKeyList = @('Version', 'Date', 'Tag')

    foreach ($requiredKey in $requiredKeyList) {
        if (-not $Release.ContainsKey($requiredKey)) {
            throw "Release.$requiredKey is required."
        }

        if ([string]::IsNullOrWhiteSpace([string]$Release[$requiredKey])) {
            throw "Release.$requiredKey is required."
        }
    }

    return [pscustomobject]@{
        Version = [string]$Release.Version
        Date    = [string]$Release.Date
        Tag     = [string]$Release.Tag
    }
}


# Source: src/private/release/Assert-KeepAChangelogReleaseDateOrder.ps1
function Get-KeepAChangelogLatestReleaseDate {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Body
    )

    $latestReleaseDate = $null
    foreach ($match in [regex]::Matches($Body, '(?m)^## \[[^\]]+\] - (?<date>\d{4}-\d{2}-\d{2})$')) {
        $releaseDate = [datetime]::ParseExact(
            $match.Groups['date'].Value,
            'yyyy-MM-dd',
            [System.Globalization.CultureInfo]::InvariantCulture
        )

        if ($null -eq $latestReleaseDate -or $releaseDate -gt $latestReleaseDate) {
            $latestReleaseDate = $releaseDate
        }
    }

    if ($null -eq $latestReleaseDate) {
        return $null
    }

    return $latestReleaseDate.ToString('yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture)
}

function Assert-KeepAChangelogReleaseDateOrder {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Body,
        [Parameter(Mandatory)]
        [pscustomobject]$Release
    )

    $latestExistingReleaseDate = Get-KeepAChangelogLatestReleaseDate -Body $Body
    if ([string]::IsNullOrWhiteSpace($latestExistingReleaseDate)) {
        return
    }

    $releaseDate = [datetime]::ParseExact(
        $Release.Date,
        'yyyy-MM-dd',
        [System.Globalization.CultureInfo]::InvariantCulture
    )
    $latestReleaseDate = [datetime]::ParseExact(
        $latestExistingReleaseDate,
        'yyyy-MM-dd',
        [System.Globalization.CultureInfo]::InvariantCulture
    )

    if ($releaseDate -lt $latestReleaseDate) {
        throw "Release.Date '$($Release.Date)' cannot be earlier than latest existing release date '$latestExistingReleaseDate'."
    }
}


# Source: src/private/release/Get-ChangelogReleaseNotesBody.ps1
function Get-ChangelogReleaseNotesBody {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Body
    )

    $trimmedBody = $Body.Trim()

    if ([string]::IsNullOrWhiteSpace($trimmedBody)) {
        return ''
    }

    if ($trimmedBody -notmatch '(?m)^[ \t]*###\s+') {
        return $trimmedBody
    }

    $sectionPattern = '(?ms)^(?<heading>[ \t]*###\s+[^\r\n]+)\r?\n(?<content>.*?)(?=^[ \t]*###\s+|\z)'
    $sectionMatchList = [regex]::Matches($trimmedBody, $sectionPattern)
    $sectionTextList = [System.Collections.Generic.List[string]]::new()

    foreach ($sectionMatch in $sectionMatchList) {
        $heading = $sectionMatch.Groups['heading'].Value.TrimEnd()
        $content = $sectionMatch.Groups['content'].Value.Trim()

        if ([string]::IsNullOrWhiteSpace($content)) {
            continue
        }

        $sectionTextList.Add("$heading`n`n$content")
    }

    return ($sectionTextList -join "`n`n").Trim()
}


# Source: src/private/release/Get-ClearedUnreleasedBody.ps1
function Get-ClearedUnreleasedBody {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Body
    )

    $headingList = @(
        [regex]::Matches($Body, '(?m)^[ \t]*###\s+[^\r\n]+') |
            ForEach-Object { $_.Value.TrimEnd() }
    )

    if (-not $headingList) {
        return ''
    }

    return ($headingList -join "`n`n").TrimEnd()
}


# Source: src/private/release/Get-KeepAChangelogPreviousReleaseReference.ps1
function Get-ChangelogReleaseTargetReference {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Link
    )

    $compareMatch = [regex]::Match($Link, '/compare/.+\.\.\.(?<target>.+)$')
    if ($compareMatch.Success) {
        return $compareMatch.Groups['target'].Value
    }

    $tagMatch = [regex]::Match($Link, '/releases/tag/(?<target>.+)$')
    if ($tagMatch.Success) {
        return $tagMatch.Groups['target'].Value
    }

    return $null
}

function Get-KeepAChangelogPreviousReleaseReference {
    [CmdletBinding()]
    param(
        [AllowEmptyString()]
        [string]$Footer,
        [Parameter(Mandatory)]
        [pscustomobject]$Validation,
        [Parameter(Mandatory)]
        [pscustomobject]$Release
    )

    $previousReleaseReference = $Validation.PreviousReleaseReference
    if ([string]::IsNullOrWhiteSpace($previousReleaseReference)) {
        return $null
    }

    if ($previousReleaseReference -ne $Release.Tag) {
        return $previousReleaseReference
    }

    $referenceLinkData = Get-ChangelogReferenceLinkData -Footer $Footer
    foreach ($releaseVersion in $Validation.ReleaseVersions) {
        if (-not $referenceLinkData.LinkMap.Contains($releaseVersion)) {
            continue
        }

        $releaseTargetReference = Get-ChangelogReleaseTargetReference -Link $referenceLinkData.LinkMap[$releaseVersion]
        if (-not [string]::IsNullOrWhiteSpace($releaseTargetReference) -and $releaseTargetReference -ne $Release.Tag) {
            return $releaseTargetReference
        }
    }

    return $null
}


# Source: src/private/release/Get-UnreleasedSectionMatch.ps1
function Get-UnreleasedSectionMatch {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Text
    )

    $pattern = '(?ms)^##\s+\[Unreleased\]\s*\r?\n(?<body>.*?)(?=^##\s+\[|\z)'
    $match = [regex]::Match($Text, $pattern)

    if (-not $match.Success) {
        throw 'Could not find ## [Unreleased] section in CHANGELOG.md.'
    }

    return $match
}


# Source: src/private/release/Get-UpdatedChangelogReferenceFooter.ps1
function Get-ChangelogReferenceLinkData {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Footer
    )

    $orderedLabelList = [System.Collections.Generic.List[string]]::new()
    $linkMap = [ordered]@{}

    foreach ($referenceMatch in [regex]::Matches($Footer, '(?m)^\[(?<label>[^\]]+)\]:\s*(?<url>\S.*?)\s*$')) {
        $label = $referenceMatch.Groups['label'].Value
        $url = $referenceMatch.Groups['url'].Value.Trim()

        if (-not $linkMap.Contains($label)) {
            $orderedLabelList.Add($label)
        }

        $linkMap[$label] = $url
    }

    return [pscustomobject]@{
        OrderedLabelList = $orderedLabelList
        LinkMap          = $linkMap
    }
}

function Get-NormalizedChangelogRepositoryUrl {
    [CmdletBinding()]
    param(
        [string]$RepositoryUrl,
        [Parameter(Mandatory)]
        [string]$UnreleasedCompareLinkPrefix
    )

    if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) {
        return ($UnreleasedCompareLinkPrefix -replace '/compare/$', '')
    }

    return $RepositoryUrl.TrimEnd('/')
}

function Get-ChangelogReleaseLink {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$RepositoryUrl,
        [Parameter(Mandatory)]
        [string]$UnreleasedCompareLinkPrefix,
        [AllowEmptyString()]
        [string]$PreviousReleaseReference,
        [Parameter(Mandatory)]
        [string]$ReleaseTag
    )

    if ([string]::IsNullOrWhiteSpace($PreviousReleaseReference)) {
        return "$RepositoryUrl/releases/tag/$ReleaseTag"
    }

    return "$UnreleasedCompareLinkPrefix$PreviousReleaseReference...$ReleaseTag"
}

function Add-ChangelogReferenceLabel {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.Generic.List[string]]$OrderedLabelList,
        [Parameter(Mandatory)]
        [hashtable]$LinkMap,
        [Parameter(Mandatory)]
        [string]$Label
    )

    if ($LinkMap.Contains($Label)) {
        return
    }

    $OrderedLabelList.Insert($OrderedLabelList.IndexOf('Unreleased') + 1, $Label)
}

function Get-UpdatedChangelogReferenceFooter {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Footer,

        [Parameter(Mandatory)]
        [pscustomobject]$Release,

        [Parameter(Mandatory)]
        [pscustomobject]$Context
    )

    $referenceLinkData = Get-ChangelogReferenceLinkData -Footer $Footer
    $orderedLabelList = $referenceLinkData.OrderedLabelList
    $linkMap = $referenceLinkData.LinkMap

    if (-not $linkMap.Contains('Unreleased')) {
        $orderedLabelList.Add('Unreleased')
    }

    $normalizedRepositoryUrl = Get-NormalizedChangelogRepositoryUrl `
        -RepositoryUrl $Context.RepositoryUrl `
        -UnreleasedCompareLinkPrefix $Context.UnreleasedCompareLinkPrefix
    $updatedUnreleasedLink = "$($Context.UnreleasedCompareLinkPrefix)$($Release.Tag)...HEAD"
    $newReleaseLink = Get-ChangelogReleaseLink `
        -RepositoryUrl $normalizedRepositoryUrl `
        -UnreleasedCompareLinkPrefix $Context.UnreleasedCompareLinkPrefix `
        -PreviousReleaseReference $Context.PreviousReleaseReference `
        -ReleaseTag $Release.Tag
    $linkMap['Unreleased'] = $updatedUnreleasedLink

    Add-ChangelogReferenceLabel -OrderedLabelList $orderedLabelList -LinkMap $linkMap -Label $Release.Version
    $linkMap[$Release.Version] = $newReleaseLink

    $footerLineList = foreach ($label in $orderedLabelList) {
        "[$label]: $($linkMap[$label])"
    }

    return [pscustomobject]@{
        Footer               = ($footerLineList -join "`n").TrimEnd()
        UpdatedUnreleasedLink = $updatedUnreleasedLink
        NewReleaseCompareLink = $newReleaseLink
        NewReleaseLink        = $newReleaseLink
    }
}


# Source: src/private/release/Resolve-KeepAChangelogReleaseData.ps1
function Assert-KeepAChangelogValidation {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [pscustomobject]$Validation
    )

    if (-not $Validation.IsValid) {
        throw "CHANGELOG.md is not valid. $($Validation.Errors -join ' ')"
    }
}

function Get-KeepAChangelogRepositoryContext {
    [CmdletBinding()]
    param(
        [string]$RepositoryUrl,
        [AllowEmptyString()]
        [string]$Footer,
        [Parameter(Mandatory)]
        [pscustomobject]$Release,
        [Parameter(Mandatory)]
        [pscustomobject]$Validation
    )

    $normalizedRepositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) {
        $null
    }
    else {
        $RepositoryUrl.TrimEnd('/')
    }

    $unreleasedCompareLinkPrefix = $Validation.UnreleasedCompareLinkPrefix
    if ([string]::IsNullOrWhiteSpace($unreleasedCompareLinkPrefix)) {
        if ([string]::IsNullOrWhiteSpace($normalizedRepositoryUrl)) {
            throw 'RepositoryUrl is required for the first release when CHANGELOG.md has no [Unreleased] compare link.'
        }

        $unreleasedCompareLinkPrefix = "$normalizedRepositoryUrl/compare/"
    }

    if ([string]::IsNullOrWhiteSpace($normalizedRepositoryUrl)) {
        $normalizedRepositoryUrl = $unreleasedCompareLinkPrefix -replace '/compare/$', ''
    }

    $previousReleaseReference = Get-KeepAChangelogPreviousReleaseReference `
        -Footer $Footer `
        -Validation $Validation `
        -Release $Release

    return [pscustomobject]@{
        RepositoryUrl               = $normalizedRepositoryUrl
        UnreleasedCompareLinkPrefix = $unreleasedCompareLinkPrefix
        PreviousReleaseReference    = $previousReleaseReference
    }
}

function Get-KeepAChangelogSectionText {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Heading,
        [AllowEmptyString()]
        [string]$Body
    )

    if ([string]::IsNullOrWhiteSpace($Body)) {
        return $Heading
    }

    return "$Heading`n`n$Body"
}

function Get-UpdatedChangelogBodyText {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [pscustomobject]$Parts,
        [Parameter(Mandatory)]
        [System.Text.RegularExpressions.Match]$UnreleasedSectionMatch,
        [Parameter(Mandatory)]
        [string]$UnreleasedSection,
        [Parameter(Mandatory)]
        [string]$ReleaseSection
    )

    $beforeUnreleased = $Parts.Body.Substring(0, $UnreleasedSectionMatch.Index).TrimEnd()
    $afterUnreleasedStart = $UnreleasedSectionMatch.Index + $UnreleasedSectionMatch.Length
    $afterUnreleased = $Parts.Body.Substring($afterUnreleasedStart).TrimStart("`r", "`n")

    return (@(
            $beforeUnreleased
            $UnreleasedSection
            $ReleaseSection
            $afterUnreleased
        ) |
        Where-Object {-not [string]::IsNullOrWhiteSpace($_)} |
        ForEach-Object {$_.TrimEnd()} |
        Join-String -Separator "`n`n").TrimEnd()
}

function Resolve-KeepAChangelogReleaseData {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Text,

        [Parameter(Mandatory)]
        [hashtable]$Release,

        [string]$RepositoryUrl
    )

    $normalizedRelease = Assert-KeepAChangelogRelease -Release $Release
    $validation = Get-KeepAChangelogValidationResult -Text $Text
    Assert-KeepAChangelogValidation -Validation $validation
    $parts = Split-KeepAChangelogText -Text $Text
    Assert-KeepAChangelogReleaseDateOrder -Body $parts.Body -Release $normalizedRelease
    $unreleasedSectionMatch = Get-UnreleasedSectionMatch -Text $parts.Body
    $repositoryContext = Get-KeepAChangelogRepositoryContext `
        -RepositoryUrl $RepositoryUrl `
        -Footer $parts.Footer `
        -Release $normalizedRelease `
        -Validation $validation
    $unreleasedBody = $unreleasedSectionMatch.Groups['body'].Value.Trim()
    $releaseNotesBody = Get-ChangelogReleaseNotesBody -Body $unreleasedBody
    $clearedUnreleasedBody = Get-ClearedUnreleasedBody -Body $unreleasedBody
    $unreleasedSection = Get-KeepAChangelogSectionText -Heading '## [Unreleased]' -Body $clearedUnreleasedBody
    $newReleaseSection = Get-KeepAChangelogSectionText -Heading "## [$($normalizedRelease.Version)] - $($normalizedRelease.Date)" -Body $releaseNotesBody
    $updatedBody = Get-UpdatedChangelogBodyText `
        -Parts $parts `
        -UnreleasedSectionMatch $unreleasedSectionMatch `
        -UnreleasedSection $unreleasedSection `
        -ReleaseSection $newReleaseSection
    $updatedFooterData = Get-UpdatedChangelogReferenceFooter `
        -Footer $parts.Footer `
        -Release $normalizedRelease `
        -Context $repositoryContext
    $updatedText = ($updatedBody + "`n`n" + $updatedFooterData.Footer).TrimEnd() + "`n"

    return [pscustomobject]@{
        Release                     = $normalizedRelease
        UnreleasedCompareLinkPrefix = $repositoryContext.UnreleasedCompareLinkPrefix
        PreviousReleaseReference    = $repositoryContext.PreviousReleaseReference
        UnreleasedBody              = $unreleasedBody
        ReleaseNotesBody            = $releaseNotesBody
        ClearedUnreleasedBody       = $clearedUnreleasedBody
        NewReleaseSection           = $newReleaseSection
        UpdatedUnreleasedLink       = $updatedFooterData.UpdatedUnreleasedLink
        NewReleaseCompareLink       = $updatedFooterData.NewReleaseCompareLink
        NewReleaseLink              = $updatedFooterData.NewReleaseLink
        TagMessageText              = Convert-ChangelogReleaseNotesToTagMessage -ReleaseNotes $releaseNotesBody
        UpdatedText                 = $updatedText
    }
}


# Source: src/private/release/Resolve-KeepAChangelogReleaseDate.ps1
function Resolve-KeepAChangelogReleaseDate {
    [CmdletBinding()]
    param(
        [AllowEmptyString()]
        [string]$Date
    )

    if ([string]::IsNullOrWhiteSpace($Date)) {
        return (Get-Date).ToString('yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture)
    }

    $parsedDate = [datetime]::MinValue
    $isValidDate = [datetime]::TryParseExact(
        $Date,
        'yyyy-MM-dd',
        [System.Globalization.CultureInfo]::InvariantCulture,
        [System.Globalization.DateTimeStyles]::None,
        [ref]$parsedDate
    )

    if (-not $isValidDate) {
        throw "Date must use yyyy-MM-dd format. Received: '$Date'."
    }

    return $parsedDate.ToString('yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture)
}


# Source: src/private/shared/Get-KeepAChangelogModuleVersion.ps1
function Get-KeepAChangelogModuleVersion {
    [CmdletBinding()]
    param()

    return $ExecutionContext.SessionState.Module.Version.ToString()
}


# Source: src/private/shared/Split-KeepAChangelogText.ps1
function Split-KeepAChangelogText {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Text
    )

    $footerMatch = [regex]::Match($Text, '(?ms)(?<footer>(?:^\[[^\]]+\]:[^\r\n]*(?:\r?\n|$))+)\s*\z')

    if ($footerMatch.Success) {
        return [pscustomobject]@{
            Body   = $Text.Substring(0, $footerMatch.Index).TrimEnd()
            Footer = $footerMatch.Groups['footer'].Value.TrimEnd()
        }
    }

    return [pscustomobject]@{
        Body   = $Text.TrimEnd()
        Footer = ''
    }
}


# Source: src/private/validation/Add-KeepAChangelogDuplicateReferenceLinkError.ps1
function Add-KeepAChangelogDuplicateReferenceLinkError {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$ReferenceLabelCountMap,
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[string]]$ErrorList
    )

    foreach ($label in $ReferenceLabelCountMap.Keys) {
        if ($ReferenceLabelCountMap[$label] -le 1) {
            continue
        }

        $ErrorList.Add("Reference link [$label] is duplicated.")
    }
}


# Source: src/private/validation/Get-KeepAChangelogReferenceLabelCountMap.ps1
function Get-KeepAChangelogReferenceLabelCountMap {
    [CmdletBinding()]
    param(
        [AllowEmptyString()]
        [string]$Footer
    )

    $referenceLabelCountMap = @{}
    if ([string]::IsNullOrWhiteSpace($Footer)) {
        return $referenceLabelCountMap
    }

    foreach ($referenceMatch in [regex]::Matches($Footer, '(?m)^\[(?<label>[^\]]+)\]:')) {
        $label = $referenceMatch.Groups['label'].Value
        if (-not $referenceLabelCountMap.ContainsKey($label)) {
            $referenceLabelCountMap[$label] = 0
        }

        $referenceLabelCountMap[$label]++
    }

    return $referenceLabelCountMap
}


# Source: src/private/validation/Get-KeepAChangelogReleaseVersionList.ps1
function Get-KeepAChangelogReleaseVersionList {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Body,
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[string]]$ErrorList
    )

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

    foreach ($releaseHeadingLine in [regex]::Matches($Body, '(?m)^##\s+\[(?<label>[^\]]+)\].*$')) {
        $label = $releaseHeadingLine.Groups['label'].Value
        if ($label -eq 'Unreleased') {
            continue
        }

        $line = $releaseHeadingLine.Value.TrimEnd()
        if ($line -notmatch '^## \[[^\]]+\] - \d{4}-\d{2}-\d{2}$') {
            $ErrorList.Add("Release section '$line' must use '## [<version>] - <date>' format.")
            continue
        }

        if ($releaseVersionList.Contains($label)) {
            $ErrorList.Add("Release section [$label] is duplicated.")
            continue
        }

        $releaseVersionList.Add($label)
    }

    return ,$releaseVersionList
}


# Source: src/private/validation/Get-KeepAChangelogUnreleasedCompareLinkData.ps1
function Get-KeepAChangelogUnreleasedCompareLinkData {
    [CmdletBinding()]
    param(
        [AllowEmptyString()]
        [string]$Footer,
        [switch]$HasReferenceFooter,
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[string]]$ErrorList
    )

    $result = [pscustomobject]@{
        UnreleasedCompareLinkPrefix = $null
        PreviousReleaseReference    = $null
    }

    if (-not $HasReferenceFooter) {
        return $result
    }

    $pattern = '(?m)^\[Unreleased\]:\s*(?<prefix>\S+/compare/)(?<from>.+?)\.\.\.HEAD\s*$'
    $matchList = [regex]::Matches($Footer, $pattern)

    if ($matchList.Count -eq 0) {
        $ErrorList.Add('Could not find an [Unreleased] compare link in CHANGELOG.md.')
        return $result
    }

    if ($matchList.Count -gt 1) {
        $ErrorList.Add('CHANGELOG.md must contain exactly one [Unreleased] compare link.')
    }

    $match = $matchList[0]
    return [pscustomobject]@{
        UnreleasedCompareLinkPrefix = $match.Groups['prefix'].Value
        PreviousReleaseReference    = $match.Groups['from'].Value
    }
}


# Source: src/private/validation/Get-KeepAChangelogValidationResult.ps1
function Get-KeepAChangelogValidationResult {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Text
    )

    $errorList = [System.Collections.Generic.List[string]]::new()
    $parts = Split-KeepAChangelogText -Text $Text
    if (-not (Test-KeepAChangelogHasUnreleasedSection -Body $parts.Body)) {
        $errorList.Add('Could not find ## [Unreleased] section in CHANGELOG.md.')
    }

    $releaseVersionList = Get-KeepAChangelogReleaseVersionList `
        -Body $parts.Body `
        -ErrorList $errorList
    $hasReferenceFooter = -not [string]::IsNullOrWhiteSpace($parts.Footer)

    if (-not $hasReferenceFooter -and $releaseVersionList.Count -gt 0) {
        $errorList.Add('CHANGELOG.md must end with reference links once releases exist.')
    }

    $referenceLabelCountMap = Get-KeepAChangelogReferenceLabelCountMap -Footer $parts.Footer
    Add-KeepAChangelogDuplicateReferenceLinkError `
        -ReferenceLabelCountMap $referenceLabelCountMap `
        -ErrorList $errorList

    $unreleasedCompareLinkData = Get-KeepAChangelogUnreleasedCompareLinkData `
        -Footer $parts.Footer `
        -HasReferenceFooter:$hasReferenceFooter `
        -ErrorList $errorList

    return [pscustomobject]@{
        IsValid                     = ($errorList.Count -eq 0)
        Errors                      = @($errorList.ToArray())
        UnreleasedCompareLinkPrefix = $unreleasedCompareLinkData.UnreleasedCompareLinkPrefix
        PreviousReleaseReference    = $unreleasedCompareLinkData.PreviousReleaseReference
        ReleaseVersions             = @($releaseVersionList.ToArray())
    }
}


# Source: src/private/validation/Get-UnreleasedCompareLinkMatch.ps1
function Get-UnreleasedCompareLinkMatch {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Text
    )

    $pattern = '(?m)^\[Unreleased\]:\s*(?<prefix>\S+/compare/)(?<from>.+?)\.\.\.HEAD\s*$'
    $match = [regex]::Match($Text, $pattern)

    if (-not $match.Success) {
        throw 'Could not find an [Unreleased] compare link in CHANGELOG.md.'
    }

    return $match
}


# Source: src/private/validation/Test-KeepAChangelogHasUnreleasedSection.ps1
function Test-KeepAChangelogHasUnreleasedSection {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Body
    )

    return $Body -match '(?ms)^##\s+\[Unreleased\]\s*\r?\n'
}