KeepAChangelog.psm1

Set-StrictMode -Version Latest

# Source: src/public/Convert-ChangelogReleaseNotesToTagMessage.ps1
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)) {
            if ($allowBlankLine -and $lineList.Count -gt 0) {
                $lineList.Add('')
                $allowBlankLine = $false
            }

            continue
        }

        if ($trimmedLine -match '^###\s+(?<value>.+)$') {
            $lineList.Add($Matches.value)
            $allowBlankLine = $true
            continue
        }

        if ($trimmedLine -match '^(?:-|\*)\s+(?<value>.+)$') {
            $lineList.Add($Matches.value)
            $allowBlankLine = $true
            continue
        }

        $lineList.Add($trimmedLine)
        $allowBlankLine = $true
    }

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

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

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


# 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
    )

    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."
    }

    $normalizedRepositoryUrl = $RepositoryUrl.TrimEnd('/')
    $hasPreviousReleaseReference = -not [string]::IsNullOrWhiteSpace($PreviousReleaseReference)
    $lineList = [System.Collections.Generic.List[string]]::new()
    $lineList.Add('# Changelog')
    $lineList.Add('')
    $lineList.Add('All notable changes to this project will be documented in this file.')
    $lineList.Add('')
    $lineList.Add('The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),')
    $lineList.Add('and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).')
    $lineList.Add('')
    $lineList.Add('## [Unreleased]')
    $lineList.Add('')

    foreach ($heading in $SectionHeading) {
        $lineList.Add("### $heading")
        $lineList.Add('')
    }

    if ($hasPreviousReleaseReference) {
        $lineList.Add("[Unreleased]: $normalizedRepositoryUrl/compare/$PreviousReleaseReference...HEAD")
    }

    $template = ($lineList -join "`n").TrimEnd() + "`n"

    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 ($hasPreviousReleaseReference) { $PreviousReleaseReference } else { $null }
    }
}


# 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
    $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/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/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/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/Get-KeepAChangelogValidationResult.ps1
function Get-KeepAChangelogValidationResult {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Text
    )

    $errorList = [System.Collections.Generic.List[string]]::new()
    $parts = Split-KeepAChangelogText -Text $Text
    $unreleasedCompareLinkPrefix = $null
    $previousReleaseReference = $null

    if ($parts.Body -notmatch '(?ms)^##\s+\[Unreleased\]\s*\r?\n') {
        $errorList.Add('Could not find ## [Unreleased] section in CHANGELOG.md.')
    }

    $releaseHeadingLineList = @(
        [regex]::Matches($parts.Body, '(?m)^##\s+\[(?<label>[^\]]+)\].*$') |
            ForEach-Object { $_ }
    )
    $releaseVersionList = [System.Collections.Generic.List[string]]::new()

    foreach ($releaseHeadingLine in $releaseHeadingLineList) {
        $label = $releaseHeadingLine.Groups['label'].Value
        $line = $releaseHeadingLine.Value.TrimEnd()

        if ($label -eq 'Unreleased') {
            continue
        }

        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)
    }

    $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 = @{}

    if ($hasReferenceFooter) {
        foreach ($referenceMatch in [regex]::Matches($parts.Footer, '(?m)^\[(?<label>[^\]]+)\]:')) {
            $label = $referenceMatch.Groups['label'].Value

            if (-not $referenceLabelCountMap.ContainsKey($label)) {
                $referenceLabelCountMap[$label] = 0
            }

            $referenceLabelCountMap[$label]++
        }
    }

    foreach ($label in $referenceLabelCountMap.Keys) {
        if ($referenceLabelCountMap[$label] -gt 1) {
            $errorList.Add("Reference link [$label] is duplicated.")
        }
    }

    if ($hasReferenceFooter) {
        $unreleasedCompareLinkPattern = '(?m)^\[Unreleased\]:\s*(?<prefix>\S+/compare/)(?<from>.+?)\.\.\.HEAD\s*$'
        $unreleasedCompareLinkMatchList = [regex]::Matches($parts.Footer, $unreleasedCompareLinkPattern)

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

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

        if ($unreleasedCompareLinkMatchList.Count -ge 1) {
            $unreleasedCompareLinkPrefix = $unreleasedCompareLinkMatchList[0].Groups['prefix'].Value
            $previousReleaseReference = $unreleasedCompareLinkMatchList[0].Groups['from'].Value
        }
    }

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


# Source: src/private/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/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/Get-UpdatedChangelogReferenceFooter.ps1
function Get-UpdatedChangelogReferenceFooter {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Footer,

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

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

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

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$PreviousReleaseReference,

        [string]$RepositoryUrl
    )

    $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
    }

    if (-not $linkMap.Contains('Unreleased')) {
        $orderedLabelList.Insert(0, 'Unreleased')
    }

    $normalizedRepositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) {
        $UnreleasedCompareLinkPrefix -replace '/compare/$', ''
    } else {
        $RepositoryUrl.TrimEnd('/')
    }
    $updatedUnreleasedLink = "$UnreleasedCompareLinkPrefix$ReleaseTag...HEAD"
    $newReleaseLink = if ([string]::IsNullOrWhiteSpace($PreviousReleaseReference)) {
        "$normalizedRepositoryUrl/releases/tag/$ReleaseTag"
    } else {
        "$UnreleasedCompareLinkPrefix$PreviousReleaseReference...$ReleaseTag"
    }
    $linkMap['Unreleased'] = $updatedUnreleasedLink

    if (-not $linkMap.Contains($ReleaseVersion)) {
        $unreleasedIndex = $orderedLabelList.IndexOf('Unreleased')

        if ($unreleasedIndex -lt 0) {
            $orderedLabelList.Insert(0, 'Unreleased')
            $unreleasedIndex = 0
        }

        $orderedLabelList.Insert($unreleasedIndex + 1, $ReleaseVersion)
    }

    $linkMap[$ReleaseVersion] = $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/Resolve-KeepAChangelogReleaseData.ps1
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

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

    $parts = Split-KeepAChangelogText -Text $Text
    $unreleasedSectionMatch = Get-UnreleasedSectionMatch -Text $parts.Body
    $normalizedRepositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) {
        $null
    } else {
        $RepositoryUrl.TrimEnd('/')
    }
    $unreleasedCompareLinkPrefix = $validation.UnreleasedCompareLinkPrefix
    $previousReleaseReference = $validation.PreviousReleaseReference

    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/"
    } elseif ([string]::IsNullOrWhiteSpace($normalizedRepositoryUrl)) {
        $normalizedRepositoryUrl = $unreleasedCompareLinkPrefix -replace '/compare/$', ''
    }

    $unreleasedBody = $unreleasedSectionMatch.Groups['body'].Value.Trim()
    $releaseNotesBody = Get-ChangelogReleaseNotesBody -Body $unreleasedBody
    $clearedUnreleasedBody = Get-ClearedUnreleasedBody -Body $unreleasedBody

    $unreleasedSectionLineList = [System.Collections.Generic.List[string]]::new()
    $unreleasedSectionLineList.Add('## [Unreleased]')

    if (-not [string]::IsNullOrWhiteSpace($clearedUnreleasedBody)) {
        $unreleasedSectionLineList.Add('')
        $unreleasedSectionLineList.Add($clearedUnreleasedBody)
    }

    $newReleaseSectionLineList = [System.Collections.Generic.List[string]]::new()
    $newReleaseSectionLineList.Add("## [$($normalizedRelease.Version)] - $($normalizedRelease.Date)")

    if (-not [string]::IsNullOrWhiteSpace($releaseNotesBody)) {
        $newReleaseSectionLineList.Add('')
        $newReleaseSectionLineList.Add($releaseNotesBody)
    }

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

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

    foreach ($section in @(
            $beforeUnreleased
            ($unreleasedSectionLineList -join "`n")
            ($newReleaseSectionLineList -join "`n")
            $afterUnreleased
        )) {
        if ([string]::IsNullOrWhiteSpace($section)) {
            continue
        }

        $bodySectionList.Add($section.TrimEnd())
    }

    $updatedBody = ($bodySectionList -join "`n`n").TrimEnd()
    $updatedFooterData = Get-UpdatedChangelogReferenceFooter `
        -Footer $parts.Footer `
        -ReleaseVersion $normalizedRelease.Version `
        -ReleaseTag $normalizedRelease.Tag `
        -UnreleasedCompareLinkPrefix $unreleasedCompareLinkPrefix `
        -PreviousReleaseReference $previousReleaseReference `
        -RepositoryUrl $normalizedRepositoryUrl
    $updatedText = ($updatedBody + "`n`n" + $updatedFooterData.Footer).TrimEnd() + "`n"
    $newReleaseSection = ($newReleaseSectionLineList -join "`n").TrimEnd()

    return [pscustomobject]@{
        Release                     = $normalizedRelease
        UnreleasedCompareLinkPrefix = $unreleasedCompareLinkPrefix
        PreviousReleaseReference    = $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/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/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 = ''
    }
}